feat(server): 라우트 전면 개편 — 반응·가시성·댓글/대댓글·삭제

- GET / : 내 프로젝트/피드 분리, role 도구 필터, 댓글 일괄 조회(commentsByProject)
- 반응(heart/star) AJAX JSON, visibility/delete(본인), 댓글/대댓글(parent_id)
- 댓글 LGTM/🚀 egg 신호 + return_to 복귀, isAjax/safeBack 헬퍼
- res.locals 로 icon/identicon/langColor 노출
- db: listCommentsByProjects 추가, _header: body 시작 신호(toast/egg/open)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyemin Lee
2026-06-29 17:26:37 +09:00
parent 6a9cf46db8
commit ace46d2fb8
3 changed files with 115 additions and 32 deletions

View File

@@ -57,6 +57,18 @@ export async function listComments(projectId) {
return rows;
}
// 여러 프로젝트의 댓글을 한 번에 (대시보드 모달 저장소용). parent_id 포함.
export async function listCommentsByProjects(ids) {
if (!ids || !ids.length) return [];
const { rows } = await q(
`SELECT c.*, u.name AS author_name
FROM comments c JOIN users u ON u.sabun=c.author_sabun
WHERE c.project_id = ANY($1::bigint[]) ORDER BY c.created_at ASC`,
[ids]
);
return rows;
}
export async function addProject({ owner, title, description, repoUrl, appUrl, tags, isPublic, lang }) {
const { rows } = await q(
`INSERT INTO projects (owner_sabun, title, description, repo_url, app_url, tags, is_public, lang)

View File

@@ -4,8 +4,9 @@ import cookieParser from "cookie-parser";
import { fileURLToPath } from "url";
import path from "path";
import { config } from "./config.js";
import { siteGroups } from "./sites.js";
import { toolsForUser } from "./sites.js";
import { initOidc, loginRedirect, handleCallback, requireAuth, oidcReady } from "./auth.js";
import { icon, identicon, langColor, langFromTags } from "./helpers.js";
import * as db from "./db.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -26,22 +27,30 @@ app.use(
})
);
// 모든 뷰 공통 노출
// 모든 뷰 공통 노출 (브랜드·사용자·뷰 헬퍼)
app.use((req, res, next) => {
res.locals.brand = config.brand;
res.locals.user = req.session.user || null;
res.locals.icon = icon;
res.locals.identicon = identicon;
res.locals.langColor = langColor;
next();
});
app.get("/healthz", (_req, res) => res.json({ ok: true }));
// fetch(AJAX) 요청인지: app.js 가 X-Requested-With 를 붙인다.
const isAjax = (req) =>
req.get("X-Requested-With") === "fetch" || (req.get("Accept") || "").includes("application/json");
// 로컬 경로만 허용하는 안전한 리다이렉트 대상.
const safeBack = (v, fallback) => (typeof v === "string" && v.startsWith("/") ? v : fallback);
// --- 인증 ---
// SSO 세션이 없으면 중간 페이지 없이 바로 Keycloak(커스텀 테마) 로그인으로 보낸다.
// (OIDC 초기화 실패 등으로 로그인 불가하면 안내용 login.ejs 로 폴백)
// SSO 세션이 없으면 중간 페이지 없이 바로 Keycloak 로그인으로. (실패 시 login.ejs 폴백)
app.get("/login", (req, res) => {
if (req.session.user) return res.redirect("/");
if (req.query.error) return res.render("login", { error: req.query.error });
// OIDC 준비됐으면 중간 화면 없이 곧장 Keycloak 로그인 폼으로
if (oidcReady()) return loginRedirect(req, res);
return res.render("login", { error: "로그인 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요." });
});
@@ -50,7 +59,7 @@ app.get("/auth/callback", async (req, res) => {
try {
const u = await handleCallback(req);
await db.upsertUser(u);
req.session.user = u;
req.session.user = u; // { sabun, name, email, roles, isAdmin }
res.redirect("/");
} catch (e) {
console.error("oidc callback error", e);
@@ -61,49 +70,111 @@ app.get("/logout", (req, res) => {
req.session.destroy(() => res.redirect("/login"));
});
// --- 대시보드 (로그인 필요) ---
// --- 대시보드 ---
app.get("/", requireAuth, async (req, res) => {
const projects = await db.listProjects();
res.render("dashboard", { siteGroups, projects });
const me = req.session.user.sabun;
const projects = await db.listProjects(me);
const myProjects = projects.filter((p) => p.owner_sabun === me);
const feedProjects = projects.filter((p) => p.is_public);
// 모달 저장소용: 열람 가능한 모든 프로젝트의 댓글을 한 번에 받아 프로젝트별로 묶는다.
const allComments = await db.listCommentsByProjects(projects.map((p) => p.id));
const commentsByProject = {};
for (const c of allComments) (commentsByProject[c.project_id] ||= []).push(c);
res.render("dashboard", {
tools: toolsForUser(req.session.user.isAdmin),
myProjects,
feedProjects,
allProjects: projects,
commentsByProject,
toast: typeof req.query.toast === "string" ? req.query.toast : "",
egg: typeof req.query.egg === "string" ? req.query.egg : "",
openModal: typeof req.query.open === "string" ? req.query.open : "",
});
});
// --- 프로젝트 ---
// --- 프로젝트 생성 ---
app.post("/projects", requireAuth, async (req, res) => {
const { title, description, repo_url, app_url, tags } = req.body;
if (!title || !title.trim()) return res.redirect("/");
if (!title || !title.trim()) return res.redirect("/?toast=" + encodeURIComponent("제목을 입력해주세요"));
const tagArr = String(tags || "")
.split(",")
.map((t) => t.trim().replace(/^#/, ""))
.filter(Boolean)
.slice(0, 5);
await db.addProject({
owner: req.session.user.sabun,
title: title.trim(),
description,
repoUrl: repo_url,
appUrl: app_url,
tags,
description: (description || "").trim(),
repoUrl: (repo_url || "").trim(),
appUrl: (app_url || "").trim(),
tags: tagArr.join(", "),
isPublic: req.body.is_public !== "0",
lang: langFromTags(tagArr),
});
res.redirect("/");
const msg = req.body.is_public === "0" ? "비공개로 등록됐습니다" : "등록 완료 · 피드에 공유됐습니다";
res.redirect("/?toast=" + encodeURIComponent(msg));
});
// --- 단독 상세 페이지 (모달의 비-JS 폴백 / 공유 링크) ---
app.get("/projects/:id", requireAuth, async (req, res) => {
const project = await db.getProject(req.params.id);
if (!project) return res.status(404).send("not found");
const me = req.session.user.sabun;
const project = await db.getProject(req.params.id, me);
if (!project) return res.status(404).send("프로젝트를 찾을 수 없습니다.");
// 비공개는 본인만 열람
if (!project.is_public && project.owner_sabun !== me) return res.status(403).send("비공개 프로젝트입니다.");
const comments = await db.listComments(project.id);
const starred = await db.isStarred({ projectId: project.id, sabun: req.session.user.sabun });
res.render("project", { project, comments, starred });
res.render("project", { project, comments, starred: project.starred });
});
// --- 반응: ❤️ 좋아요 / ⭐ 즐겨찾기 ---
async function reactionHandler(kind, req, res) {
const result = await (kind === "heart"
? db.toggleHeart({ projectId: req.params.id, sabun: req.session.user.sabun })
: db.toggleStar({ projectId: req.params.id, sabun: req.session.user.sabun }));
if (isAjax(req)) return res.json(result);
res.redirect(safeBack(req.get("Referer"), "/"));
}
app.post("/projects/:id/heart", requireAuth, (req, res) => reactionHandler("heart", req, res));
app.post("/projects/:id/star", requireAuth, (req, res) => reactionHandler("star", req, res));
// --- 공개/비공개 전환 (본인) ---
app.post("/projects/:id/visibility", requireAuth, async (req, res) => {
const isPublic = req.body.is_public === "1";
await db.setVisibility({ id: req.params.id, sabun: req.session.user.sabun, isPublic });
const msg = isPublic ? "공개로 전환됐습니다 · 피드에 표시됩니다" : "비공개로 전환됐습니다";
res.redirect("/?toast=" + encodeURIComponent(msg));
});
// --- 프로젝트 삭제 (본인) ---
app.post("/projects/:id/delete", requireAuth, async (req, res) => {
const n = await db.deleteProject({ id: req.params.id, sabun: req.session.user.sabun });
res.redirect("/?toast=" + encodeURIComponent(n ? "삭제됐습니다" : "삭제 권한이 없습니다"));
});
// --- 댓글/대댓글 작성 ---
app.post("/projects/:id/comments", requireAuth, async (req, res) => {
if (req.body.body && req.body.body.trim()) {
await db.addComment({
projectId: req.params.id,
author: req.session.user.sabun,
body: req.body.body.trim(),
});
}
res.redirect(`/projects/${req.params.id}`);
const back = safeBack(req.body.return_to, `/projects/${req.params.id}`);
const body = (req.body.body || "").trim();
if (!body) return res.redirect(back);
const parentId = req.body.parent_id ? Number(req.body.parent_id) : null;
await db.addComment({
projectId: req.params.id,
author: req.session.user.sabun,
body,
parentId: parentId || null,
});
// 이스터에그 신호
let egg = "";
if (/\blgtm\b/i.test(body)) egg = "lgtm";
else if (body.indexOf("🚀") >= 0 || body.toLowerCase().indexOf(":shipit:") >= 0) egg = "rocket";
const sep = back.includes("?") ? "&" : "?";
res.redirect(egg ? back + sep + "egg=" + egg : back);
});
app.post("/projects/:id/star", requireAuth, async (req, res) => {
await db.toggleStar({ projectId: req.params.id, sabun: req.session.user.sabun });
res.redirect(`/projects/${req.params.id}`);
// --- 댓글 삭제 (본인) ---
app.post("/comments/:id/delete", requireAuth, async (req, res) => {
await db.deleteComment({ id: req.params.id, sabun: req.session.user.sabun });
res.redirect(safeBack(req.body.return_to, req.get("Referer") || "/"));
});
// --- 부팅 ---