feat(projects): 등록한 프로젝트 수정 기능 추가

- 상세(모달/단독) 소유자 영역에 '수정' 버튼 → 등록 모달을 현재 값으로 채워 재사용
- POST /projects/:id/edit 라우트 + db.updateProject(소유자 조건으로 타인 수정 차단)
- 등록/수정 공용 모달을 _project_form_modal.ejs 로 추출(dashboard·project 에서 include)
- pencil 아이콘 추가, modals.js 에 openEdit/openShare 초기화 로직

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 16:55:09 +09:00
parent 459f37608e
commit 7fe3a8fead
8 changed files with 168 additions and 62 deletions

View File

@@ -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; }

View File

@@ -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(

View File

@@ -28,6 +28,7 @@ const OCTICONS = {
ext: '<path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z"/>',
trash: '<path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z"/>',
book: '<path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z"/>',
pencil: '<path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z"/>',
};
// 인라인 SVG 아이콘 문자열. fill 기본은 currentColor(부모 색 상속).

View File

@@ -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;

View File

@@ -72,6 +72,16 @@
<% } %>
<% if (p.owner_sabun === me) { %>
<div class="modal-actions-owner">
<button type="button" class="comment-link" data-open-edit style="font-size:12.5px"
data-id="<%= p.id %>"
data-title="<%= p.title %>"
data-desc="<%= p.description || '' %>"
data-tags="<%= p.tags || '' %>"
data-repo="<%= p.repo_url || '' %>"
data-app="<%= p.app_url || '' %>"
data-lang="<%= p.lang || '' %>"
data-public="<%= p.is_public ? '1' : '0' %>"
data-return="<%= returnTo %>"><%- icon("pencil", { size: 13 }) %>수정</button>
<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>

View File

@@ -0,0 +1,66 @@
<%
// 프로젝트 등록/수정 공용 모달. 기본은 '등록'(action=/projects)이고,
// 상세의 '수정' 버튼을 누르면 modals.js 가 필드/제목/버튼/action 을 수정 모드로 바꿔 연다.
%>
<div class="modal-overlay" id="shareModal" hidden>
<div class="share-modal">
<div class="share-head">
<h3 id="shareTitle">새 프로젝트 등록</h3>
<button type="button" class="modal-close" data-close-share title="닫기"><%- icon("x", { size: 16 }) %></button>
</div>
<form method="post" action="/projects">
<input type="hidden" name="return_to" value="" />
<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 share-label-row">
<span>설명 <span class="hint">Markdown 지원 · 제목·목록·코드·링크</span></span>
<button type="button" class="readme-btn" id="readmeImport"><%- icon("repo", { size: 12 }) %>저장소 README 가져오기</button>
</span>
<textarea class="share-input share-textarea" name="description" rows="5" placeholder="무엇을 하는 프로젝트인가요?&#10;&#10;## 예시&#10;- Markdown 문법을 그대로 쓸 수 있어요&#10;- 저장소 URL 을 넣으면 README 를 가져올 수 있어요"></textarea>
</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">메인 언어 <span class="hint">선택 · 기본은 저장소에서 자동 감지</span></span>
<select class="share-input" name="lang">
<option value="">자동 감지</option>
<option value="Python">Python</option>
<option value="JavaScript">JavaScript</option>
<option value="TypeScript">TypeScript</option>
<option value="Go">Go</option>
<option value="Java">Java</option>
<option value="Rust">Rust</option>
<option value="Shell">Shell</option>
</select>
</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" id="shareSubmit">등록</button>
</div>
</form>
</div>
</div>

View File

@@ -196,68 +196,8 @@
<% }); %>
</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 share-label-row">
<span>설명 <span class="hint">Markdown 지원 · 제목·목록·코드·링크</span></span>
<button type="button" class="readme-btn" id="readmeImport"><%- icon("repo", { size: 12 }) %>저장소 README 가져오기</button>
</span>
<textarea class="share-input share-textarea" name="description" rows="5" placeholder="무엇을 하는 프로젝트인가요?&#10;&#10;## 예시&#10;- Markdown 문법을 그대로 쓸 수 있어요&#10;- 저장소 URL 을 넣으면 README 를 가져올 수 있어요"></textarea>
</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">메인 언어 <span class="hint">선택 · 기본은 저장소에서 자동 감지</span></span>
<select class="share-input" name="lang">
<option value="">자동 감지</option>
<option value="Python">Python</option>
<option value="JavaScript">JavaScript</option>
<option value="TypeScript">TypeScript</option>
<option value="Go">Go</option>
<option value="Java">Java</option>
<option value="Rust">Rust</option>
<option value="Shell">Shell</option>
</select>
</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>
<!-- 새 프로젝트 등록/수정 공용 모달 -->
<%- include("_project_form_modal") %>
<script type="module" src="/public/js/main.js"></script>
</body>

View File

@@ -6,6 +6,10 @@
</div>
</div>
</main>
<!-- 프로젝트 수정 모달 (상세의 '수정' 버튼이 채워서 연다) -->
<%- include("_project_form_modal") %>
<script type="module" src="/public/js/main.js"></script>
</body>
</html>