diff --git a/public/js/modals.js b/public/js/modals.js index 1d7d996..1589972 100644 --- a/public/js/modals.js +++ b/public/js/modals.js @@ -50,11 +50,53 @@ function closeDetail() { document.body.style.overflow = ""; } +// 폼 모달의 공개범위 토글을 지정 값으로 맞춘다(등록/수정 공용). +function setVis(m, isPublic) { + const input = $('input[name="is_public"]', m); + if (input) input.value = isPublic ? "1" : "0"; + $$("[data-vis]", m).forEach((b) => { + const pub = b.getAttribute("data-vis") === "public"; + b.classList.toggle("on-public", pub && isPublic); + b.classList.toggle("on-private", !pub && !isPublic); + }); + const hint = $(".share-vishint", m); + if (hint) hint.textContent = isPublic ? "피드에 공개되어 동료가 볼 수 있어요" : "나만 볼 수 있어요 · 언제든 공유로 전환 가능"; +} +function fieldSet(m, name, value) { + const el = $('[name="' + name + '"]', m); + if (el) el.value = value == null ? "" : value; +} + +// 등록 모드로 초기화해서 연다. function openShare() { const m = $("#shareModal"); if (!m) return; + const form = $("form", m); if (form) form.setAttribute("action", "/projects"); + const h = $("#shareTitle", m); if (h) h.textContent = "새 프로젝트 등록"; + const sub = $("#shareSubmit", m); if (sub) sub.textContent = "등록"; + ["title", "description", "tags", "repo_url", "app_url", "lang", "return_to"].forEach((n) => fieldSet(m, n, "")); + setVis(m, true); m.hidden = false; document.body.style.overflow = "hidden"; const t = $('input[name="title"]', m); if (t) t.focus(); } + +// 수정 모드: 상세의 '수정' 버튼 data-* 로 폼을 채우고 연다. +function openEdit(btn) { + const m = $("#shareModal"); if (!m || !btn) return; + const form = $("form", m); + if (form) form.setAttribute("action", "/projects/" + btn.getAttribute("data-id") + "/edit"); + const h = $("#shareTitle", m); if (h) h.textContent = "프로젝트 수정"; + const sub = $("#shareSubmit", m); if (sub) sub.textContent = "저장"; + fieldSet(m, "title", btn.getAttribute("data-title")); + fieldSet(m, "description", btn.getAttribute("data-desc")); + fieldSet(m, "tags", btn.getAttribute("data-tags")); + fieldSet(m, "repo_url", btn.getAttribute("data-repo")); + fieldSet(m, "app_url", btn.getAttribute("data-app")); + fieldSet(m, "lang", btn.getAttribute("data-lang")); + fieldSet(m, "return_to", btn.getAttribute("data-return")); + setVis(m, btn.getAttribute("data-public") === "1"); + m.hidden = false; document.body.style.overflow = "hidden"; + const t = $('input[name="title"]', m); if (t) { t.focus(); t.select(); } +} function closeShare() { const m = $("#shareModal"); if (!m || m.hidden) return; m.hidden = true; document.body.style.overflow = ""; @@ -71,6 +113,8 @@ 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; } + const editBtn = e.target.closest("[data-open-edit]"); + if (editBtn) { e.preventDefault(); openEdit(editBtn); return; } if (e.target.closest("[data-close-share]")) { e.preventDefault(); closeShare(); return; } if (e.target.closest("#readmeImport")) { e.preventDefault(); importReadme(); return; } diff --git a/src/db.js b/src/db.js index 4ece6e3..9c6260a 100644 --- a/src/db.js +++ b/src/db.js @@ -116,6 +116,17 @@ export async function addProject({ owner, title, description, repoUrl, appUrl, t return rows[0].id; } +// 본인 프로젝트 수정. 적용 행 수 반환(0이면 권한 없음/없음). 소유자 조건으로 타인 수정 차단. +export async function updateProject({ id, sabun, title, description, repoUrl, appUrl, tags, isPublic, lang }) { + const { rowCount } = await q( + `UPDATE projects + SET title=$3, description=$4, repo_url=$5, app_url=$6, tags=$7, is_public=$8, lang=$9, updated_at=now() + WHERE id=$1 AND owner_sabun=$2`, + [id, sabun, title, description || "", repoUrl || "", appUrl || "", tags || "", isPublic !== false, lang || ""] + ); + return rowCount; +} + // 본인 프로젝트 삭제. 삭제 행 수 반환(0이면 권한 없음/없음). export async function deleteProject({ id, sabun }) { const { rowCount } = await q( diff --git a/src/helpers.js b/src/helpers.js index b1df278..f97b85d 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -28,6 +28,7 @@ const OCTICONS = { ext: '', trash: '', book: '', + pencil: '', }; // 인라인 SVG 아이콘 문자열. fill 기본은 currentColor(부모 색 상속). diff --git a/src/server.js b/src/server.js index d23de3c..915abe8 100644 --- a/src/server.js +++ b/src/server.js @@ -179,6 +179,36 @@ app.post("/projects", requireAuth, async (req, res) => { res.redirect("/?toast=" + encodeURIComponent(msg)); }); +// --- 프로젝트 수정 (본인) --- 등록과 동일한 필드 파싱. 소유자 조건은 db.updateProject 에서 강제. +app.post("/projects/:id/edit", requireAuth, async (req, res) => { + const { title, description, repo_url, app_url, tags } = req.body; + const back = safeBack(req.body.return_to, "/"); + const sep = back.includes("?") ? "&" : "?"; + if (!title || !title.trim()) { + return res.redirect(back + sep + "toast=" + encodeURIComponent("제목을 입력해주세요")); + } + const tagArr = String(tags || "") + .split(",") + .map((t) => t.trim().replace(/^#/, "")) + .filter(Boolean) + .slice(0, 5); + const repoUrl = (repo_url || "").trim(); + const lang = (req.body.lang || "").trim() || (await fetchMainLang(repoUrl)) || langFromTags(tagArr); + const n = await db.updateProject({ + id: req.params.id, + sabun: req.session.user.sabun, + title: title.trim(), + description: (description || "").trim(), + repoUrl, + appUrl: (app_url || "").trim(), + tags: tagArr.join(", "), + isPublic: req.body.is_public !== "0", + lang, + }); + const msg = n ? "수정되었습니다" : "수정 권한이 없습니다"; + res.redirect(back + sep + "toast=" + encodeURIComponent(msg)); +}); + // --- 단독 상세 페이지 (모달의 비-JS 폴백 / 공유 링크) --- app.get("/projects/:id", requireAuth, async (req, res) => { const me = req.session.user.sabun; diff --git a/views/_project_detail.ejs b/views/_project_detail.ejs index 13dbc64..4b349da 100644 --- a/views/_project_detail.ejs +++ b/views/_project_detail.ejs @@ -72,6 +72,16 @@ <% } %> <% if (p.owner_sabun === me) { %> - - + +<%- include("_project_form_modal") %> diff --git a/views/project.ejs b/views/project.ejs index a441cb6..b46b9ba 100644 --- a/views/project.ejs +++ b/views/project.ejs @@ -6,6 +6,10 @@ + + +<%- include("_project_form_modal") %> +