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:
134
views/_project_detail.ejs
Normal file
134
views/_project_detail.ejs
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user