댓글 시스템 복구
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
49
backend/src/routes/comments.js
Normal file
49
backend/src/routes/comments.js
Normal file
@@ -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
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-07 v1.1.1
|
||||
- 댓글 기능은 다시 붙일 때 에디터 본문 로딩과 강하게 결합하지 않기로 했다. 저장된 티어표 하단 독립 카드로 분리하고, 편집 모드와 프리뷰 모드가 같은 댓글 컴포넌트를 재사용하도록 결정했다.
|
||||
- 댓글 알림은 메일이나 실시간 푸시 대신 좌측 사이드 `댓글 관리` 메뉴의 red dot + 전용 `/comments` 관리함으로 시작한다. 먼저 안정적인 인앱 확인 흐름을 만들고, 실시간 반영은 후속 과제로 남긴다.
|
||||
- 답글은 우선 1단계까지만 허용한다. 깊은 스레드는 UI 복잡도와 에디터 화면 밀도를 크게 높이므로 현재 단계에서는 root 댓글 + 답글 1단 구조만 유지한다.
|
||||
|
||||
## 2026-04-07 v1.1.0
|
||||
- 홈은 템플릿 진입 화면과 성격이 다르므로, 공개 티어표 피드와 템플릿 목록을 분리하는 편이 정보 구조상 더 자연스럽다고 정리했다.
|
||||
- 비공개 템플릿은 관리자라도 일반 사용자 화면 문법 안에서는 보이지 않아야 하므로, 일반 목록과 관리자 관리 목록 API를 분리하는 방향을 택했다.
|
||||
|
||||
11
docs/map.md
11
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` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
|
||||
## 백엔드 진입점
|
||||
|
||||
28
docs/spec.md
28
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`는 서버가 자동 생성한다.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.1.1` 댓글 복구 이후 다음 흐름을 우선 QA한다: 공개 티어표 프리뷰 하단 댓글 노출, 댓글 작성/답글 작성/본인 댓글 삭제, 댓글 관리 메뉴 red dot, 댓글 관리 화면에서 `안 읽은 댓글만 보기`와 `모두 읽음 처리`, 카드 클릭 후 해당 댓글 위치 스크롤.
|
||||
- 작성자 본인 티어표 편집 화면과 타인 티어표 프리뷰 화면에서 같은 댓글 카드가 모두 자연스럽게 보이는지, 새로고침 후에도 기존 에디터 회귀 없이 댓글 카드만 안정적으로 붙는지 확인한다.
|
||||
- 댓글 알림 unread count는 현재 접속 시와 라우트 이동 시 갱신되므로, 다른 탭에서 새 댓글이 생겼을 때 실시간 반영이 필요하면 이후 polling 또는 SSE 도입 여부를 검토한다.
|
||||
- `v1.1.0`에서 홈을 공개 티어표 피드로, 템플릿을 `/templates`로 분리했으므로 왼쪽 사이드 `홈 / 템플릿 / 나의 티어표 / 설정` 흐름과 검색 placeholder가 각 화면에서 자연스럽게 바뀌는지 확인한다.
|
||||
- 관리자 계정으로 일반 템플릿 목록(`/templates`)에 들어가도 비공개 템플릿이 보이지 않고, 관리자 화면에서는 여전히 비공개 템플릿이 관리 가능한지 확인한다.
|
||||
- 홈 피드의 추천 티어표와 최신 공개 티어표 카드가 데스크톱/태블릿/모바일에서 overflow 없이 안정적으로 보이는지 확인한다.
|
||||
|
||||
@@ -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` 화면으로 분리했다. 기존 템플릿 카드 문법과 즐겨찾기 토글은 그대로 유지하면서, 홈과 템플릿의 역할을 분리했다.
|
||||
|
||||
@@ -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() {
|
||||
<span class="leftNav__glyph">
|
||||
<SvgIcon v-if="item.iconSrc" :src="item.iconSrc" :size="24" />
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
|
||||
<span v-if="item.showDot" class="leftNav__dot" aria-hidden="true"></span>
|
||||
</span>
|
||||
<span class="leftNav__label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
530
frontend/src/components/TierListCommentsCard.vue
Normal file
530
frontend/src/components/TierListCommentsCard.vue
Normal file
@@ -0,0 +1,530 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { displayInitialFrom } from '../lib/display'
|
||||
import { loginPath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
|
||||
const props = defineProps({
|
||||
tierListId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
canWrite: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
currentUserId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '댓글',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '티어표에 대한 의견을 남기고 답글로 대화를 이어갈 수 있어요.',
|
||||
},
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const comments = ref([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref('')
|
||||
const commentDraft = ref('')
|
||||
const replyDrafts = ref({})
|
||||
const openedReplyComposerId = ref('')
|
||||
const submittingTargetId = ref('')
|
||||
const deletingCommentId = ref('')
|
||||
let activeCommentRetryTimer = 0
|
||||
|
||||
const totalCommentCount = computed(() =>
|
||||
comments.value.reduce((count, comment) => count + 1 + (comment.replies?.length || 0), 0)
|
||||
)
|
||||
const loginTarget = computed(() => loginPath(route.fullPath))
|
||||
const highlightedCommentId = computed(() =>
|
||||
typeof route.query.commentId === 'string' ? route.query.commentId.trim() : ''
|
||||
)
|
||||
|
||||
function avatarUrlOf(user) {
|
||||
return user?.authorAvatarSrc ? toApiUrl(user.authorAvatarSrc) : ''
|
||||
}
|
||||
|
||||
function avatarFallbackOf(user) {
|
||||
return displayInitialFrom(user?.authorName, user?.authorAccountName, '?')
|
||||
}
|
||||
|
||||
function displayNameOf(user) {
|
||||
return user?.authorName || '알 수 없음'
|
||||
}
|
||||
|
||||
function formatDate(ts) {
|
||||
return new Date(Number(ts || 0)).toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function isOwnComment(comment) {
|
||||
return !!props.currentUserId && props.currentUserId === comment?.authorId
|
||||
}
|
||||
|
||||
function isHighlighted(commentId) {
|
||||
return !!commentId && highlightedCommentId.value === commentId
|
||||
}
|
||||
|
||||
function clearActiveCommentRetry() {
|
||||
if (typeof window === 'undefined' || !activeCommentRetryTimer) return
|
||||
window.clearTimeout(activeCommentRetryTimer)
|
||||
activeCommentRetryTimer = 0
|
||||
}
|
||||
|
||||
async function scrollToHighlightedComment(attempt = 0) {
|
||||
clearActiveCommentRetry()
|
||||
if (!highlightedCommentId.value || typeof document === 'undefined') return
|
||||
await nextTick()
|
||||
const target = document.querySelector(`[data-comment-id="${highlightedCommentId.value}"]`)
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: attempt === 0 ? 'auto' : 'smooth', block: 'center' })
|
||||
return
|
||||
}
|
||||
if (attempt >= 6 || typeof window === 'undefined') return
|
||||
activeCommentRetryTimer = window.setTimeout(() => {
|
||||
scrollToHighlightedComment(attempt + 1)
|
||||
}, 180)
|
||||
}
|
||||
|
||||
async function loadComments() {
|
||||
if (!props.tierListId) {
|
||||
comments.value = []
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await api.listTierListComments(props.tierListId)
|
||||
comments.value = Array.isArray(data.comments) ? data.comments : []
|
||||
scrollToHighlightedComment()
|
||||
} catch (loadError) {
|
||||
error.value = '댓글을 불러오지 못했어요.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComment(parentCommentId = '') {
|
||||
if (!props.canWrite || !props.tierListId) return
|
||||
const isReply = !!parentCommentId
|
||||
const draftValue = isReply ? replyDrafts.value[parentCommentId] || '' : commentDraft.value
|
||||
const content = String(draftValue || '').trim()
|
||||
if (!content) return
|
||||
|
||||
submittingTargetId.value = parentCommentId || 'root'
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await api.createTierListComment(props.tierListId, {
|
||||
content,
|
||||
parentCommentId,
|
||||
})
|
||||
comments.value = Array.isArray(data.comments) ? data.comments : []
|
||||
if (isReply) {
|
||||
replyDrafts.value = { ...replyDrafts.value, [parentCommentId]: '' }
|
||||
openedReplyComposerId.value = ''
|
||||
} else {
|
||||
commentDraft.value = ''
|
||||
}
|
||||
await nextTick()
|
||||
if (data.createdCommentId && typeof window !== 'undefined') {
|
||||
const target = document.querySelector(`[data-comment-id="${data.createdCommentId}"]`)
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
} catch (submitError) {
|
||||
const code = submitError?.data?.error
|
||||
if (code === 'comment_reply_depth_invalid') {
|
||||
error.value = '답글에는 다시 답글을 달 수 없어요.'
|
||||
} else {
|
||||
error.value = '댓글을 저장하지 못했어요.'
|
||||
}
|
||||
} finally {
|
||||
submittingTargetId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComment(commentId) {
|
||||
if (!commentId || !props.tierListId) return
|
||||
deletingCommentId.value = commentId
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await api.deleteTierListComment(props.tierListId, commentId)
|
||||
comments.value = Array.isArray(data.comments) ? data.comments : []
|
||||
} catch (deleteError) {
|
||||
error.value = '댓글을 삭제하지 못했어요.'
|
||||
} finally {
|
||||
deletingCommentId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function toggleReplyComposer(commentId) {
|
||||
openedReplyComposerId.value = openedReplyComposerId.value === commentId ? '' : commentId
|
||||
}
|
||||
|
||||
watch(() => props.tierListId, loadComments, { immediate: true })
|
||||
watch(highlightedCommentId, () => {
|
||||
scrollToHighlightedComment()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearActiveCommentRetry()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="commentsCard">
|
||||
<header class="commentsCard__head">
|
||||
<div>
|
||||
<div class="commentsCard__eyebrow">Comments</div>
|
||||
<h3 class="commentsCard__title">{{ title }}</h3>
|
||||
<p class="commentsCard__desc">{{ description }}</p>
|
||||
</div>
|
||||
<div class="commentsCard__count">{{ totalCommentCount }}개</div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="commentsCard__error">{{ error }}</div>
|
||||
|
||||
<div v-if="props.canWrite" class="commentsComposer">
|
||||
<textarea
|
||||
v-model="commentDraft"
|
||||
class="commentsComposer__input"
|
||||
maxlength="2000"
|
||||
rows="3"
|
||||
placeholder="이 티어표에 대한 의견을 남겨보세요."
|
||||
/>
|
||||
<div class="commentsComposer__footer">
|
||||
<span class="commentsComposer__hint">{{ commentDraft.length }}/2000</span>
|
||||
<button class="btn btn--save btn--small" type="button" :disabled="!commentDraft.trim() || submittingTargetId === 'root'" @click="submitComment()">
|
||||
댓글 등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="commentsLoginCta">
|
||||
<div class="commentsLoginCta__text">로그인하면 댓글과 답글을 남길 수 있어요.</div>
|
||||
<RouterLink class="btn btn--ghost btn--small" :to="loginTarget">로그인</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="commentsCard__empty">댓글을 불러오는 중이에요.</div>
|
||||
<div v-else-if="comments.length === 0" class="commentsCard__empty">아직 댓글이 없어요. 첫 댓글을 남겨보세요.</div>
|
||||
|
||||
<div v-else class="commentsThread">
|
||||
<article
|
||||
v-for="comment in comments"
|
||||
:key="comment.id"
|
||||
class="commentItem"
|
||||
:class="{ 'commentItem--highlighted': isHighlighted(comment.id) }"
|
||||
:data-comment-id="comment.id"
|
||||
>
|
||||
<div class="commentItem__head">
|
||||
<div class="commentItem__author">
|
||||
<img v-if="avatarUrlOf(comment)" class="commentItem__avatar" :src="avatarUrlOf(comment)" :alt="displayNameOf(comment)" draggable="false" />
|
||||
<div v-else class="commentItem__avatar commentItem__avatar--fallback">{{ avatarFallbackOf(comment) }}</div>
|
||||
<div class="commentItem__meta">
|
||||
<div class="commentItem__name">{{ displayNameOf(comment) }}</div>
|
||||
<div class="commentItem__date">{{ formatDate(comment.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="commentItem__actions">
|
||||
<button v-if="props.canWrite" class="commentItem__action" type="button" @click="toggleReplyComposer(comment.id)">답글</button>
|
||||
<button
|
||||
v-if="isOwnComment(comment)"
|
||||
class="commentItem__action commentItem__action--danger"
|
||||
type="button"
|
||||
:disabled="deletingCommentId === comment.id"
|
||||
@click="deleteComment(comment.id)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="commentItem__body">{{ comment.content }}</div>
|
||||
|
||||
<div v-if="openedReplyComposerId === comment.id && props.canWrite" class="replyComposer">
|
||||
<textarea
|
||||
v-model="replyDrafts[comment.id]"
|
||||
class="commentsComposer__input commentsComposer__input--reply"
|
||||
maxlength="2000"
|
||||
rows="2"
|
||||
placeholder="답글을 입력하세요."
|
||||
/>
|
||||
<div class="commentsComposer__footer">
|
||||
<span class="commentsComposer__hint">{{ (replyDrafts[comment.id] || '').length }}/2000</span>
|
||||
<button class="btn btn--save btn--small" type="button" :disabled="!(replyDrafts[comment.id] || '').trim() || submittingTargetId === comment.id" @click="submitComment(comment.id)">
|
||||
답글 등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="comment.replies?.length" class="replyList">
|
||||
<article
|
||||
v-for="reply in comment.replies"
|
||||
:key="reply.id"
|
||||
class="commentItem commentItem--reply"
|
||||
:class="{ 'commentItem--highlighted': isHighlighted(reply.id) }"
|
||||
:data-comment-id="reply.id"
|
||||
>
|
||||
<div class="commentItem__head">
|
||||
<div class="commentItem__author">
|
||||
<img v-if="avatarUrlOf(reply)" class="commentItem__avatar" :src="avatarUrlOf(reply)" :alt="displayNameOf(reply)" draggable="false" />
|
||||
<div v-else class="commentItem__avatar commentItem__avatar--fallback">{{ avatarFallbackOf(reply) }}</div>
|
||||
<div class="commentItem__meta">
|
||||
<div class="commentItem__name">{{ displayNameOf(reply) }}</div>
|
||||
<div class="commentItem__date">{{ formatDate(reply.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="commentItem__actions">
|
||||
<button
|
||||
v-if="isOwnComment(reply)"
|
||||
class="commentItem__action commentItem__action--danger"
|
||||
type="button"
|
||||
:disabled="deletingCommentId === reply.id"
|
||||
@click="deleteComment(reply.id)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="commentItem__body">{{ reply.content }}</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.commentsCard {
|
||||
margin-top: 24px;
|
||||
padding: 24px;
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
}
|
||||
|
||||
.commentsCard__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.commentsCard__eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
|
||||
.commentsCard__title {
|
||||
margin: 6px 0 8px;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.commentsCard__desc {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
}
|
||||
|
||||
.commentsCard__count {
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.commentsCard__error {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--theme-danger-border);
|
||||
background: var(--theme-danger-bg);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
|
||||
.commentsComposer,
|
||||
.commentsLoginCta,
|
||||
.commentItem,
|
||||
.commentItem--reply {
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-surface);
|
||||
}
|
||||
|
||||
.commentsComposer,
|
||||
.commentsLoginCta {
|
||||
margin-bottom: 18px;
|
||||
padding: 16px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.commentsComposer__input {
|
||||
width: 100%;
|
||||
min-height: 92px;
|
||||
resize: vertical;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--theme-field-border);
|
||||
background: var(--theme-input-bg);
|
||||
color: var(--theme-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.commentsComposer__input--reply {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.commentsComposer__footer,
|
||||
.commentsLoginCta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.commentsComposer__footer {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.commentsComposer__hint,
|
||||
.commentsLoginCta__text {
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.commentsCard__empty {
|
||||
padding: 20px 0;
|
||||
color: var(--theme-text-muted);
|
||||
}
|
||||
|
||||
.commentsThread {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.commentItem {
|
||||
padding: 16px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.commentItem--reply {
|
||||
margin-top: 12px;
|
||||
margin-left: 24px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.commentItem--highlighted {
|
||||
border-color: color-mix(in srgb, var(--theme-accent) 65%, var(--theme-card-border));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--theme-accent) 38%, transparent);
|
||||
}
|
||||
|
||||
.commentItem__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.commentItem__author {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.commentItem__avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.commentItem__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.commentItem__meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.commentItem__name {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.commentItem__date {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
|
||||
.commentItem__actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.commentItem__action {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.commentItem__action--danger {
|
||||
color: var(--theme-danger-text, #ff8f8f);
|
||||
}
|
||||
|
||||
.commentItem__body {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.replyComposer {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.commentsCard {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.commentsCard__head,
|
||||
.commentsComposer__footer,
|
||||
.commentsLoginCta,
|
||||
.commentItem__head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.commentItem--reply {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -170,10 +170,19 @@ export const api = {
|
||||
followUser: (userId) => request(`/api/users/${encodeURIComponent(userId)}/follow`, { method: 'POST', body: {} }),
|
||||
unfollowUser: (userId) => request(`/api/users/${encodeURIComponent(userId)}/follow`, { method: 'DELETE' }),
|
||||
getTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`),
|
||||
listTierListComments: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/comments`),
|
||||
createTierListComment: (id, payload) =>
|
||||
request(`/api/tierlists/${encodeURIComponent(id)}/comments`, { method: 'POST', body: payload }),
|
||||
deleteTierListComment: (id, commentId) =>
|
||||
request(`/api/tierlists/${encodeURIComponent(id)}/comments/${encodeURIComponent(commentId)}`, { method: 'DELETE' }),
|
||||
favoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }),
|
||||
deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
duplicateTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/duplicate`, { method: 'POST' }),
|
||||
listCommentInbox: ({ unreadOnly = false } = {}) =>
|
||||
request(`/api/comments/inbox?unreadOnly=${encodeURIComponent(unreadOnly ? '1' : '0')}`),
|
||||
getCommentInboxUnreadCount: () => request('/api/comments/inbox/unread-count'),
|
||||
markCommentInboxRead: (payload) => request('/api/comments/inbox/read', { method: 'POST', body: payload }),
|
||||
requestTierListTemplate: (payload) => request('/api/tierlists/template-request', { method: 'POST', body: payload }),
|
||||
saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }),
|
||||
uploadTierListThumbnail: async (file) => {
|
||||
|
||||
@@ -46,6 +46,10 @@ export function followingFeedPath() {
|
||||
return '/following'
|
||||
}
|
||||
|
||||
export function commentsPath() {
|
||||
return '/comments'
|
||||
}
|
||||
|
||||
export function profilePath() {
|
||||
return '/profile'
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import LoginView from '../views/LoginView.vue'
|
||||
import MyTierListsView from '../views/MyTierListsView.vue'
|
||||
import FavoriteTierListsView from '../views/FavoriteTierListsView.vue'
|
||||
import FollowingFeedView from '../views/FollowingFeedView.vue'
|
||||
import CommentInboxView from '../views/CommentInboxView.vue'
|
||||
import UserProfileView from '../views/UserProfileView.vue'
|
||||
import AdminView from '../views/AdminView.vue'
|
||||
import ProfileView from '../views/ProfileView.vue'
|
||||
@@ -27,6 +28,7 @@ export function createRouter() {
|
||||
{ path: '/me', name: 'me', component: MyTierListsView },
|
||||
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
|
||||
{ path: '/following', name: 'followingFeed', component: FollowingFeedView },
|
||||
{ path: '/comments', name: 'comments', component: CommentInboxView },
|
||||
{ path: '/search', name: 'search', component: SearchResultsView },
|
||||
{ path: '/admin', redirect: '/admin/featured' },
|
||||
{ path: '/admin/featured', name: 'adminFeatured', component: AdminView },
|
||||
|
||||
313
frontend/src/views/CommentInboxView.vue
Normal file
313
frontend/src/views/CommentInboxView.vue
Normal file
@@ -0,0 +1,313 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { displayInitialFrom } from '../lib/display'
|
||||
import { editorPath, loginPath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const notifications = ref([])
|
||||
const isLoading = ref(false)
|
||||
const unreadOnly = ref(false)
|
||||
const isMarkingAllRead = ref(false)
|
||||
|
||||
const unreadCount = computed(() => notifications.value.filter((item) => !item.isRead).length)
|
||||
|
||||
function avatarUrlOf(notification) {
|
||||
return notification.actorAvatarSrc ? toApiUrl(notification.actorAvatarSrc) : ''
|
||||
}
|
||||
|
||||
function avatarFallbackOf(notification) {
|
||||
return displayInitialFrom(notification.actorName, notification.actorAccountName, '?')
|
||||
}
|
||||
|
||||
function formatDate(ts) {
|
||||
return new Date(Number(ts || 0)).toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function notificationTitle(notification) {
|
||||
return notification.notificationType === 'comment_reply' ? '내 댓글에 답글이 달렸어요.' : '내 티어표에 새 댓글이 달렸어요.'
|
||||
}
|
||||
|
||||
function emitUnreadCount(unread) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(new CustomEvent('tier-maker:comment-inbox-updated', { detail: { unreadCount: unread } }))
|
||||
}
|
||||
|
||||
async function loadInbox() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const data = await api.listCommentInbox({ unreadOnly: unreadOnly.value })
|
||||
notifications.value = Array.isArray(data.notifications) ? data.notifications : []
|
||||
emitUnreadCount(unreadCount.value)
|
||||
} catch (error) {
|
||||
toast.error('댓글 알림을 불러오지 못했어요.')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markOneAsRead(notificationId) {
|
||||
const target = notifications.value.find((item) => item.id === notificationId)
|
||||
if (!target || target.isRead) return
|
||||
target.isRead = true
|
||||
emitUnreadCount(unreadCount.value)
|
||||
try {
|
||||
await api.markCommentInboxRead({ notificationIds: [notificationId] })
|
||||
} catch (error) {
|
||||
target.isRead = false
|
||||
emitUnreadCount(unreadCount.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllAsRead() {
|
||||
if (!unreadCount.value) return
|
||||
isMarkingAllRead.value = true
|
||||
const original = notifications.value.map((item) => ({ ...item }))
|
||||
notifications.value = notifications.value.map((item) => ({ ...item, isRead: true }))
|
||||
emitUnreadCount(0)
|
||||
try {
|
||||
await api.markCommentInboxRead({ all: true })
|
||||
if (unreadOnly.value) {
|
||||
notifications.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.value = original
|
||||
emitUnreadCount(unreadCount.value)
|
||||
toast.error('읽음 처리를 완료하지 못했어요.')
|
||||
} finally {
|
||||
isMarkingAllRead.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openNotification(notification) {
|
||||
await markOneAsRead(notification.id)
|
||||
router.push({
|
||||
path: editorPath(notification.topicSlug || notification.topicId, notification.tierListId),
|
||||
query: { commentId: notification.commentId },
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await api.me()
|
||||
} catch (error) {
|
||||
toast.error('로그인이 필요해요.')
|
||||
router.push(loginPath('/comments'))
|
||||
return
|
||||
}
|
||||
loadInbox()
|
||||
})
|
||||
|
||||
watch(unreadOnly, loadInbox)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageWrap">
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Inbox</div>
|
||||
<h1 class="pageHead__title">댓글 관리</h1>
|
||||
<p class="pageHead__desc">내 티어표에 달린 댓글과, 내 댓글에 달린 답글을 한곳에서 확인하고 바로 이동할 수 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="commentInboxToolbar">
|
||||
<label class="commentInboxToolbar__toggle">
|
||||
<input v-model="unreadOnly" type="checkbox" />
|
||||
<span>안 읽은 댓글만 보기</span>
|
||||
</label>
|
||||
<button class="btn btn--ghost btn--small" type="button" :disabled="!unreadCount || isMarkingAllRead" @click="markAllAsRead">
|
||||
모두 읽음 처리
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="commentInboxPanel">
|
||||
<div v-if="isLoading" class="commentInboxEmpty">댓글 알림을 불러오는 중이에요.</div>
|
||||
<div v-else-if="notifications.length === 0" class="commentInboxEmpty">
|
||||
{{ unreadOnly ? '안 읽은 댓글 알림이 없어요.' : '아직 도착한 댓글 알림이 없어요.' }}
|
||||
</div>
|
||||
|
||||
<div v-else class="commentInboxList">
|
||||
<article
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="commentInboxCard"
|
||||
:class="{ 'commentInboxCard--unread': !notification.isRead }"
|
||||
>
|
||||
<button class="commentInboxCard__body" type="button" @click="openNotification(notification)">
|
||||
<div class="commentInboxCard__main">
|
||||
<div class="commentInboxCard__titleRow">
|
||||
<div class="commentInboxCard__title">{{ notificationTitle(notification) }}</div>
|
||||
<span v-if="!notification.isRead" class="commentInboxCard__dot" aria-label="안 읽음"></span>
|
||||
</div>
|
||||
<div class="commentInboxCard__meta">
|
||||
<img
|
||||
v-if="avatarUrlOf(notification)"
|
||||
class="commentInboxCard__avatar"
|
||||
:src="avatarUrlOf(notification)"
|
||||
:alt="notification.actorName || '작성자'"
|
||||
draggable="false"
|
||||
/>
|
||||
<div v-else class="commentInboxCard__avatar commentInboxCard__avatar--fallback">{{ avatarFallbackOf(notification) }}</div>
|
||||
<span class="commentInboxCard__actor">{{ notification.actorName }}</span>
|
||||
<span class="commentInboxCard__separator">·</span>
|
||||
<span class="commentInboxCard__date">{{ formatDate(notification.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="commentInboxCard__target">
|
||||
{{ notification.tierListTitle || '제목 없는 티어표' }}
|
||||
<span class="commentInboxCard__targetMeta">/ {{ notification.topicName || notification.topicSlug || notification.topicId }}</span>
|
||||
</div>
|
||||
<div class="commentInboxCard__content">{{ notification.commentContent }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.commentInboxToolbar {
|
||||
margin-bottom: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.commentInboxToolbar__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.commentInboxPanel {
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.commentInboxEmpty {
|
||||
color: var(--theme-text-muted);
|
||||
}
|
||||
|
||||
.commentInboxList {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.commentInboxCard {
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.commentInboxCard--unread {
|
||||
border-color: color-mix(in srgb, var(--theme-accent) 48%, var(--theme-card-border));
|
||||
}
|
||||
|
||||
.commentInboxCard__body {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.commentInboxCard__titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.commentInboxCard__title {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.commentInboxCard__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: #ff4d67;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.commentInboxCard__meta {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.commentInboxCard__avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.commentInboxCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.commentInboxCard__target {
|
||||
margin-top: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.commentInboxCard__targetMeta {
|
||||
color: var(--theme-text-faint);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.commentInboxCard__content {
|
||||
margin-top: 10px;
|
||||
color: var(--theme-text-muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.commentInboxToolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.commentInboxPanel {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import Sortable from 'sortablejs'
|
||||
import * as htmlToImage from 'html-to-image'
|
||||
import SvgIcon from '../components/SvgIcon.vue'
|
||||
import TierListCommentsCard from '../components/TierListCommentsCard.vue'
|
||||
import addColumnRightIcon from '../assets/icons/add_column_right.svg'
|
||||
import addRowBelowIcon from '../assets/icons/add_row_below.svg'
|
||||
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
|
||||
@@ -159,6 +160,8 @@ const canRequestTemplateCreate = computed(
|
||||
const canRequestTemplateUpdate = computed(
|
||||
() => canEdit.value && hasSavedTierList.value && templateId.value !== 'freeform' && customItems.value.length > 0
|
||||
)
|
||||
const activeTierListId = computed(() => persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : ''))
|
||||
const currentUserId = computed(() => auth.user?.id || '')
|
||||
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||
const templateRequestTargetLabel = computed(() => (templateId.value === 'freeform' ? '새로운 템플릿' : (templateName.value || templateId.value || '선택한 주제')))
|
||||
@@ -1061,12 +1064,20 @@ async function copyShareUrl() {
|
||||
|
||||
function openViewerMode() {
|
||||
if (!canSwitchToViewerMode.value) return
|
||||
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value, { preview: true }))
|
||||
router.push({
|
||||
path: editorPath(templateId.value, persistedTierListId.value || tierListId.value),
|
||||
query: { ...route.query, preview: '1' },
|
||||
})
|
||||
}
|
||||
|
||||
function openEditMode() {
|
||||
if (!canSwitchToEditMode.value) return
|
||||
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value))
|
||||
const nextQuery = { ...route.query }
|
||||
delete nextQuery.preview
|
||||
router.push({
|
||||
path: editorPath(templateId.value, persistedTierListId.value || tierListId.value),
|
||||
query: nextQuery,
|
||||
})
|
||||
}
|
||||
|
||||
function closeNavigationConfirmModal() {
|
||||
@@ -1354,7 +1365,10 @@ async function loadEditorState() {
|
||||
isFavorited.value = !!t.isFavorited
|
||||
|
||||
if (!previewMode.value && !canEdit.value) {
|
||||
router.replace(editorPath(templateId.value, t.id, { preview: true }))
|
||||
router.replace({
|
||||
path: editorPath(templateId.value, t.id),
|
||||
query: { ...route.query, preview: '1' },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1465,6 +1479,14 @@ onUnmounted(() => {
|
||||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<TierListCommentsCard
|
||||
v-if="activeTierListId"
|
||||
:tier-list-id="activeTierListId"
|
||||
:can-write="!!auth.user"
|
||||
:current-user-id="currentUserId"
|
||||
title="댓글"
|
||||
description="이 티어표에 대한 의견을 남기고 답글로 대화를 이어갈 수 있어요."
|
||||
/>
|
||||
|
||||
<Teleport :to="localRightRailTarget">
|
||||
<template v-if="globalRightRailOpen">
|
||||
@@ -1797,6 +1819,14 @@ onUnmounted(() => {
|
||||
<li>아이템이 많아 한 번에 보기 어렵다면 브라우저 확대/축소(`Ctrl +`, `Ctrl -`)로 화면 밀도를 조절해보세요.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<TierListCommentsCard
|
||||
v-if="activeTierListId"
|
||||
:tier-list-id="activeTierListId"
|
||||
:can-write="!!auth.user"
|
||||
:current-user-id="currentUserId"
|
||||
title="댓글"
|
||||
description="이 티어표에 대한 의견을 남기고 답글로 대화를 이어갈 수 있어요."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebarStickyFrame">
|
||||
|
||||
Reference in New Issue
Block a user