feat(ui): 상세/공유 모달과 단독 상세·로그인 페이지

- _project_detail.ejs: 모달/단독 공용 상세(헤더·반응·바로가기·댓글 스레드·입력)
- dashboard: 상세 모달 오버레이 + 프로젝트별 template 저장소 + 새 프로젝트 등록 모달
- project.ejs(단독)·login.ejs 새 디자인으로 재작성

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyemin Lee
2026-06-29 17:26:50 +09:00
parent ace46d2fb8
commit 42542a0912
4 changed files with 200 additions and 45 deletions

134
views/_project_detail.ejs Normal file
View File

@@ -0,0 +1,134 @@
<%
// 상세(모달/단독 페이지 공용) — locals: p, comments[], me, returnTo, modal(bool)
function tagList(s) { return String(s || "").split(",").map(function (t) { return t.trim(); }).filter(Boolean); }
function reltime(d) {
var s = Math.floor((Date.now() - new Date(d).getTime()) / 1000);
if (s < 60) return "방금 전";
var m = Math.floor(s / 60); if (m < 60) return m + "분 전";
var h = Math.floor(m / 60); if (h < 24) return h + "시간 전";
var dd = Math.floor(h / 24); if (dd < 7) return dd + "일 전";
var x = new Date(d); return x.getFullYear() + "." + String(x.getMonth() + 1).padStart(2, "0") + "." + String(x.getDate()).padStart(2, "0");
}
function isLgtm(b) { return /\blgtm\b/i.test(b || ""); }
var pdTags = tagList(p.tags);
var tops = comments.filter(function (c) { return !c.parent_id; });
var repliesOf = function (id) { return comments.filter(function (c) { return String(c.parent_id) === String(id); }); };
%>
<div class="modal-head">
<div class="modal-title-row">
<div class="modal-titlewrap">
<div class="modal-title-line">
<span class="modal-repoico"><%- icon("repo", { size: 18 }) %></span>
<h3 class="modal-title"><%= p.title %></h3>
<span class="vis-badge <%= p.is_public ? "public" : "private" %>"><%- icon(p.is_public ? "globe" : "lock", { size: 11 }) %><%= p.is_public ? "공개" : "비공개" %></span>
</div>
<div class="modal-meta">
<%- identicon(p.owner_sabun, 24) %>
<span class="sabun"><%= p.owner_sabun %></span>
<span class="date">· <%= reltime(p.created_at) %> 공유</span>
<% if (p.lang) { %><span class="lang"><span class="lang-dot" style="background:<%= langColor(p.lang) %>"></span><%= p.lang %></span><% } %>
</div>
</div>
<% if (modal) { %>
<button type="button" class="modal-close" data-close-detail title="닫기"><%- icon("x", { size: 16 }) %></button>
<% } else { %>
<a class="back-link" href="/">← 대시보드</a>
<% } %>
</div>
<% if (pdTags.length) { %>
<div class="modal-tags"><% pdTags.forEach(function (t) { %><span class="tag"><%= t %></span><% }); %></div>
<% } %>
<p class="modal-desc"><%= p.description || "설명 없음" %></p>
<div class="modal-actions">
<button type="button" class="modal-action heart<%= p.hearted ? " on" : "" %>" data-react data-id="<%= p.id %>" data-kind="heart">
<span class="ico ico-on"><%- icon("heartFill", { size: 15 }) %></span><span class="ico ico-off"><%- icon("heart", { size: 15 }) %></span>
<span>좋아요</span><span class="count" data-rcount><%= p.heart_count %></span>
</button>
<button type="button" class="modal-action star<%= p.starred ? " on" : "" %>" data-react data-id="<%= p.id %>" data-kind="star">
<span class="ico ico-on"><%- icon("starFill", { size: 15 }) %></span><span class="ico ico-off"><%- icon("star", { size: 15 }) %></span>
<span>즐겨찾기</span><span class="count" data-rcount><%= p.star_count %></span>
</button>
<% if (p.app_url) { %>
<a class="modal-action primary" href="<%= p.app_url %>" target="_blank" rel="noopener">바로가기 <%- icon("ext", { size: 12, fill: "#fff" }) %></a>
<% } %>
<% if (p.repo_url) { %>
<a class="modal-action ghost" href="<%= p.repo_url %>" target="_blank" rel="noopener"><%- icon("code", { size: 14 }) %>저장소</a>
<% } %>
<% if (p.owner_sabun === me) { %>
<form method="post" action="/projects/<%= p.id %>/delete" style="margin-left:auto">
<input type="hidden" name="return_to" value="/" />
<button type="submit" class="comment-link comment-del" data-confirm="이 프로젝트를 삭제할까요? 댓글·반응도 함께 삭제됩니다." style="font-size:12.5px">프로젝트 삭제</button>
</form>
<% } %>
</div>
</div>
<div class="modal-body">
<div class="comments-title">댓글 <span class="comments-count tabnum"><%= comments.length %></span></div>
<div class="comment-list">
<% tops.forEach(function (c) { var reps = repliesOf(c.id); %>
<div class="comment-item">
<div class="comment">
<%- identicon(c.author_sabun, 28) %>
<div class="comment-main">
<div class="comment-head">
<span class="comment-author"><%= c.author_sabun %></span>
<span class="comment-time"><%= reltime(c.created_at) %></span>
<% if (isLgtm(c.body)) { %><span class="lgtm"><%- icon("check", { size: 11 }) %>LGTM</span><% } %>
</div>
<div class="comment-text"><%= c.body %></div>
<div class="comment-actions">
<button type="button" class="comment-link" data-reply-to="<%= c.id %>" data-reply-name="<%= c.author_sabun %>"><%- icon("reply", { size: 13 }) %>답글</button>
<% if (c.author_sabun === me) { %>
<form method="post" action="/comments/<%= c.id %>/delete" data-stop style="display:inline">
<input type="hidden" name="return_to" value="<%= returnTo %>" />
<button type="submit" class="comment-link comment-del" data-confirm="댓글을 삭제할까요?">삭제</button>
</form>
<% } %>
</div>
</div>
</div>
<% if (reps.length) { %>
<div class="replies">
<% reps.forEach(function (r) { %>
<div class="reply">
<%- identicon(r.author_sabun, 24) %>
<div class="comment-main">
<div class="comment-head">
<span class="comment-author"><%= r.author_sabun %></span>
<span class="comment-time"><%= reltime(r.created_at) %></span>
<% if (isLgtm(r.body)) { %><span class="lgtm"><%- icon("check", { size: 11 }) %>LGTM</span><% } %>
</div>
<div class="comment-text"><%= r.body %></div>
<% if (r.author_sabun === me) { %>
<form method="post" action="/comments/<%= r.id %>/delete" data-stop style="display:inline">
<input type="hidden" name="return_to" value="<%= returnTo %>" />
<button type="submit" class="comment-link comment-del" data-confirm="댓글을 삭제할까요?" style="margin-top:4px">삭제</button>
</form>
<% } %>
</div>
</div>
<% }); %>
</div>
<% } %>
</div>
<% }); %>
</div>
</div>
<div class="modal-foot">
<form method="post" action="/projects/<%= p.id %>/comments">
<input type="hidden" name="parent_id" value="" />
<input type="hidden" name="return_to" value="<%= returnTo %>" />
<div class="reply-banner" hidden>
<span><b class="reply-banner-name"></b> 님에게 답글 작성 중</span>
<button type="button" data-cancel-reply>취소</button>
</div>
<div class="comment-form">
<%- identicon(me, 28) %>
<textarea class="comment-input" name="body" rows="1" placeholder="댓글을 입력하세요…"></textarea>
<button type="submit" class="submit-btn">등록</button>
</div>
<div class="comment-hint">Enter 등록 · Shift+Enter 줄바꿈 · 대댓글은 1뎁스까지만 가능합니다</div>
</form>
</div>

View File

@@ -184,7 +184,63 @@
</div> </div>
</footer> </footer>
<!-- 상세/공유 모달 + 상세 저장소는 Phase 5 에서 이 아래에 추가 --> <!-- 상세 모달 (app.js 가 detailStore 의 내용을 .modal 에 주입) -->
<div class="modal-overlay" id="detailModal" hidden><div class="modal"></div></div>
<!-- 상세 저장소: 열람 가능한 프로젝트별 모달 내용을 template 으로 보관 -->
<div id="detailStore" style="display:none">
<% allProjects.forEach(function (p) { %>
<template data-detail-id="<%= p.id %>"><%- include("_project_detail", { p: p, comments: commentsByProject[p.id] || [], me: user.sabun, returnTo: "/?open=" + p.id, modal: true }) %></template>
<% }); %>
</div>
<!-- 새 프로젝트 등록 모달 -->
<div class="modal-overlay" id="shareModal" hidden>
<div class="share-modal">
<div class="share-head">
<h3>새 프로젝트 등록</h3>
<button type="button" class="modal-close" data-close-share title="닫기"><%- icon("x", { size: 16 }) %></button>
</div>
<form method="post" action="/projects">
<div class="share-body">
<label>
<span class="share-label">제목 <span class="req">*</span></span>
<input class="share-input" name="title" required placeholder="예) 방화벽 정책 DSL 변환기" />
</label>
<label>
<span class="share-label">한 줄 설명</span>
<input class="share-input" name="description" placeholder="무엇을 하는 프로젝트인가요?" />
</label>
<label>
<span class="share-label">태그 <span class="hint">쉼표로 구분 · 최대 5개</span></span>
<input class="share-input" name="tags" placeholder="infra, react, poc" />
</label>
<label>
<span class="share-label">저장소 URL <span class="hint">선택</span></span>
<input class="share-input mono" name="repo_url" placeholder="https://gitea.bokdev.in/사번/repo" />
</label>
<label>
<span class="share-label">배포 URL <span class="hint">선택 · 없으면 바로가기 숨김</span></span>
<input class="share-input mono" name="app_url" placeholder="https://myapp.apps.bokdev.in" />
</label>
<div>
<span class="share-label">공개 범위</span>
<div class="vis-toggle">
<button type="button" class="vis-opt on-public" data-vis="public"><%- icon("globe", { size: 12 }) %>공개</button>
<button type="button" class="vis-opt" data-vis="private"><%- icon("lock", { size: 12 }) %>비공개</button>
</div>
<input type="hidden" name="is_public" value="1" />
<div class="share-vishint">피드에 공개되어 동료가 볼 수 있어요</div>
</div>
</div>
<div class="modal-foot-actions">
<button type="button" class="btn" data-close-share>취소</button>
<button type="submit" class="btn btn-success">등록</button>
</div>
</form>
</div>
</div>
<script src="/public/app.js"></script> <script src="/public/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,12 +1,12 @@
<%- include("_header") %> <%- include("_header") %>
<div class="login-wrap"> <div class="login-wrap">
<div class="box login-card"> <div class="login-card">
<div class="login-logo">🧠</div> <img class="login-logo" src="/public/logo.svg" width="56" height="56" alt="" />
<div class="login-title"><%= brand %></div> <div class="login-title"><%= brand %></div>
<% if (typeof error !== "undefined" && error) { %> <% if (typeof error !== "undefined" && error) { %>
<div class="alert"><%= error %></div> <div class="alert"><%= error %></div>
<% } %> <% } %>
<a class="btn btn-primary" style="width:100%" href="/auth/login">Keycloak 으로 로그인</a> <a class="btn btn-primary" style="width:100%;justify-content:center" href="/auth/login">Keycloak 으로 로그인</a>
<p class="muted" style="margin-top:16px">사번으로 로그인합니다. (사내 SSO)</p> <p class="muted" style="margin-top:16px">사번으로 로그인합니다. (사내 SSO)</p>
</div> </div>
</div> </div>

View File

@@ -1,46 +1,11 @@
<%- include("_header") %> <%- include("_header") %>
<div class="container" style="max-width:760px"> <main class="main">
<a href="/" class="muted">← 대시보드</a> <div class="detail-wrap">
<div class="box" style="margin-top:12px"> <div class="modal" style="margin:0 auto;animation:none">
<div class="box-body"> <%- include("_project_detail", { p: project, comments: comments, me: user.sabun, returnTo: "/projects/" + project.id, modal: false }) %>
<h2 style="margin:0 0 4px"><%= project.title %></h2>
<div class="proj-meta"><%= project.owner_name %> 님이 공유 · <%= new Date(project.created_at).toLocaleDateString("ko-KR") %></div>
<% if (project.description) { %><p style="margin-top:12px; white-space:pre-wrap"><%= project.description %></p><% } %>
<div style="margin-top:12px; display:flex; gap:8px; align-items:center; flex-wrap:wrap">
<% if (project.repo_url) { %><a class="btn btn-sm" href="<%= project.repo_url %>" target="_blank">🍵 Repo</a><% } %>
<% if (project.app_url) { %><a class="btn btn-sm" href="<%= project.app_url %>" target="_blank">🚀 앱 열기</a><% } %>
<form method="post" action="/projects/<%= project.id %>/star" style="display:inline">
<button class="btn btn-sm" type="submit"><%= starred ? "★ 스타 취소" : "☆ 스타" %> (<%= project.star_count %>)</button>
</form>
</div>
<% if (project.tags) { %>
<div style="margin-top:12px">
<% project.tags.split(",").map(t=>t.trim()).filter(Boolean).forEach(function(t){ %>
<span class="tag"><%= t %></span>
<% }); %>
</div>
<% } %>
</div>
</div>
<h3 style="margin:24px 0 12px">💬 코멘트 (<%= comments.length %>)</h3>
<% comments.forEach(function(c){ %>
<div class="comment">
<div class="comment-head"><strong><%= c.author_name %></strong> · <%= new Date(c.created_at).toLocaleString("ko-KR") %></div>
<div class="comment-body"><%= c.body %></div>
</div>
<% }); %>
<div class="box" style="margin-top:16px">
<div class="box-body">
<form method="post" action="/projects/<%= project.id %>/comments">
<textarea name="body" placeholder="코멘트를 남겨보세요" required></textarea>
<div style="margin-top:8px; text-align:right">
<button class="btn btn-primary" type="submit">코멘트 등록</button>
</div>
</form>
</div>
</div> </div>
</div> </div>
</main>
<script src="/public/app.js"></script>
</body> </body>
</html> </html>