Files
ai-dev-portal/views/_project_detail.ejs
2620227 da1653a8ea feat(projects): 설명 마크다운 렌더링 + 저장소 README 가져오기
등록 모달의 '한 줄 설명' input 을 여러 줄 textarea 로 바꾸고, 설명을
markdown-it 로 렌더링한다(html:false 로 raw HTML 이스케이프·javascript:
링크 차단 — XSS 안전). 상세 화면은 마크다운으로, 카드 미리보기는 기호를
걷어낸 순수 텍스트(mdPlain) + 3줄 클램프로 표시.

'저장소 README 가져오기' 버튼과 GET /api/readme 라우트를 추가해 repo_url
의 Gitea README 를 설명란으로 불러올 수 있다(gitea.js fetchReadme).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:18:27 +09:00

144 lines
7.9 KiB
Plaintext

<%
// 상세(모달/단독 페이지 공용) — 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" title="<%= p.owner_sabun %>"><%= p.owner_name || 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>
<% } %>
<% if (p.description && p.description.trim()) { %>
<div class="modal-desc md-body"><%- renderMarkdown(p.description) %></div>
<% } else { %>
<p class="modal-desc modal-desc-empty">설명 없음</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 %>/visibility" style="margin-left:auto">
<input type="hidden" name="is_public" value="<%= p.is_public ? "0" : "1" %>" />
<input type="hidden" name="return_to" value="<%= returnTo %>" />
<button type="submit" class="modal-action"><%- icon(p.is_public ? "lock" : "globe", { size: 14 }) %><%= p.is_public ? "비공개로 전환" : "공개로 전환" %></button>
</form>
<form method="post" action="/projects/<%= p.id %>/delete">
<input type="hidden" name="return_to" value="/" />
<button type="submit" class="comment-link comment-del" data-confirm="이 프로젝트를 삭제할까요? 댓글·반응도 함께 삭제됩니다." style="font-size:12.5px"><%- icon("trash", { size: 13 }) %>프로젝트 삭제</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" title="<%= c.author_sabun %>"><%= c.author_name || 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_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" title="<%= r.author_sabun %>"><%= r.author_name || 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>