From 6bac13006aae67f304abc65d9f0145585e3aed70 Mon Sep 17 00:00:00 2001 From: zenn Date: Tue, 7 Apr 2026 12:44:24 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/index.js | 2 + backend/src/db.js | 272 +++++++++ backend/src/routes/comments.js | 49 ++ backend/src/routes/tierlists.js | 89 ++- docs/history.md | 5 + docs/map.md | 11 +- docs/spec.md | 28 + docs/todo.md | 3 + docs/update.md | 9 + frontend/src/App.vue | 56 +- .../src/components/TierListCommentsCard.vue | 530 ++++++++++++++++++ frontend/src/lib/api.js | 9 + frontend/src/lib/paths.js | 4 + frontend/src/router/index.js | 2 + frontend/src/views/CommentInboxView.vue | 313 +++++++++++ frontend/src/views/TierEditorView.vue | 36 +- 16 files changed, 1403 insertions(+), 15 deletions(-) create mode 100644 backend/src/routes/comments.js create mode 100644 frontend/src/components/TierListCommentsCard.vue create mode 100644 frontend/src/views/CommentInboxView.vue diff --git a/backend/index.js b/backend/index.js index 8912ead..5b60a26 100644 --- a/backend/index.js +++ b/backend/index.js @@ -12,6 +12,7 @@ const { ensureData } = require('./src/db') const authRoutes = require('./src/routes/auth') const topicsRoutes = require('./src/routes/topics') const tierListsRoutes = require('./src/routes/tierlists') +const commentsRoutes = require('./src/routes/comments') const usersRoutes = require('./src/routes/users') const adminRoutes = require('./src/routes/admin') const shareRoutes = require('./src/routes/share') @@ -87,6 +88,7 @@ app.use(async (req, res, next) => { app.use('/api/auth', authRoutes) app.use('/api/topics', topicsRoutes) app.use('/api/tierlists', tierListsRoutes) +app.use('/api/comments', commentsRoutes) app.use('/api/users', usersRoutes) app.use('/api/admin', adminRoutes) app.use('/share', shareRoutes) diff --git a/backend/src/db.js b/backend/src/db.js index b7fa09f..6e081e8 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -241,6 +241,52 @@ function mapTierListRow(row) { } } +function mapTierListCommentRow(row) { + if (!row) return null + return { + id: row.id, + tierListId: row.tierlist_id, + parentCommentId: row.parent_comment_id || '', + authorId: row.author_id, + authorName: getUserDisplayName(row), + authorAccountName: getUserAccountName(row), + authorAvatarSrc: row.avatar_src || '', + content: row.content || '', + createdAt: Number(row.created_at || 0), + updatedAt: Number(row.updated_at || 0), + replies: [], + } +} + +function mapCommentNotificationRow(row) { + if (!row) return null + return { + id: row.id, + userId: row.user_id, + tierListId: row.tierlist_id, + topicId: row.topic_id, + topicSlug: row.topic_slug || row.topic_id, + topicName: row.topic_name || '', + tierListTitle: row.tierlist_title || '', + commentId: row.comment_id, + parentCommentId: row.parent_comment_id || '', + notificationType: row.notification_type || 'tierlist_comment', + isRead: !!row.is_read, + readAt: Number(row.read_at || 0), + createdAt: Number(row.created_at || 0), + actorId: row.actor_user_id, + actorName: getUserDisplayName({ + nickname: row.actor_nickname, + email: row.actor_email, + }), + actorAccountName: getUserAccountName({ + email: row.actor_email, + }), + actorAvatarSrc: row.actor_avatar_src || '', + commentContent: row.comment_content || '', + } +} + function mapTemplateRequestRow(row) { if (!row) return null return { @@ -499,6 +545,43 @@ async function ensureSchema() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) + await query(` + CREATE TABLE IF NOT EXISTS tierlist_comments ( + id VARCHAR(64) PRIMARY KEY, + tierlist_id VARCHAR(64) NOT NULL, + author_id VARCHAR(64) NOT NULL, + parent_comment_id VARCHAR(64) NULL DEFAULT NULL, + content TEXT NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + INDEX idx_tierlist_comments_tierlist_created (tierlist_id, created_at), + INDEX idx_tierlist_comments_parent (parent_comment_id), + CONSTRAINT fk_tierlist_comments_tierlist FOREIGN KEY (tierlist_id) REFERENCES tierlists(id) ON DELETE CASCADE, + CONSTRAINT fk_tierlist_comments_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_tierlist_comments_parent FOREIGN KEY (parent_comment_id) REFERENCES tierlist_comments(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `) + + await query(` + CREATE TABLE IF NOT EXISTS comment_notifications ( + id VARCHAR(64) PRIMARY KEY, + user_id VARCHAR(64) NOT NULL, + tierlist_id VARCHAR(64) NOT NULL, + comment_id VARCHAR(64) NOT NULL, + actor_user_id VARCHAR(64) NOT NULL, + notification_type VARCHAR(32) NOT NULL, + is_read TINYINT(1) NOT NULL DEFAULT 0, + read_at BIGINT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + INDEX idx_comment_notifications_user_read_created (user_id, is_read, created_at), + INDEX idx_comment_notifications_comment (comment_id), + CONSTRAINT fk_comment_notifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_comment_notifications_tierlist FOREIGN KEY (tierlist_id) REFERENCES tierlists(id) ON DELETE CASCADE, + CONSTRAINT fk_comment_notifications_comment FOREIGN KEY (comment_id) REFERENCES tierlist_comments(id) ON DELETE CASCADE, + CONSTRAINT fk_comment_notifications_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `) + await query(` CREATE TABLE IF NOT EXISTS user_follows ( follower_id VARCHAR(64) NOT NULL, @@ -2612,6 +2695,187 @@ async function listFollowingTierLists(userId, queryText = '') { return applyFavoriteMetaToTierLists(tierLists, favoriteStats) } +async function listTierListComments(tierListId) { + const rows = await query( + ` + SELECT + c.id, + c.tierlist_id, + c.author_id, + c.parent_comment_id, + c.content, + c.created_at, + c.updated_at, + u.nickname, + u.email, + u.avatar_src + FROM tierlist_comments c + INNER JOIN users u ON u.id = c.author_id + WHERE c.tierlist_id = ? + ORDER BY c.created_at ASC, c.id ASC + `, + [tierListId] + ) + + const comments = rows.map(mapTierListCommentRow) + const byId = new Map(comments.map((comment) => [comment.id, comment])) + const roots = [] + comments.forEach((comment) => { + if (comment.parentCommentId) { + const parent = byId.get(comment.parentCommentId) + if (parent) { + parent.replies.push(comment) + return + } + } + roots.push(comment) + }) + return roots +} + +async function findTierListCommentById(commentId) { + const rows = await query( + ` + SELECT id, tierlist_id, author_id, parent_comment_id, content, created_at, updated_at + FROM tierlist_comments + WHERE id = ? + LIMIT 1 + `, + [commentId] + ) + const row = rows[0] + if (!row) return null + return { + id: row.id, + tierListId: row.tierlist_id, + authorId: row.author_id, + parentCommentId: row.parent_comment_id || '', + content: row.content || '', + createdAt: Number(row.created_at || 0), + updatedAt: Number(row.updated_at || 0), + } +} + +async function createTierListComment({ tierListId, authorId, parentCommentId = '', content }) { + let parentComment = null + if (parentCommentId) { + parentComment = await findTierListCommentById(parentCommentId) + if (!parentComment || parentComment.tierListId !== tierListId) { + const error = new Error('comment_parent_invalid') + error.code = 'COMMENT_PARENT_INVALID' + throw error + } + if (parentComment.parentCommentId) { + const error = new Error('comment_reply_depth_invalid') + error.code = 'COMMENT_REPLY_DEPTH_INVALID' + throw error + } + } + + const commentId = nanoid() + const createdAt = now() + await query( + ` + INSERT INTO tierlist_comments (id, tierlist_id, author_id, parent_comment_id, content, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, + [commentId, tierListId, authorId, parentComment ? parentComment.id : null, String(content || '').trim(), createdAt, createdAt] + ) + return commentId +} + +async function deleteTierListComment(commentId) { + await query('DELETE FROM tierlist_comments WHERE id = ?', [commentId]) +} + +async function createCommentNotificationsForComment(commentId) { + const comment = await findTierListCommentById(commentId) + if (!comment) return + + const tierList = await findTierListById(comment.tierListId) + if (!tierList) return + + const recipientMap = new Map() + if (tierList.authorId && tierList.authorId !== comment.authorId) { + recipientMap.set(tierList.authorId, 'tierlist_comment') + } + + if (comment.parentCommentId) { + const parentComment = await findTierListCommentById(comment.parentCommentId) + if (parentComment && parentComment.authorId && parentComment.authorId !== comment.authorId) { + recipientMap.set(parentComment.authorId, 'comment_reply') + } + } + + await Promise.all( + Array.from(recipientMap.entries()).map(([userId, notificationType]) => + query( + ` + INSERT INTO comment_notifications (id, user_id, tierlist_id, comment_id, actor_user_id, notification_type, is_read, read_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?) + `, + [nanoid(), userId, comment.tierListId, comment.id, comment.authorId, notificationType, now()] + ) + ) + ) +} + +async function listCommentNotifications(userId, { unreadOnly = false } = {}) { + const rows = await query( + ` + SELECT + n.id, + n.user_id, + n.tierlist_id, + n.comment_id, + n.notification_type, + n.is_read, + n.read_at, + n.created_at, + n.actor_user_id, + c.parent_comment_id, + c.content AS comment_content, + t.topic_id, + tp.slug AS topic_slug, + tp.name AS topic_name, + t.title AS tierlist_title, + actor.nickname AS actor_nickname, + actor.email AS actor_email, + actor.avatar_src AS actor_avatar_src + FROM comment_notifications n + INNER JOIN tierlist_comments c ON c.id = n.comment_id + INNER JOIN tierlists t ON t.id = n.tierlist_id + INNER JOIN topics tp ON tp.id = t.topic_id + INNER JOIN users actor ON actor.id = n.actor_user_id + WHERE n.user_id = ? + ${unreadOnly ? 'AND n.is_read = 0' : ''} + ORDER BY n.is_read ASC, n.created_at DESC + LIMIT 300 + `, + [userId] + ) + return rows.map(mapCommentNotificationRow) +} + +async function countUnreadCommentNotifications(userId) { + const rows = await query('SELECT COUNT(*) AS count FROM comment_notifications WHERE user_id = ? AND is_read = 0', [userId]) + return Number(rows[0]?.count || 0) +} + +async function markCommentNotificationsRead(userId, { notificationIds = [], all = false } = {}) { + if (all) { + await query('UPDATE comment_notifications SET is_read = 1, read_at = ? WHERE user_id = ? AND is_read = 0', [now(), userId]) + return + } + + const ids = Array.from(new Set((notificationIds || []).filter(Boolean))).slice(0, 100) + if (!ids.length) return + await query( + `UPDATE comment_notifications SET is_read = 1, read_at = ? WHERE user_id = ? AND id IN (${ids.map(() => '?').join(', ')}) AND is_read = 0`, + [now(), userId, ...ids] + ) +} + function uniqueTierListItems(poolItems) { const map = new Map() ;(poolItems || []).forEach((item) => { @@ -3275,6 +3539,14 @@ module.exports = { updateTierListFeaturedStatus, favoriteTopic, unfavoriteTopic, + listTierListComments, + findTierListCommentById, + createTierListComment, + deleteTierListComment, + createCommentNotificationsForComment, + listCommentNotifications, + countUnreadCommentNotifications, + markCommentNotificationsRead, followUser, unfollowUser, favoriteTierList, diff --git a/backend/src/routes/comments.js b/backend/src/routes/comments.js new file mode 100644 index 0000000..d15380a --- /dev/null +++ b/backend/src/routes/comments.js @@ -0,0 +1,49 @@ +const express = require('express') +const { z } = require('zod') +const { + listCommentNotifications, + countUnreadCommentNotifications, + markCommentNotificationsRead, +} = require('../db') +const { requireAuth } = require('../middleware/auth') + +const router = express.Router() + +router.get('/inbox', requireAuth, async (req, res) => { + const schema = z.object({ + unreadOnly: z + .union([z.literal('1'), z.literal('0'), z.literal('true'), z.literal('false')]) + .optional() + .default('0'), + }) + const parsed = schema.safeParse(req.query) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const unreadOnly = ['1', 'true'].includes(parsed.data.unreadOnly) + const notifications = await listCommentNotifications(req.session.userId, { unreadOnly }) + res.json({ notifications }) +}) + +router.get('/inbox/unread-count', requireAuth, async (req, res) => { + const unreadCount = await countUnreadCommentNotifications(req.session.userId) + res.json({ unreadCount }) +}) + +router.post('/inbox/read', requireAuth, async (req, res) => { + const schema = z.object({ + all: z.boolean().optional().default(false), + notificationIds: z.array(z.string().min(1).max(64)).max(100).optional().default([]), + }) + const parsed = schema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + await markCommentNotificationsRead(req.session.userId, { + all: parsed.data.all, + notificationIds: parsed.data.notificationIds, + }) + + const unreadCount = await countUnreadCommentNotifications(req.session.userId) + res.json({ ok: true, unreadCount }) +}) + +module.exports = router diff --git a/backend/src/routes/tierlists.js b/backend/src/routes/tierlists.js index 373e8d5..3d6bfca 100644 --- a/backend/src/routes/tierlists.js +++ b/backend/src/routes/tierlists.js @@ -16,6 +16,11 @@ const { favoriteTierList, unfavoriteTierList, duplicateTierListForUser, + listTierListComments, + findTierListCommentById, + createTierListComment, + deleteTierListComment, + createCommentNotificationsForComment, } = require('../db') const { requireAuth } = require('../middleware/auth') const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage') @@ -122,6 +127,22 @@ const tierListUpsertSchema = z.object({ } }) +const tierListCommentSchema = z.object({ + content: z.string().trim().min(1).max(2000), + parentCommentId: z.string().trim().max(64).optional().default(''), +}) + +async function getTierListAccessContext(req, tierListId) { + const tierList = await findTierListById(tierListId, req.session?.userId || '') + if (!tierList) return { error: 'not_found' } + if (tierList.isPublic) return { tierList, canRead: true, canEdit: req.session?.userId === tierList.authorId } + if (!req.session?.userId) return { error: 'forbidden' } + if (req.session.userId === tierList.authorId) return { tierList, canRead: true, canEdit: true } + const currentUser = await findUserById(req.session.userId) + if (!currentUser?.isAdmin) return { error: 'forbidden' } + return { tierList, canRead: true, canEdit: true, isAdmin: true } +} + router.get('/public', async (req, res) => { const topicId = typeof req.query.topicId === 'string' ? req.query.topicId : '' const queryText = typeof req.query.q === 'string' ? req.query.q : '' @@ -142,14 +163,10 @@ router.get('/favorites/me', requireAuth, async (req, res) => { }) router.get('/:id', async (req, res) => { - const t = await findTierListById(req.params.id, req.session?.userId || '') - if (!t) return res.status(404).json({ error: 'not_found' }) - if (!t.isPublic) { - if (!req.session?.userId) return res.status(403).json({ error: 'forbidden' }) - const currentUser = req.session.userId === t.authorId ? { isAdmin: false } : await findUserById(req.session.userId) - if (req.session.userId !== t.authorId && !currentUser?.isAdmin) return res.status(403).json({ error: 'forbidden' }) - } - res.json({ tierList: normalizeTierList(t) }) + const access = await getTierListAccessContext(req, req.params.id) + if (access.error === 'not_found') return res.status(404).json({ error: 'not_found' }) + if (access.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) + res.json({ tierList: normalizeTierList(access.tierList) }) }) router.post('/:id/duplicate', requireAuth, async (req, res) => { @@ -189,6 +206,62 @@ router.delete('/:id/favorite', requireAuth, async (req, res) => { res.json({ tierList: normalizeTierList(updated) }) }) +router.get('/:id/comments', async (req, res) => { + const access = await getTierListAccessContext(req, req.params.id) + if (access.error === 'not_found') return res.status(404).json({ error: 'not_found' }) + if (access.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) + + const comments = await listTierListComments(access.tierList.id) + res.json({ comments }) +}) + +router.post('/:id/comments', requireAuth, async (req, res) => { + const parsed = tierListCommentSchema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const access = await getTierListAccessContext(req, req.params.id) + if (access.error === 'not_found') return res.status(404).json({ error: 'not_found' }) + if (access.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) + + try { + const commentId = await createTierListComment({ + tierListId: access.tierList.id, + authorId: req.session.userId, + parentCommentId: parsed.data.parentCommentId, + content: parsed.data.content, + }) + await createCommentNotificationsForComment(commentId) + const comments = await listTierListComments(access.tierList.id) + res.json({ comments, createdCommentId: commentId }) + } catch (error) { + if (error?.code === 'COMMENT_PARENT_INVALID') { + return res.status(400).json({ error: 'comment_parent_invalid' }) + } + if (error?.code === 'COMMENT_REPLY_DEPTH_INVALID') { + return res.status(400).json({ error: 'comment_reply_depth_invalid' }) + } + throw error + } +}) + +router.delete('/:id/comments/:commentId', requireAuth, async (req, res) => { + const access = await getTierListAccessContext(req, req.params.id) + if (access.error === 'not_found') return res.status(404).json({ error: 'not_found' }) + if (access.error === 'forbidden') return res.status(403).json({ error: 'forbidden' }) + + const comment = await findTierListCommentById(req.params.commentId) + if (!comment || comment.tierListId !== access.tierList.id) return res.status(404).json({ error: 'not_found' }) + + const currentUser = req.session.userId === comment.authorId ? { isAdmin: false } : await findUserById(req.session.userId) + if (req.session.userId !== comment.authorId && !currentUser?.isAdmin) { + return res.status(403).json({ error: 'forbidden' }) + } + + await deleteTierListComment(comment.id) + const comments = await listTierListComments(access.tierList.id) + res.json({ comments }) +}) + router.post('/custom-items', requireAuth, upload.single('image'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'file_required' }) diff --git a/docs/history.md b/docs/history.md index e896def..e1c16c7 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-04-07 v1.1.1 +- 댓글 기능은 다시 붙일 때 에디터 본문 로딩과 강하게 결합하지 않기로 했다. 저장된 티어표 하단 독립 카드로 분리하고, 편집 모드와 프리뷰 모드가 같은 댓글 컴포넌트를 재사용하도록 결정했다. +- 댓글 알림은 메일이나 실시간 푸시 대신 좌측 사이드 `댓글 관리` 메뉴의 red dot + 전용 `/comments` 관리함으로 시작한다. 먼저 안정적인 인앱 확인 흐름을 만들고, 실시간 반영은 후속 과제로 남긴다. +- 답글은 우선 1단계까지만 허용한다. 깊은 스레드는 UI 복잡도와 에디터 화면 밀도를 크게 높이므로 현재 단계에서는 root 댓글 + 답글 1단 구조만 유지한다. + ## 2026-04-07 v1.1.0 - 홈은 템플릿 진입 화면과 성격이 다르므로, 공개 티어표 피드와 템플릿 목록을 분리하는 편이 정보 구조상 더 자연스럽다고 정리했다. - 비공개 템플릿은 관리자라도 일반 사용자 화면 문법 안에서는 보이지 않아야 하므로, 일반 목록과 관리자 관리 목록 API를 분리하는 방향을 택했다. diff --git a/docs/map.md b/docs/map.md index 78feb2a..649eca3 100644 --- a/docs/map.md +++ b/docs/map.md @@ -17,8 +17,13 @@ ## `/editor/:topicId/new`, `/editor/:topicId/:tierListId` - 화면 파일: `frontend/src/views/TierEditorView.vue` -- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링 -- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request` +- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰와 하단 댓글 카드를 렌더링 +- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `GET /api/tierlists/:id/comments`, `POST /api/tierlists/:id/comments`, `DELETE /api/tierlists/:id/comments/:commentId`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request` + +## `/comments` +- 화면 파일: `frontend/src/views/CommentInboxView.vue` +- 역할: 내 티어표에 달린 댓글과 내 댓글에 달린 답글을 시간순 카드로 확인, 안 읽은 댓글만 보기 필터, 모두 읽음 처리, 카드별 red dot 표시, 카드 클릭 시 해당 티어표의 특정 댓글 위치로 이동 +- 연동 API: `GET /api/comments/inbox`, `GET /api/comments/inbox/unread-count`, `POST /api/comments/inbox/read` ## `/login` - 화면 파일: `frontend/src/views/LoginView.vue` @@ -62,7 +67,7 @@ ## 공통 레이아웃 - 앱 셸 파일: `frontend/src/App.vue` -- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `홈 / 템플릿` 분리 네비게이션과 화면별 검색 placeholder 전환, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링, 관리자/에디터 화면이 Teleport로 사용하는 `#local-right-rail-root` 대상 DOM을 상시 유지해 라우트 전환 중 우측 레일 언마운트 순서를 안정화 +- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `홈 / 템플릿 / 댓글 관리` 네비게이션과 화면별 검색 placeholder 전환, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 댓글 알림 unread dot, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링, 관리자/에디터 화면이 Teleport로 사용하는 `#local-right-rail-root` 대상 DOM을 상시 유지해 라우트 전환 중 우측 레일 언마운트 순서를 안정화 - 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다. ## 백엔드 진입점 diff --git a/docs/spec.md b/docs/spec.md index 9419a66..050f5ed 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -48,6 +48,8 @@ - 홈 피드(`/`)는 `GET /api/tierlists/public?q=...`를 사용한다. - `featuredTierLists`: 상단 추천 티어표 - `tierLists`: 추천 제외 최신 공개 티어표 +- 저장된 티어표에는 댓글 스레드가 붙을 수 있다. 작성자 본인 편집 화면에서는 `작업 팁` 아래, 작성자가 아닌 사용자의 보기 전용 화면에서는 `preview` 보드 아래에서 같은 댓글 카드를 사용한다. +- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다. - 우측 패널 - 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다. - 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다. @@ -148,6 +150,25 @@ - `userId`: string - `topicId`: string - `createdAt`: number +- `tierListComments` + - `id`: string + - `tierListId`: string + - `authorId`: string + - `parentCommentId`: string + - `content`: string + - `createdAt`: number + - `updatedAt`: number + - 답글은 1단계까지만 허용한다. +- `commentNotifications` + - `id`: string + - `userId`: string + - `tierListId`: string + - `commentId`: string + - `actorUserId`: string + - `notificationType`: `tierlist_comment | comment_reply` + - `isRead`: boolean + - `readAt`: number + - `createdAt`: number - `templateRequests` - `id`: string - `type`: string @@ -200,6 +221,9 @@ - `GET /api/tierlists/me` - `GET /api/tierlists/favorites/me` - `GET /api/tierlists/:id` + - `GET /api/tierlists/:id/comments` + - `POST /api/tierlists/:id/comments` + - `DELETE /api/tierlists/:id/comments/:commentId` - `POST /api/tierlists/:id/template-request` - `POST /api/tierlists/:id/favorite` - `DELETE /api/tierlists/:id/favorite` @@ -216,6 +240,10 @@ - 해당 작성자의 공개 티어표 목록을 반환한다. - `POST /api/users/:userId/follow` - `DELETE /api/users/:userId/follow` +- 댓글 알림 + - `GET /api/comments/inbox` + - `GET /api/comments/inbox/unread-count` + - `POST /api/comments/inbox/read` - 관리자 - `POST /api/admin/templates` - 요청 본문은 `slug`, `name`, `isPublic`, `thumbnailSrc`를 사용하고, 내부 `topics.id`는 서버가 자동 생성한다. diff --git a/docs/todo.md b/docs/todo.md index ef56522..ade4155 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,9 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.1.1` 댓글 복구 이후 다음 흐름을 우선 QA한다: 공개 티어표 프리뷰 하단 댓글 노출, 댓글 작성/답글 작성/본인 댓글 삭제, 댓글 관리 메뉴 red dot, 댓글 관리 화면에서 `안 읽은 댓글만 보기`와 `모두 읽음 처리`, 카드 클릭 후 해당 댓글 위치 스크롤. +- 작성자 본인 티어표 편집 화면과 타인 티어표 프리뷰 화면에서 같은 댓글 카드가 모두 자연스럽게 보이는지, 새로고침 후에도 기존 에디터 회귀 없이 댓글 카드만 안정적으로 붙는지 확인한다. +- 댓글 알림 unread count는 현재 접속 시와 라우트 이동 시 갱신되므로, 다른 탭에서 새 댓글이 생겼을 때 실시간 반영이 필요하면 이후 polling 또는 SSE 도입 여부를 검토한다. - `v1.1.0`에서 홈을 공개 티어표 피드로, 템플릿을 `/templates`로 분리했으므로 왼쪽 사이드 `홈 / 템플릿 / 나의 티어표 / 설정` 흐름과 검색 placeholder가 각 화면에서 자연스럽게 바뀌는지 확인한다. - 관리자 계정으로 일반 템플릿 목록(`/templates`)에 들어가도 비공개 템플릿이 보이지 않고, 관리자 화면에서는 여전히 비공개 템플릿이 관리 가능한지 확인한다. - 홈 피드의 추천 티어표와 최신 공개 티어표 카드가 데스크톱/태블릿/모바일에서 overflow 없이 안정적으로 보이는지 확인한다. diff --git a/docs/update.md b/docs/update.md index f2b9ca1..cdc190e 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,14 @@ # 업데이트 로그 +## 2026-04-07 v1.1.1 +- 댓글 시스템을 `v1.1.0` 기준 위에 다시 복구했다. 티어표 댓글/답글용 DB 테이블(`tierlist_comments`)과 댓글 알림 테이블(`comment_notifications`)을 추가하고, 댓글 작성 시 `내 티어표에 새 댓글`, `내 댓글에 새 답글` 알림이 생성되도록 했다. +- 티어표 API에 댓글 조회/작성/삭제를 추가했다. 비공개 티어표의 댓글은 기존 티어표 접근 권한을 그대로 따르며, 답글은 1단계까지만 허용한다. +- 댓글 관리 전용 `/comments` 화면을 추가했다. 안 읽은 댓글만 보기, 모두 읽음 처리, 개별 red dot, 카드 클릭 시 해당 티어표의 댓글 위치로 바로 이동하는 흐름을 붙였다. +- 왼쪽 사이드 메뉴에 `댓글 관리`를 추가하고, 읽지 않은 댓글이 하나라도 있으면 메뉴 아이콘에 빨간 dot이 표시되도록 했다. +- 티어표 편집 화면과 `preview=1` 보기 화면 모두 저장된 티어표 하단에서 같은 댓글 카드 컴포넌트를 재사용하도록 정리했다. 작성자가 아닌 사용자는 프리뷰 화면 아래에서 댓글을 보고 작성할 수 있다. +- 프리뷰 자동 전환 시 `commentId` 쿼리가 유지되도록 보정해 댓글 관리함에서 특정 댓글 위치로 이동하는 흐름이 끊기지 않게 했다. +- 확인: `node --check backend/src/routes/tierlists.js`, `node --check backend/src/routes/comments.js`, `npm run build` + ## 2026-04-07 v1.1.0 - 홈 화면을 템플릿 목록이 아니라 `공개 티어표 피드`로 다시 구성했다. 공개 티어표는 최신 업데이트순으로 표시하고, 관리자가 추천한 티어표는 상단 `추천 티어표` 섹션에 별도로 유지한다. - 템플릿 목록은 새 `/templates` 화면으로 분리했다. 기존 템플릿 카드 문법과 즐겨찾기 토글은 그대로 유지하면서, 홈과 템플릿의 역할을 분리했다. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index dd59356..7e66446 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,8 +2,9 @@ import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useAuthStore } from './stores/auth' -import { editorNewPath, favoritesPath, followingFeedPath, homePath, loginPath, mePath, templatesPath } from './lib/paths' +import { commentsPath, editorNewPath, favoritesPath, followingFeedPath, homePath, loginPath, mePath, templatesPath } from './lib/paths' import { displayInitialFrom } from './lib/display' +import { api } from './lib/api' import { toApiUrl } from './lib/runtime' import { useToast } from './composables/useToast' import iconDockToLeft from './assets/icons/dock_to_left.svg' @@ -40,6 +41,7 @@ const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 14 const backendState = ref('online') const backendMessage = ref('') const isFullscreenActive = ref(false) +const unreadCommentCount = ref(0) provide('rightRailOpen', rightRailOpen) provide('localRightRailTarget', '#local-right-rail-root') @@ -76,6 +78,7 @@ const leftNavItems = computed(() => { { key: 'me', label: '나의 티어표', path: '/me', iconSrc: iconLists, requiresAuth: true }, { key: 'favorites', label: '즐겨찾기', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true }, { key: 'followingFeed', label: '팔로우 피드', path: '/following', iconSrc: iconKidStar, requiresAuth: true }, + { key: 'comments', label: '댓글 관리', path: commentsPath(), iconSrc: iconMenuBook, requiresAuth: true, showDot: unreadCommentCount.value > 0 }, { key: 'profile', label: '설정', path: '/profile', iconSrc: iconSettings, requiresAuth: true }, ] return items.filter((item) => !item.requiresAuth || (authReady.value && auth.user)) @@ -261,6 +264,16 @@ const routeMeta = computed(() => { action: () => router.push(favoritesPath()), } } + if (route.name === 'comments') { + return { + title: '댓글 관리', + subtitle: '댓글과 답글 확인', + contextTitle: '알림', + contextText: '내 티어표에 달린 댓글과 내 댓글에 달린 답글을 확인하고 바로 해당 위치로 이동할 수 있어요.', + actionLabel: '나의 티어표 보기', + action: () => router.push(mePath()), + } + } if (route.name === 'userProfile') { return { title: '작성자 프로필', @@ -313,6 +326,23 @@ function handleBackendStatus(event) { backendMessage.value = typeof event?.detail?.message === 'string' ? event.detail.message : '' } +function handleCommentInboxUpdated(event) { + unreadCommentCount.value = Math.max(0, Number(event?.detail?.unreadCount || 0)) +} + +async function refreshUnreadCommentCount() { + if (!authReady.value || !auth.user) { + unreadCommentCount.value = 0 + return + } + try { + const data = await api.getCommentInboxUnreadCount() + unreadCommentCount.value = Math.max(0, Number(data.unreadCount || 0)) + } catch (error) { + unreadCommentCount.value = 0 + } +} + function syncFullscreenState() { if (typeof document === 'undefined') return isFullscreenActive.value = !!(document.fullscreenElement || document.webkitFullscreenElement) @@ -344,6 +374,7 @@ onMounted(async () => { syncViewportWidth() syncFullscreenState() window.addEventListener('tier-maker:backend-status', handleBackendStatus) + window.addEventListener('tier-maker:comment-inbox-updated', handleCommentInboxUpdated) window.addEventListener('resize', syncViewportWidth) window.addEventListener('keydown', handleGlobalKeydown) document.addEventListener('fullscreenchange', syncFullscreenState) @@ -360,6 +391,7 @@ onMounted(async () => { rightRailOpen.value = true } searchQuery.value = typeof route.query.q === 'string' ? route.query.q : '' + await refreshUnreadCommentCount() }) function handleGlobalKeydown(event) { @@ -423,6 +455,7 @@ async function toggleFullscreen() { onBeforeUnmount(() => { if (typeof window !== 'undefined') { window.removeEventListener('tier-maker:backend-status', handleBackendStatus) + window.removeEventListener('tier-maker:comment-inbox-updated', handleCommentInboxUpdated) window.removeEventListener('resize', syncViewportWidth) window.removeEventListener('keydown', handleGlobalKeydown) document.removeEventListener('fullscreenchange', syncFullscreenState) @@ -441,6 +474,14 @@ watch( mobileLeftNavOpen.value = false rightRailOpen.value = false } + refreshUnreadCommentCount() + } +) + +watch( + () => auth.user?.id, + () => { + refreshUnreadCommentCount() } ) @@ -656,6 +697,7 @@ function reloadApp() { + {{ item.label }} @@ -1260,12 +1302,24 @@ function reloadApp() { /* width: 28px; */ /* height: 28px; */ /* border-radius: 10px; */ + position: relative; display: grid; place-items: center; /* background: rgba(255, 255, 255, 0.06); */ flex: 0 0 auto; } +.leftNav__dot { + position: absolute; + top: -2px; + right: -3px; + width: 9px; + height: 9px; + border-radius: 999px; + background: #ff4d67; + box-shadow: 0 0 0 2px var(--theme-surface); +} + .appShell--leftCollapsed .leftRail__top { justify-content: center; } diff --git a/frontend/src/components/TierListCommentsCard.vue b/frontend/src/components/TierListCommentsCard.vue new file mode 100644 index 0000000..d333a08 --- /dev/null +++ b/frontend/src/components/TierListCommentsCard.vue @@ -0,0 +1,530 @@ + + +