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>
This commit is contained in:
2026-07-02 14:18:27 +09:00
parent 4a3e5636c8
commit da1653a8ea
10 changed files with 232 additions and 11 deletions

View File

@@ -1,7 +1,30 @@
// 상세 모달 · 공유(등록) 모달 — 열기/닫기 및 공개범위 토글.
import { $, $$ } from "./util.js";
import { $, $$, toast } from "./util.js";
import { applyReactions } from "./reactions.js";
// 등록 모달의 "저장소 README 가져오기" — repo_url 의 README 를 받아 설명란을 채운다.
async function importReadme() {
const m = $("#shareModal"); if (!m) return;
const btn = $("#readmeImport", m);
const repoInput = $('input[name="repo_url"]', m);
const ta = $('textarea[name="description"]', m);
const repo = ((repoInput && repoInput.value) || "").trim();
if (!repo) { toast("먼저 저장소 URL 을 입력하세요"); if (repoInput) repoInput.focus(); return; }
if (ta && ta.value.trim() && !window.confirm("현재 설명 내용을 README 로 덮어쓸까요?")) return;
const label = btn ? btn.innerHTML : "";
if (btn) { btn.disabled = true; btn.textContent = "가져오는 중…"; }
try {
const res = await fetch("/api/readme?repo_url=" + encodeURIComponent(repo), { headers: { "X-Requested-With": "fetch" } });
const data = res.ok ? await res.json() : null;
if (data && data.content && ta) { ta.value = data.content; toast("README 를 가져왔어요"); ta.focus(); }
else toast("README 를 찾지 못했어요 · URL 을 확인해 주세요");
} catch (e) {
toast("가져오기에 실패했어요");
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = label; }
}
}
export function openDetail(id) {
const tpl = $('#detailStore template[data-detail-id="' + id + '"]');
const overlay = $("#detailModal");
@@ -49,6 +72,7 @@ export function initModals() {
if (e.target.closest("[data-close-detail]")) { e.preventDefault(); closeDetail(); return; }
if (e.target.closest("[data-open-share]")) { e.preventDefault(); openShare(); return; }
if (e.target.closest("[data-close-share]")) { e.preventDefault(); closeShare(); return; }
if (e.target.closest("#readmeImport")) { e.preventDefault(); importReadme(); return; }
const detail = $("#detailModal");
if (detail && !detail.hidden && e.target === detail) { closeDetail(); return; }