Files
ai-dev-portal/public/js/modals.js
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

99 lines
4.7 KiB
JavaScript

// 상세 모달 · 공유(등록) 모달 — 열기/닫기 및 공개범위 토글.
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");
if (!tpl || !overlay) { window.location.href = "/projects/" + id; return; }
const mount = $(".modal", overlay) || overlay;
mount.innerHTML = tpl.innerHTML;
// 템플릿은 로드 시점 스냅샷이라 그동안 바뀐 반응 수가 반영돼 있지 않다.
// 마운트 직후 현재 store 값으로 덮어써 카드와 항상 같은 값을 보이게 한다.
applyReactions(mount);
overlay.hidden = false;
document.body.style.overflow = "hidden";
// 주의: 열기 감지 셀렉터가 [data-open-id] 라서 같은 이름을 쓰면 모달 내부 클릭(닫기/삭제)이
// 다시 "열기"로 잡힌다. 마커는 반드시 다른 속성명(data-open-pid)으로 둔다.
overlay.setAttribute("data-open-pid", id);
const ta = $(".comment-input", overlay);
if (ta) ta.focus();
}
function closeDetail() {
const overlay = $("#detailModal");
if (!overlay || overlay.hidden) return;
overlay.hidden = true;
overlay.removeAttribute("data-open-pid");
document.body.style.overflow = "";
}
function openShare() {
const m = $("#shareModal"); if (!m) return;
m.hidden = false; document.body.style.overflow = "hidden";
const t = $('input[name="title"]', m); if (t) t.focus();
}
function closeShare() {
const m = $("#shareModal"); if (!m || m.hidden) return;
m.hidden = true; document.body.style.overflow = "";
}
export function initModals() {
document.addEventListener("click", (e) => {
// 상세 모달 열기 (카드 클릭 — 반응 버튼/링크/data-stop 은 제외)
const opener = e.target.closest("[data-open-id]");
if (opener && !e.target.closest("[data-react]") && !e.target.closest("a") && !e.target.closest("[data-stop]")) {
e.preventDefault();
openDetail(opener.getAttribute("data-open-id"));
return;
}
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; }
const share = $("#shareModal");
if (share && !share.hidden && e.target === share) { closeShare(); return; }
// 공유 모달 공개/비공개 토글
const vo = e.target.closest("[data-vis]");
if (vo && share) {
const val = vo.getAttribute("data-vis");
const input = $('input[name="is_public"]', share);
if (input) input.value = val === "public" ? "1" : "0";
$$("[data-vis]", share).forEach((b) => {
const pub = b.getAttribute("data-vis") === "public";
b.classList.toggle("on-public", pub && val === "public");
b.classList.toggle("on-private", !pub && val === "private");
});
const hint = $(".share-vishint", share);
if (hint) hint.textContent = val === "public" ? "피드에 공개되어 동료가 볼 수 있어요" : "나만 볼 수 있어요 · 언제든 공유로 전환 가능";
}
});
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { closeDetail(); closeShare(); } });
}