Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09b9036bbe | |||
| 9f143d4a89 | |||
| d5575d3028 | |||
| 173f547d8b | |||
| db037c6163 | |||
| 6bac13006a | |||
| 2c0b5268fa | |||
| 8fc8872114 |
@@ -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,54 @@ 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 || '',
|
||||
tierListThumbnailSrc: row.tierlist_thumbnail_src || '',
|
||||
commentId: row.comment_id,
|
||||
parentCommentId: row.parent_comment_id || '',
|
||||
parentCommentContent: row.parent_comment_content || '',
|
||||
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 +547,51 @@ 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(`ALTER TABLE tierlist_comments ADD COLUMN IF NOT EXISTS parent_comment_id VARCHAR(64) NULL DEFAULT NULL AFTER author_id`)
|
||||
await query(`ALTER TABLE tierlist_comments ADD COLUMN IF NOT EXISTS updated_at BIGINT NOT NULL DEFAULT 0 AFTER created_at`)
|
||||
await query(`ALTER TABLE comment_notifications ADD COLUMN IF NOT EXISTS actor_user_id VARCHAR(64) NOT NULL DEFAULT '' AFTER comment_id`)
|
||||
await query(`ALTER TABLE comment_notifications ADD COLUMN IF NOT EXISTS notification_type VARCHAR(32) NOT NULL DEFAULT 'tierlist_comment' AFTER actor_user_id`)
|
||||
await query(`ALTER TABLE comment_notifications ADD COLUMN IF NOT EXISTS is_read TINYINT(1) NOT NULL DEFAULT 0 AFTER notification_type`)
|
||||
await query(`ALTER TABLE comment_notifications ADD COLUMN IF NOT EXISTS read_at BIGINT NOT NULL DEFAULT 0 AFTER is_read`)
|
||||
await query(`CREATE INDEX IF NOT EXISTS idx_comment_notifications_user_read_created ON comment_notifications (user_id, is_read, created_at)`)
|
||||
await query(`CREATE INDEX IF NOT EXISTS idx_comment_notifications_comment ON comment_notifications (comment_id)`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS user_follows (
|
||||
follower_id VARCHAR(64) NOT NULL,
|
||||
@@ -2612,6 +2705,195 @@ 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)
|
||||
})
|
||||
roots.sort((a, b) => {
|
||||
const timeDiff = Number(b.createdAt || 0) - Number(a.createdAt || 0)
|
||||
if (timeDiff !== 0) return timeDiff
|
||||
return String(b.id || '').localeCompare(String(a.id || ''))
|
||||
})
|
||||
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,
|
||||
parent.content AS parent_comment_content,
|
||||
t.topic_id,
|
||||
tp.slug AS topic_slug,
|
||||
tp.name AS topic_name,
|
||||
t.title AS tierlist_title,
|
||||
t.thumbnail_src AS tierlist_thumbnail_src,
|
||||
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
|
||||
LEFT JOIN tierlist_comments parent ON parent.id = c.parent_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 +3557,14 @@ module.exports = {
|
||||
updateTierListFeaturedStatus,
|
||||
favoriteTopic,
|
||||
unfavoriteTopic,
|
||||
listTierListComments,
|
||||
findTierListCommentById,
|
||||
createTierListComment,
|
||||
deleteTierListComment,
|
||||
createCommentNotificationsForComment,
|
||||
listCommentNotifications,
|
||||
countUnreadCommentNotifications,
|
||||
markCommentNotificationsRead,
|
||||
followUser,
|
||||
unfollowUser,
|
||||
favoriteTierList,
|
||||
|
||||
@@ -136,6 +136,11 @@ function canManageAdminRole(actingUser, primaryAdmin) {
|
||||
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
|
||||
}
|
||||
|
||||
router.get('/templates', requireAdmin, async (req, res) => {
|
||||
const templates = await listTopics('', { includePrivate: true })
|
||||
res.json({ topics: templates })
|
||||
})
|
||||
|
||||
router.post('/templates', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
|
||||
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' })
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const { requireAuth } = require('../middleware/auth')
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const topics = await listTopics(req.session?.userId || '', { includePrivate: !!req.session?.isAdmin })
|
||||
const topics = await listTopics(req.session?.userId || '', { includePrivate: false })
|
||||
res.json({ topics })
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-06 v1.1.0
|
||||
- `v1.0.105`까지 공개 기준 1.0 라인에서 초기 안정화 작업을 이어왔고, 이번부터는 사용자 경험 polish 성격의 개선을 `v1.1.x` 라인으로 올려 진행하기로 정리했다.
|
||||
- 최초 접속 로딩은 인증 상태를 설명하는 문구보다, 사용자가 사이트에 들어왔다는 느낌을 주는 짧은 브랜드 스플래시가 더 자연스럽다. 다만 실제 서버 연결 실패나 점검 상황은 여전히 명확한 안내가 필요하므로, 백엔드 fallback 화면은 기존 기능 안내형 UI를 유지한다.
|
||||
## 2026-04-07 v1.1.6
|
||||
- 댓글 영역은 과하게 화려한 새로운 카드보다, 우측 뷰어 카드와 통일되는 단정한 서비스 톤이 더 적합하다고 판단했다. 같은 화면 안에서 카드 문법이 지나치게 갈라지면 오히려 UI 완성도가 떨어지므로 댓글 카드도 공통 서비스 톤에 맞춘다.
|
||||
- 댓글 정렬은 `루트 최신순 / 답글 오래된순`으로 고정한다. 최신 댓글을 먼저 보는 편이 전체 참여 흐름엔 유리하고, 답글은 작성 순서가 유지되어야 문맥 이해가 쉽다.
|
||||
- 뷰어 우측 레일은 본문 길이와 독립적으로 위에서부터 쌓이도록 유지한다. 댓글처럼 본문이 길어질 수 있는 요소가 생겨도, 공유/복사 같은 보조 액션은 스폰서 카드 아래에서 바로 보여야 한다.
|
||||
|
||||
## 2026-04-06 v1.0.105
|
||||
- 인증 상태와 템플릿 데이터는 모두 비동기 로딩이라, 응답 전 빈 상태를 먼저 보여주면 “로그아웃된 것처럼 보임” 또는 “템플릿이 없는 사이트처럼 보임”으로 오해될 수 있다. 따라서 앱 셸에서는 인증 확인이 끝날 때까지 공통 로딩을 보여주고, 홈 화면은 템플릿 API 응답 전 빈 상태 대신 로딩 카드를 보여주는 편이 더 자연스럽다고 정리했다.
|
||||
## 2026-04-07 v1.1.5
|
||||
- 댓글 UI는 정보를 구분하기 위해 모든 레이어에 border를 두기보다, 큰 카드만 최소 테두리를 두고 내부는 surface 톤과 그림자 차이로 나누는 방향이 더 낫다고 판단했다. 댓글/답글 구조는 구분보다 과밀감이 먼저 느껴지면 안 되므로 이 원칙을 유지한다.
|
||||
|
||||
## 2026-04-07 v1.1.4
|
||||
- 댓글 관리함은 단순 목록보다 `무슨 티어표에서`, `원래 어떤 댓글이 있었고`, `새로 무엇이 달렸는지`를 한눈에 이해하는 정보 구조가 중요하다고 판단했다. 그래서 썸네일 + 스레드 비교 블록을 기본 카드 문법으로 채택했다.
|
||||
- 댓글 본문과 답글도 단순 들여쓰기보다 카드/말풍선/연결선으로 관계를 보여주는 쪽이 최신 UI 감각에 더 맞는다고 보고, reply depth 1단 구조에 맞춘 시각 문법을 적용했다.
|
||||
|
||||
## 2026-04-07 v1.1.3
|
||||
- 댓글 답글 입력창은 포커스 상태에만 의존하지 않고, 비포커스 상태에서도 시각적 경계를 명확히 주기로 했다. 댓글 UI는 에디터 안의 부가 기능이지만 사용자가 바로 이해할 수 있어야 하므로 카드형 배경과 기본 테두리를 유지한다.
|
||||
|
||||
## 2026-04-07 v1.1.2
|
||||
- 댓글/알림 기능처럼 새 테이블을 뒤늦게 붙이는 경우 `CREATE TABLE IF NOT EXISTS`만으로는 충분하지 않다고 판단했다. 이미 남아 있는 예전 스키마와 충돌할 수 있으므로, 서버 시작 시 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` 형태의 점진 마이그레이션을 함께 넣는 방향으로 유지한다.
|
||||
|
||||
## 2026-04-07 v1.1.1
|
||||
- 댓글 기능은 다시 붙일 때 에디터 본문 로딩과 강하게 결합하지 않기로 했다. 저장된 티어표 하단 독립 카드로 분리하고, 편집 모드와 프리뷰 모드가 같은 댓글 컴포넌트를 재사용하도록 결정했다.
|
||||
- 댓글 알림은 메일이나 실시간 푸시 대신 좌측 사이드 `댓글 관리` 메뉴의 red dot + 전용 `/comments` 관리함으로 시작한다. 먼저 안정적인 인앱 확인 흐름을 만들고, 실시간 반영은 후속 과제로 남긴다.
|
||||
- 답글은 우선 1단계까지만 허용한다. 깊은 스레드는 UI 복잡도와 에디터 화면 밀도를 크게 높이므로 현재 단계에서는 root 댓글 + 답글 1단 구조만 유지한다.
|
||||
|
||||
## 2026-04-07 v1.1.0
|
||||
- 홈은 템플릿 진입 화면과 성격이 다르므로, 공개 티어표 피드와 템플릿 목록을 분리하는 편이 정보 구조상 더 자연스럽다고 정리했다.
|
||||
- 비공개 템플릿은 관리자라도 일반 사용자 화면 문법 안에서는 보이지 않아야 하므로, 일반 목록과 관리자 관리 목록 API를 분리하는 방향을 택했다.
|
||||
- 아바타 fallback 은 이메일 계정명보다 사용자가 직접 정한 닉네임을 우선하는 편이 화면 인상이 더 일관적이라고 정리했다.
|
||||
|
||||
## 2026-04-07 기준 복원 메모
|
||||
- 최근 회귀는 개별 버그 패치로 좁히기보다, 마지막 안정 버전인 `v1.0.104`를 기준선으로 되돌린 뒤 기능을 다시 쌓는 편이 더 안전하다고 판단했다.
|
||||
- 특히 티어표 편집 화면은 새로고침/라우트 전환 안정성이 핵심이므로, 이후 기능 추가는 편집기 내부 생명주기를 건드리는 범위를 최소화하는 방향으로 진행한다.
|
||||
|
||||
## 2026-04-06 v1.0.104
|
||||
- 아이템 사용 횟수는 “템플릿에 포함되어 선택 가능했던 횟수”가 아니라 “사용자가 실제 티어표 보드에 배치한 횟수”가 운영 지표로 더 의미 있다고 정리했다. 따라서 `pool_json`은 미사용 후보로 보고 제외하고, `groups_json`에 들어간 item id만 사용 횟수로 집계한다.
|
||||
|
||||
23
docs/map.md
23
docs/map.md
@@ -2,8 +2,13 @@
|
||||
|
||||
## `/`
|
||||
- 화면 파일: `frontend/src/views/HomeView.vue`
|
||||
- 역할: 데스크톱 기본 4열 주제 카드 라이브러리 대시보드, 상단 메인 썸네일과 `주제명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 주제 카드 클릭 이동, `직접 티어표 만들기` 진입
|
||||
- 연동 API: `GET /api/topics`
|
||||
- 역할: 공개 티어표 홈 피드, 상단 `추천 티어표`와 아래 `최신 공개 티어표` 목록을 같은 카드 문법으로 표시, 검색어(`q`)가 있으면 공개 티어표 제목/작성자 기준으로 필터링, 카드 클릭 시 해당 티어표 화면으로 이동
|
||||
- 연동 API: `GET /api/tierlists/public?q=...`
|
||||
|
||||
## `/templates`
|
||||
- 화면 파일: `frontend/src/views/TemplatesView.vue`
|
||||
- 역할: 공개 템플릿 전용 목록, 관리자 수동 순서와 즐겨찾기 여부를 반영한 주제 템플릿 카드 목록 표시, 템플릿 즐겨찾기 토글, 검색어(`q`)가 있으면 템플릿 이름/slug 기준으로 즉시 필터링
|
||||
- 연동 API: `GET /api/topics`, `POST /api/topics/:topicId/favorite`, `DELETE /api/topics/:topicId/favorite`
|
||||
|
||||
## `/topics/:topicId`
|
||||
- 화면 파일: `frontend/src/views/TopicHubView.vue`
|
||||
@@ -12,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`
|
||||
@@ -48,7 +58,7 @@
|
||||
## `/admin`
|
||||
- 화면 파일: `frontend/src/views/AdminView.vue`
|
||||
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 템플릿 이름/slug 수정, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기·최근 콘텐츠 활동·마지막 접속일 확인과 회원 정보·권한 수정 및 공개 프로필 보기, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
|
||||
- 연동 API: `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/tierlists/stats`, `PATCH /api/admin/tierlists/:tierListId/featured`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId`
|
||||
- 연동 API: `GET /api/admin/templates`, `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/tierlists/stats`, `PATCH /api/admin/tierlists/:tierListId/featured`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId`
|
||||
|
||||
## `/profile`
|
||||
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
||||
@@ -57,12 +67,13 @@
|
||||
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.vue`
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `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` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
|
||||
## 백엔드 진입점
|
||||
- 서버 엔트리: `backend/index.js`
|
||||
- 데이터 초기화: `backend/src/db.js`
|
||||
- 세부: 댓글/알림 관련 테이블(`tierlist_comments`, `comment_notifications`)은 여기서 생성되고, 기존 DB에 누락된 컬럼이 있으면 서버 시작 시 자동 보강한다.
|
||||
- 로컬 DB 실행 설정: `docker-compose.yml`
|
||||
- 로컬 MariaDB 가이드: `docs/local-mariadb.md`
|
||||
- 인증 라우트: `backend/src/routes/auth.js`
|
||||
|
||||
42
docs/spec.md
42
docs/spec.md
@@ -33,12 +33,24 @@
|
||||
- 상단 토글 버튼은 항상 고정되어 있고, 패널을 축소하면 텍스트를 숨기고 아이콘 중심 레일로 전환한다.
|
||||
- `Settings`는 별도 메뉴 항목으로만 진입하며, 사용자 카드 자체는 정보 표시 용도로만 사용한다.
|
||||
- 사용자 아바타는 원형 보더 스타일을 유지하고, `Favorites` 영역은 최근 즐겨찾기 티어표 최대 10개를 메인 메뉴보다 작은 밀도의 바로가기 목록으로 보여준 뒤 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결한다.
|
||||
- 사용자 아바타가 없을 때 표시하는 fallback 이니셜은 계정명보다 닉네임을 우선한다.
|
||||
- 중앙 워크스페이스
|
||||
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
|
||||
- 공통 `workspaceBody`는 별도 외곽 카드 테두리 없이 셸 여백만 제공하고, 실제 카드/패널 레이어는 각 화면 내부에서만 구성한다.
|
||||
- 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색 결과 화면은 같은 카드 문법(상단 16:9 썸네일, `제목+좋아요` 1행, `작성자+최종 수정일` 1행)을 공유하며, 데스크톱 기준 기본 4열 카드 그리드를 사용한다.
|
||||
- 단, 홈 게임 선택 카드는 템플릿 선택용이므로 상단 메인 썸네일은 유지하되, 하단 메타는 `게임명 + 작은 ID`만 간결하게 표시한다.
|
||||
- `/` 홈은 템플릿 선택 화면이 아니라 `공개 티어표 피드`이며, 추천 티어표와 최신 공개 티어표를 같은 보드 카드 문법으로 보여준다.
|
||||
- `/templates`는 공개 템플릿 전용 화면이며, 템플릿 카드는 상단 메인 썸네일과 `주제명 + 작은 slug/id` 메타를 가진다.
|
||||
- 목록 계열 화면의 상단 도구 영역은 통계 카드와 액션 버튼을 공통 높이/반경으로 맞춰, 같은 라이브러리 대시보드로 읽히도록 정리한다.
|
||||
|
||||
## 주요 라우트/데이터 규칙
|
||||
- 일반 `GET /api/topics`는 로그인한 관리자라도 공개 템플릿만 반환한다.
|
||||
- 관리자 전용 템플릿 목록은 `GET /api/admin/templates`를 사용하며, 비공개 템플릿까지 포함한다.
|
||||
- 홈 피드(`/`)는 `GET /api/tierlists/public?q=...`를 사용한다.
|
||||
- `featuredTierLists`: 상단 추천 티어표
|
||||
- `tierLists`: 추천 제외 최신 공개 티어표
|
||||
- 저장된 티어표에는 댓글 스레드가 붙을 수 있다. 작성자 본인 편집 화면에서는 `작업 팁` 아래, 작성자가 아닌 사용자의 보기 전용 화면에서는 `preview` 보드 아래에서 같은 댓글 카드를 사용한다.
|
||||
- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다.
|
||||
- 댓글 정렬은 루트 댓글 최신순, 각 루트 내부의 답글은 오래된순을 기본 규칙으로 유지한다.
|
||||
- 우측 패널
|
||||
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
|
||||
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
|
||||
@@ -139,6 +151,26 @@
|
||||
- `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
|
||||
- 기존 운영 DB에 예전 형태 테이블이 남아 있어도 서버 시작 시 스키마 보정으로 누락 컬럼을 자동 추가한다.
|
||||
- `templateRequests`
|
||||
- `id`: string
|
||||
- `type`: string
|
||||
@@ -191,6 +223,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`
|
||||
@@ -207,6 +242,11 @@
|
||||
- 해당 작성자의 공개 티어표 목록을 반환한다.
|
||||
- `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`는 서버가 자동 생성한다.
|
||||
|
||||
27
docs/todo.md
27
docs/todo.md
@@ -1,5 +1,32 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.1.6` 이후 루트 댓글이 최신순으로, 답글은 오래된순으로 정확히 보이는지 실제 댓글 데이터를 여러 개 넣어 확인한다.
|
||||
- 뷰어 모드에서 댓글이 길어져도 우측 `공유 티어표 보기` 카드가 스폰서 카드 바로 아래에서 유지되고, 더 이상 하단으로 밀려 보이지 않는지 확인한다.
|
||||
- `v1.1.5` 이후 댓글 카드/댓글 관리 카드에서 보더가 과해 보이지 않고, surface/shadow 중심 레이어가 다크/라이트 모드 모두에서 자연스러운지 확인한다.
|
||||
- 댓글 등록/답글 등록 버튼이 실제 저장 CTA 톤으로 보이고 hover/disabled 상태도 다른 저장 버튼들과 이질감이 없는지 확인한다.
|
||||
- `v1.1.4` 이후 댓글 관리 카드에서 티어표 썸네일, 원댓글/새 댓글 비교 블록이 데스크톱과 모바일에서 모두 자연스럽게 보이는지 확인한다.
|
||||
- 댓글 스레드 카드 리디자인 후 답글 연결선, 배지, 본문 말풍선 배경이 라이트/다크 모드 모두에서 과하지 않게 보이는지 확인한다.
|
||||
- `v1.1.3` 이후 답글 작성 시 입력창이 열리자마자 포커스를 받고, 포커스 전에도 카드/입력 경계가 분명하게 보이는지 다크/라이트 모드 모두에서 확인한다.
|
||||
- `v1.1.2` 반영 후 실제 운영/로컬 DB에서 서버를 다시 띄워 `comment_notifications.is_read` 컬럼이 자동 보강되는지, `댓글 관리` 메뉴 unread dot과 `/api/comments/inbox/unread-count`가 더 이상 SQL 오류 없이 동작하는지 확인한다.
|
||||
- `v1.1.1` 댓글 복구 이후 다음 흐름을 우선 QA한다: 공개 티어표 프리뷰 하단 댓글 노출, 댓글 작성/답글 작성/본인 댓글 삭제, 댓글 관리 메뉴 red dot, 댓글 관리 화면에서 `안 읽은 댓글만 보기`와 `모두 읽음 처리`, 카드 클릭 후 해당 댓글 위치 스크롤.
|
||||
- 작성자 본인 티어표 편집 화면과 타인 티어표 프리뷰 화면에서 같은 댓글 카드가 모두 자연스럽게 보이는지, 새로고침 후에도 기존 에디터 회귀 없이 댓글 카드만 안정적으로 붙는지 확인한다.
|
||||
- 댓글 알림 unread count는 현재 접속 시와 라우트 이동 시 갱신되므로, 다른 탭에서 새 댓글이 생겼을 때 실시간 반영이 필요하면 이후 polling 또는 SSE 도입 여부를 검토한다.
|
||||
- `v1.1.0`에서 홈을 공개 티어표 피드로, 템플릿을 `/templates`로 분리했으므로 왼쪽 사이드 `홈 / 템플릿 / 나의 티어표 / 설정` 흐름과 검색 placeholder가 각 화면에서 자연스럽게 바뀌는지 확인한다.
|
||||
- 관리자 계정으로 일반 템플릿 목록(`/templates`)에 들어가도 비공개 템플릿이 보이지 않고, 관리자 화면에서는 여전히 비공개 템플릿이 관리 가능한지 확인한다.
|
||||
- 홈 피드의 추천 티어표와 최신 공개 티어표 카드가 데스크톱/태블릿/모바일에서 overflow 없이 안정적으로 보이는지 확인한다.
|
||||
- 아바타 fallback 이니셜이 썸네일 미등록 상태에서 계정명이 아니라 닉네임 첫 글자로 보이는지 홈/주제 허브/나의 티어표/즐겨찾기/팔로우 피드/검색 결과/사용자 프로필에서 각각 확인한다.
|
||||
|
||||
## 다음 작업자 인수인계
|
||||
- 현재 기준선은 `v1.0.104`다. 홈 피드와 댓글 기능은 이 버전 위에서 다시 구현해야 하며, 편집 화면 로딩/새로고침 안정성이 먼저다.
|
||||
- 홈 피드는 기존 템플릿 메인 화면과 분리된 별도 `/` 화면으로 두되, 데이터 원천은 `공개 티어표 목록` API로만 시작한다. 첫 단계에서는 `최근 공개 티어표` 목록과 `관리자 추천 티어표` 상단 섹션만 붙이고, 템플릿 화면 카드 문법을 최대한 재사용한다.
|
||||
- 홈 피드 카드는 새 컴포넌트를 급히 만들기보다, 이미 안정적이던 템플릿/목록 카드 문법을 공통 컴포넌트로 먼저 분리한 뒤 사용한다. 썸네일 비율, 아바타 fallback, 제목/메타 overflow를 카드마다 따로 다시 구현하지 않는다.
|
||||
- 댓글 기능은 티어표 편집기 본문 안으로 깊게 섞기보다, 저장된 티어표 하단의 독립 카드 섹션으로 붙인다. 편집 모드와 preview 모드 모두 같은 댓글 카드 컴포넌트를 재사용하는 구조가 안전하다.
|
||||
- 댓글 로딩은 에디터 메인 `loadEditorState()`와 분리해, 티어표 본문이 먼저 안정적으로 그려진 뒤 별도 비동기로 붙인다. 댓글 로딩/스크롤/읽음 처리 타이머는 라우트 변경 시 즉시 취소 가능해야 한다.
|
||||
- `commentId` 이동, 댓글 관리함, unread dot 같은 부가 기능은 댓글 목록 조회/등록이 안정화된 뒤 2단계로 구현한다. 처음부터 알림, 자동 스크롤, 읽음 처리까지 한 번에 붙이지 않는다.
|
||||
- 글로벌 로딩 화면이나 initial loading state는 에디터 본문 전체를 조건부로 갈아끼우기보다, 앱 셸 수준의 고정 오버레이 또는 최소한의 skeleton으로 처리한다. Teleport 우측 레일, RouterView, 편집기 내부 DOM을 동시에 조건부 마운트/언마운트하는 방식은 피한다.
|
||||
- 티어표 편집 화면에서는 새로고침 직후 다음 세 가지 QA가 항상 선행되어야 한다: `오른쪽 아이템 풀 복구`, `왼쪽 메뉴 이동 가능`, `콘솔 Vue patch/unmount 오류 없음`.
|
||||
|
||||
## 단기 확인
|
||||
- `v1.4.68`에서 아이템 우클릭 처리를 `window` 캡처 단계로 보강했으므로, 보드에 배치된 아이템/미사용 풀 아이템/아이템 썸네일 이미지 위에서 각각 우클릭했을 때 브라우저 기본 메뉴 대신 `아이템 복제` 메뉴가 바로 뜨는지 QA한다.
|
||||
- `v1.4.67`에서 같은 `src`가 프로필 아바타와 템플릿/사용자 아이템으로 동시에 쓰여도 자산 카드를 유지하도록 바꿨으므로, 운영 관리자 화면의 `전체 이미지`와 `프로필 이미지` 필터에서 실제 아바타가 보이고 상세 모달의 공유 참조 목록도 자연스럽게 읽히는지 QA한다.
|
||||
|
||||
@@ -1,15 +1,56 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-06 v1.1.0
|
||||
- 이번 작업부터 공개 후 개선 흐름을 `v1.1.x` 라인으로 올려 이어간다. 이전 최신 문서 기준은 `v1.0.105`다.
|
||||
- 사이트 최초 접속 로딩 화면을 `접속 정보를 확인하고 있어요.` 같은 기능 안내 중심 문구에서, Tier Maker 브랜드 마크와 진행 애니메이션이 있는 스플래시 스타일로 변경했다.
|
||||
## 2026-04-07 v1.1.6
|
||||
- 댓글 영역 스타일을 다시 전면 정리했다. 과한 장식/그림자 중심 디자인 대신 `viewerSidebar__section` 계열과 같은 단정한 카드 문법으로 맞추고, 댓글/답글은 배경과 간격 위주로 읽히게 재구성했다.
|
||||
- 댓글 등록/답글 등록 버튼은 불필요한 shadow 없이 에디터 저장 계열과 같은 `btn--save` 톤으로 다시 맞췄다.
|
||||
- 댓글 정렬 규칙을 조정했다. 루트 댓글은 `최신 댓글이 가장 위`, 답글은 `가장 먼저 달린 답글이 가장 위`로 유지되도록 바꿨다.
|
||||
- 뷰어 모드 우측 카드가 댓글 길이에 따라 아래로 밀려 보이지 않도록, 우측 로컬 레일 루트와 `viewerSidebar__section` 정렬을 `스폰서 카드 바로 아래` 기준으로 고정했다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-06 v1.0.105
|
||||
- 사이트 최초 접속 시 로그인/세션 확인이 끝나기 전 게스트 UI처럼 보였다가 뒤늦게 로그인 상태로 바뀌는 깜빡임을 줄이기 위해, 앱 공통 초기 로딩 화면을 추가했다.
|
||||
- 홈 화면의 주제 템플릿 목록도 API 응답 전에는 `표시할 주제 템플릿이 없어요.`를 바로 보여주지 않고, `주제 템플릿을 불러오고 있어요.` 로딩 카드가 먼저 나오도록 정리했다.
|
||||
## 2026-04-07 v1.1.5
|
||||
- 댓글 카드에서 과도하게 겹치던 보더 문법을 줄이고, 배경 톤과 그림자 중심으로 레이어를 구분하도록 다시 정리했다. 바깥 카드, 댓글 본문, 답글 입력 영역은 border 대신 surface/shadow 조합으로 읽히게 했다.
|
||||
- 댓글 관리 카드의 티어표 썸네일은 항상 `16:9` 비율로 고정되도록 수정했다. 화면 크기에 따라 높이만 달라지고 이미지 인상 자체는 바뀌지 않게 맞췄다.
|
||||
- 댓글 등록/답글 등록 버튼은 컴포넌트 내부에서도 실제 `btn--save` 스타일이 적용되도록 공통 save CTA 문법을 직접 정의해, 에디터 저장 버튼과 같은 톤으로 보이게 했다.
|
||||
- `commentsCard__desc` 안내 문구 폰트 크기를 `12px`로 줄여 본문보다 덜 강조되게 정리했다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-07 v1.1.4
|
||||
- 댓글 관리 카드 디자인을 확장했다. 각 카드에 해당 티어표 썸네일을 붙이고, `원래 댓글`과 `새 댓글/새 답글`을 한 번에 비교해서 볼 수 있게 스레드 블록 구조로 바꿨다.
|
||||
- 댓글 알림 조회 API는 이제 티어표 썸네일과 부모 댓글 내용을 함께 내려준다. 답글 알림에서는 어떤 댓글에 어떤 답글이 달렸는지 바로 읽을 수 있다.
|
||||
- 일반 댓글 카드(`commentItem`)도 더 카드형이고 세련된 톤으로 정리했다. 본문은 말풍선처럼 분리하고, 답글은 얇은 연결선과 보조 배지로 관계가 자연스럽게 읽히도록 다듬었다.
|
||||
- 확인: `node --check backend/src/db.js`, `npm run build`
|
||||
|
||||
## 2026-04-07 v1.1.3
|
||||
- 댓글 답글 입력 UX를 다듬었다. `답글` 버튼을 누르면 입력창이 열리자마자 자동으로 포커스가 이동하고, 포커스 전에도 구분이 되도록 답글 입력 영역 카드와 textarea 기본 경계선을 보강했다.
|
||||
- 답글 등록 버튼도 기존의 작은 기본형 버튼 대신 프로젝트 전반의 저장 계열 CTA 문법과 같은 `btn--save` 스타일로 맞췄다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-07 v1.1.2
|
||||
- 댓글 알림 테이블을 기존 DB에서도 안전하게 올릴 수 있도록 스키마 보정 로직을 추가했다. 예전 형태의 `comment_notifications` 또는 `tierlist_comments` 테이블이 이미 있어도 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`로 `is_read`, `read_at`, `notification_type`, `actor_user_id`, `parent_comment_id`, `updated_at`를 보강한다.
|
||||
- 이 수정으로 기존 DB에서 `/api/comments/inbox/unread-count` 호출 시 `Unknown column 'is_read' in 'WHERE'`가 나던 문제를 해결한다.
|
||||
- 확인: `node --check backend/src/db.js`, `npm run build`
|
||||
|
||||
## 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` 화면으로 분리했다. 기존 템플릿 카드 문법과 즐겨찾기 토글은 그대로 유지하면서, 홈과 템플릿의 역할을 분리했다.
|
||||
- 일반 `/api/topics`는 이제 관리자 계정이라도 `비공개 템플릿`을 포함하지 않는다. 관리자 화면은 별도 `/api/admin/templates` 목록 API를 사용해 비공개 템플릿까지 관리한다.
|
||||
- 사용자 아바타가 없을 때 표시하는 fallback 이니셜 기준을 계정명보다 `닉네임 우선`으로 통일했다. 홈/주제 허브/나의 티어표/즐겨찾기/팔로우 피드/검색 결과/사용자 프로필/앱 셸까지 같은 helper를 사용한다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-07 기준 복원 메모
|
||||
- 현재 작업 트리는 사용자 요청에 따라 `v1.0.104` 태그 기준으로 전체 복원했다.
|
||||
- 복원 이유: 티어표 편집 화면에 `initial loading states`와 이후 홈 피드/댓글 기능을 얹는 과정에서 새로고침 시 사이트 전체가 멈추는 회귀가 발생했고, 편집 화면 진입 경로도 일부 저장본에서 `freeform`으로 잘못 열리는 문제가 겹쳤다.
|
||||
- 따라서 다음 작업자는 `v1.0.104`를 안정 기준선으로 삼고, 홈 피드/댓글 기능은 이 기준 위에서 다시 단계적으로 구현해야 한다.
|
||||
|
||||
## 2026-04-06 v1.0.104
|
||||
- 관리자 아이템 사용 횟수 기준을 실제 티어표 배치 기준으로 정리했다. 이제 `pool_json`에 남아 있는 미사용 후보는 사용 횟수에 포함하지 않고, `groups_json`에 실제 배치된 아이템만 사용된 것으로 계산한다.
|
||||
- 템플릿 아이템 사용 횟수도 같은 이미지가 연결된 템플릿 개수가 아니라, 해당 기본 아이템이 저장된 티어표 보드에 실제로 배치된 횟수를 기준으로 계산한다.
|
||||
|
||||
@@ -2,7 +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 } 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'
|
||||
@@ -30,7 +32,7 @@ const leftRailCollapsed = ref(false)
|
||||
const mobileLeftNavOpen = ref(false)
|
||||
const rightRailOpen = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const leftRailSearchPlaceholder = '주제 템플릿 검색'
|
||||
const leftRailSearchPlaceholder = computed(() => (route.name === 'templates' ? '주제 템플릿 검색' : '공개 티어표 검색'))
|
||||
const isCollapsedSearchOpen = ref(false)
|
||||
const isGuideModalOpen = ref(false)
|
||||
const themeMode = ref('dark')
|
||||
@@ -39,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')
|
||||
|
||||
@@ -70,10 +73,12 @@ const shellStyle = computed(() => ({
|
||||
}))
|
||||
const leftNavItems = computed(() => {
|
||||
const items = [
|
||||
{ key: 'home', label: '주제 선택', path: '/', iconSrc: iconGridView },
|
||||
{ key: 'home', label: '홈', path: '/', iconSrc: iconGridView },
|
||||
{ key: 'templates', label: '템플릿', path: '/templates', iconSrc: iconDashboardCustomize },
|
||||
{ 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))
|
||||
@@ -155,13 +160,15 @@ const showSettingsThemePanel = computed(() => route.name === 'profile')
|
||||
const showTopicViewToggle = computed(() => route.name === 'topicHub')
|
||||
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
|
||||
const showInitialAppLoading = computed(() => !authReady.value && !showBackendFallback.value)
|
||||
const shouldLockRightRailBodyScroll = computed(() => isRightRailOverlay.value && rightRailOpen.value && !showBackendFallback.value)
|
||||
const leftBottomPrimaryAction = computed(() => {
|
||||
if (!authReady.value) return null
|
||||
if (route.name === 'home' && auth.user) {
|
||||
return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
|
||||
}
|
||||
if (route.name === 'templates' && auth.user) {
|
||||
return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
|
||||
}
|
||||
if (route.name === 'topicHub') {
|
||||
const target = editorNewPath(currentTopicId.value)
|
||||
return { label: '새 티어표 만들기', to: auth.user ? target : loginPath(target), iconSrc: iconAddNotes }
|
||||
@@ -172,10 +179,22 @@ const leftBottomPrimaryAction = computed(() => {
|
||||
const routeMeta = computed(() => {
|
||||
if (route.name === 'home') {
|
||||
return {
|
||||
title: '주제 선택',
|
||||
subtitle: '주제 템플릿 선택과 커스텀 보드 시작',
|
||||
title: '홈',
|
||||
subtitle: '공개 티어표 피드',
|
||||
contextTitle: '빠른 시작',
|
||||
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 주제를 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
||||
contextText: auth.user ? '추천 티어표와 최신 공개 티어표를 둘러보고 바로 새 작업을 시작할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
||||
actionLabel: '템플릿 보기',
|
||||
action: () => {
|
||||
router.push(templatesPath())
|
||||
},
|
||||
}
|
||||
}
|
||||
if (route.name === 'templates') {
|
||||
return {
|
||||
title: '템플릿',
|
||||
subtitle: '주제 템플릿 선택',
|
||||
contextTitle: '빠른 시작',
|
||||
contextText: auth.user ? '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
||||
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
|
||||
action: () => {
|
||||
router.push(auth.user ? editorNewPath('freeform') : loginPath())
|
||||
@@ -245,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: '작성자 프로필',
|
||||
@@ -297,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)
|
||||
@@ -328,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)
|
||||
@@ -344,6 +391,7 @@ onMounted(async () => {
|
||||
rightRailOpen.value = true
|
||||
}
|
||||
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||
await refreshUnreadCommentCount()
|
||||
})
|
||||
|
||||
function handleGlobalKeydown(event) {
|
||||
@@ -407,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)
|
||||
@@ -425,6 +474,14 @@ watch(
|
||||
mobileLeftNavOpen.value = false
|
||||
rightRailOpen.value = false
|
||||
}
|
||||
refreshUnreadCommentCount()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => auth.user?.id,
|
||||
() => {
|
||||
refreshUnreadCommentCount()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -540,7 +597,7 @@ function handleLeftRailSearch() {
|
||||
function submitGlobalSearch() {
|
||||
const query = (searchQuery.value || '').trim()
|
||||
isCollapsedSearchOpen.value = false
|
||||
router.push(homePath(query))
|
||||
router.push(route.name === 'templates' ? templatesPath(query) : homePath(query))
|
||||
}
|
||||
|
||||
function reloadApp() {
|
||||
@@ -581,24 +638,6 @@ function reloadApp() {
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
<template v-else-if="showInitialAppLoading">
|
||||
<main class="appLoading">
|
||||
<section class="appLoading__card">
|
||||
<div class="appLoading__brand" aria-hidden="true">
|
||||
<div class="appLoading__logo">
|
||||
<span class="appLoading__logoMain">T</span>
|
||||
<span class="appLoading__logoBar"></span>
|
||||
</div>
|
||||
<span class="appLoading__orb appLoading__orb--mint"></span>
|
||||
<span class="appLoading__orb appLoading__orb--blue"></span>
|
||||
</div>
|
||||
<div class="appLoading__eyebrow">Tier Maker</div>
|
||||
<h1 class="appLoading__title">티어표를 준비하고 있어요</h1>
|
||||
<p class="appLoading__desc">잠시만 기다려 주세요.</p>
|
||||
<div class="appLoading__progress" aria-hidden="true"><span></span></div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
<template v-else>
|
||||
<aside class="leftRail">
|
||||
<div class="leftRail__top railHeader">
|
||||
@@ -612,7 +651,7 @@ function reloadApp() {
|
||||
<div v-if="authReady" class="appUserCard">
|
||||
<div class="appUserCard__button">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" draggable="false" />
|
||||
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
|
||||
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ displayInitialFrom(auth.user?.nickname, accountName, 'U') }}</div>
|
||||
<div class="appUserCard__meta">
|
||||
<div class="appUserCard__name">{{ accountName }}</div>
|
||||
<div class="appUserCard__email" :class="{ 'appUserCard__email--hint': isAccountEmailHint }">{{ accountEmail }}</div>
|
||||
@@ -658,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>
|
||||
@@ -856,8 +896,7 @@ function reloadApp() {
|
||||
transition: grid-template-columns 220ms ease;
|
||||
}
|
||||
|
||||
.backendFallback,
|
||||
.appLoading {
|
||||
.backendFallback {
|
||||
min-width: 100dvw;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
@@ -868,17 +907,7 @@ function reloadApp() {
|
||||
var(--theme-shell-bg);
|
||||
}
|
||||
|
||||
.appLoading {
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 50% 18%, rgba(127, 231, 214, 0.22), transparent 28%),
|
||||
radial-gradient(circle at 26% 76%, rgba(95, 202, 255, 0.18), transparent 30%),
|
||||
radial-gradient(circle at 78% 68%, rgba(167, 139, 250, 0.14), transparent 28%),
|
||||
var(--theme-shell-bg);
|
||||
}
|
||||
|
||||
.backendFallback__card,
|
||||
.appLoading__card {
|
||||
.backendFallback__card {
|
||||
width: min(100%, 560px);
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
@@ -889,104 +918,7 @@ function reloadApp() {
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
}
|
||||
|
||||
.appLoading__card {
|
||||
position: relative;
|
||||
width: min(100%, 420px);
|
||||
justify-items: center;
|
||||
gap: 14px;
|
||||
padding: 42px 32px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
background:
|
||||
linear-gradient(145deg, color-mix(in srgb, var(--theme-card-bg) 92%, white), var(--theme-card-bg)),
|
||||
var(--theme-card-bg);
|
||||
}
|
||||
|
||||
.appLoading__card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -42%;
|
||||
background: conic-gradient(from 90deg, transparent, rgba(127, 231, 214, 0.18), transparent 34%, rgba(95, 202, 255, 0.16), transparent 68%);
|
||||
animation: appLoadingAura 4.8s linear infinite;
|
||||
}
|
||||
|
||||
.appLoading__card > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.appLoading__brand {
|
||||
position: relative;
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.appLoading__logo {
|
||||
position: relative;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 24px;
|
||||
border: 1px solid color-mix(in srgb, var(--theme-accent-strong) 34%, transparent);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(127, 231, 214, 0.2), rgba(95, 202, 255, 0.12)),
|
||||
color-mix(in srgb, var(--theme-card-bg) 86%, #090d16);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.28),
|
||||
0 0 34px rgba(127, 231, 214, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
animation: appLoadingFloat 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.appLoading__logoMain {
|
||||
color: var(--theme-accent-strong);
|
||||
font-size: 44px;
|
||||
font-weight: 950;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.12em;
|
||||
}
|
||||
|
||||
.appLoading__logoBar {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
bottom: 16px;
|
||||
width: 10px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: #5fcaff;
|
||||
box-shadow: 0 0 18px rgba(95, 202, 255, 0.54);
|
||||
}
|
||||
|
||||
.appLoading__orb {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
filter: drop-shadow(0 0 12px currentColor);
|
||||
animation: appLoadingOrbit 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.appLoading__orb--mint {
|
||||
top: 10px;
|
||||
right: 18px;
|
||||
color: #7fe7d6;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.appLoading__orb--blue {
|
||||
left: 14px;
|
||||
bottom: 18px;
|
||||
color: #5fcaff;
|
||||
background: currentColor;
|
||||
animation-delay: -1.1s;
|
||||
}
|
||||
|
||||
.backendFallback__eyebrow,
|
||||
.appLoading__eyebrow {
|
||||
.backendFallback__eyebrow {
|
||||
color: var(--theme-accent-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
@@ -994,94 +926,20 @@ function reloadApp() {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.backendFallback__title,
|
||||
.appLoading__title {
|
||||
.backendFallback__title {
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.appLoading__title {
|
||||
font-size: clamp(27px, 4vw, 38px);
|
||||
}
|
||||
|
||||
.backendFallback__desc,
|
||||
.appLoading__desc {
|
||||
.backendFallback__desc {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.appLoading__progress {
|
||||
width: min(100%, 220px);
|
||||
height: 6px;
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--theme-text-faint) 18%, transparent);
|
||||
}
|
||||
|
||||
.appLoading__progress span {
|
||||
display: block;
|
||||
width: 42%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #7fe7d6, #5fcaff);
|
||||
box-shadow: 0 0 20px rgba(127, 231, 214, 0.46);
|
||||
animation: appLoadingProgress 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes appLoadingAura {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes appLoadingFloat {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-6px) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes appLoadingOrbit {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(8px, -10px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes appLoadingProgress {
|
||||
0% {
|
||||
transform: translateX(-110%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(250%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.appLoading__card::before,
|
||||
.appLoading__logo,
|
||||
.appLoading__orb,
|
||||
.appLoading__progress span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.backendFallback__actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
@@ -1444,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;
|
||||
}
|
||||
@@ -2134,6 +2004,8 @@ function reloadApp() {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
|
||||
631
frontend/src/components/TierListCommentsCard.vue
Normal file
631
frontend/src/components/TierListCommentsCard.vue
Normal file
@@ -0,0 +1,631 @@
|
||||
<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('')
|
||||
const replyInputRefs = 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 registerReplyInput(commentId, element) {
|
||||
if (!commentId) return
|
||||
if (element) {
|
||||
replyInputRefs.value[commentId] = element
|
||||
return
|
||||
}
|
||||
delete replyInputRefs.value[commentId]
|
||||
}
|
||||
|
||||
async function focusReplyInput(commentId) {
|
||||
if (!commentId) return
|
||||
await nextTick()
|
||||
const target = replyInputRefs.value[commentId]
|
||||
if (target && typeof target.focus === 'function') {
|
||||
target.focus()
|
||||
const value = target.value || ''
|
||||
if (typeof target.setSelectionRange === 'function') {
|
||||
target.setSelectionRange(value.length, value.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleReplyComposer(commentId) {
|
||||
const nextId = openedReplyComposerId.value === commentId ? '' : commentId
|
||||
openedReplyComposerId.value = nextId
|
||||
if (!nextId) return
|
||||
await focusReplyInput(nextId)
|
||||
}
|
||||
|
||||
watch(() => props.tierListId, loadComments, { immediate: true })
|
||||
watch(highlightedCommentId, () => {
|
||||
scrollToHighlightedComment()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearActiveCommentRetry()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="commentsCard">
|
||||
<header class="commentsCard__head">
|
||||
<div class="commentsCard__headline">
|
||||
<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 commentsComposer__submit" 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 commentsComposer__submit" :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]"
|
||||
:ref="(element) => registerReplyInput(comment.id, element)"
|
||||
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 commentsComposer__submit" 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: 18px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-pill-bg);
|
||||
}
|
||||
|
||||
.commentsCard__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.commentsCard__headline {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.commentsCard__desc {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.commentsCard__count {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--theme-border);
|
||||
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 {
|
||||
margin-bottom: 18px;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
background: var(--theme-surface-soft);
|
||||
}
|
||||
|
||||
.commentsComposer__input {
|
||||
width: 100%;
|
||||
min-height: 92px;
|
||||
resize: vertical;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-input-bg);
|
||||
color: var(--theme-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.commentsComposer__input--reply {
|
||||
min-height: 72px;
|
||||
background: var(--theme-input-bg);
|
||||
}
|
||||
|
||||
.commentsComposer__input:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--theme-accent) 60%, var(--theme-field-border));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-accent) 16%, transparent);
|
||||
}
|
||||
|
||||
.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: 12px;
|
||||
}
|
||||
|
||||
.commentItem {
|
||||
position: relative;
|
||||
padding: 14px 0 0;
|
||||
}
|
||||
|
||||
.commentItem--reply {
|
||||
margin-top: 10px;
|
||||
margin-left: 22px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.commentItem--reply::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -12px;
|
||||
width: 1px;
|
||||
bottom: 0;
|
||||
background: color-mix(in srgb, var(--theme-border) 82%, transparent);
|
||||
}
|
||||
|
||||
.commentItem--highlighted {
|
||||
border-radius: 18px;
|
||||
background: color-mix(in srgb, var(--theme-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.commentItem__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
padding: 14px 15px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.replyComposer {
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
background: var(--theme-surface-soft);
|
||||
}
|
||||
|
||||
.commentsComposer__submit {
|
||||
min-width: 112px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text);
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
transition: background 160ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--theme-surface-soft-3);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.58;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn--save {
|
||||
min-width: 112px;
|
||||
font-weight: 900;
|
||||
background: rgba(96, 165, 250, 0.22);
|
||||
border-color: rgba(96, 165, 250, 0.36);
|
||||
}
|
||||
|
||||
.btn--save:hover {
|
||||
background: rgba(96, 165, 250, 0.3);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: color-mix(in srgb, var(--theme-surface-soft) 86%, transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.commentsCard {
|
||||
padding: 18px 16px;
|
||||
}
|
||||
|
||||
.commentsCard__head,
|
||||
.commentsComposer__footer,
|
||||
.commentsLoginCta,
|
||||
.commentItem__head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.commentItem--reply {
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.commentItem--reply::before {
|
||||
left: -8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -67,6 +67,7 @@ export const api = {
|
||||
logout: () => request('/api/auth/logout', { method: 'POST' }),
|
||||
|
||||
listTopics: () => request('/api/topics'),
|
||||
listAdminTemplates: () => request('/api/admin/templates'),
|
||||
getTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}`),
|
||||
favoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'DELETE' }),
|
||||
@@ -169,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) => {
|
||||
|
||||
9
frontend/src/lib/display.js
Normal file
9
frontend/src/lib/display.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export function displayInitialFrom(primaryName = '', fallbackName = '', emptyValue = 'U') {
|
||||
const primary = String(primaryName || '').trim()
|
||||
if (primary) return Array.from(primary)[0] || emptyValue
|
||||
|
||||
const fallback = String(fallbackName || '').trim()
|
||||
if (fallback) return Array.from(fallback)[0] || emptyValue
|
||||
|
||||
return emptyValue
|
||||
}
|
||||
@@ -7,6 +7,11 @@ export function homePath(query = '') {
|
||||
return normalized ? `/?q=${encodeURIComponent(normalized)}` : '/'
|
||||
}
|
||||
|
||||
export function templatesPath(query = '') {
|
||||
const normalized = String(query || '').trim()
|
||||
return normalized ? `/templates?q=${encodeURIComponent(normalized)}` : '/templates'
|
||||
}
|
||||
|
||||
export function loginPath(redirect = '') {
|
||||
const normalized = String(redirect || '').trim()
|
||||
return normalized ? `/login?redirect=${encodeURIComponent(normalized)}` : '/login'
|
||||
@@ -41,6 +46,10 @@ export function followingFeedPath() {
|
||||
return '/following'
|
||||
}
|
||||
|
||||
export function commentsPath() {
|
||||
return '/comments'
|
||||
}
|
||||
|
||||
export function profilePath() {
|
||||
return '/profile'
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { createRouter as _createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import TemplatesView from '../views/TemplatesView.vue'
|
||||
import TopicHubView from '../views/TopicHubView.vue'
|
||||
import TierEditorView from '../views/TierEditorView.vue'
|
||||
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'
|
||||
@@ -18,6 +20,7 @@ export function createRouter() {
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: HomeView },
|
||||
{ path: '/templates', name: 'templates', component: TemplatesView },
|
||||
{ path: '/topics/:topicId', name: 'topicHub', component: TopicHubView },
|
||||
{ path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView },
|
||||
{ path: '/editor/:topicId/:tierListId', name: 'editEditor', component: TierEditorView },
|
||||
@@ -25,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 },
|
||||
|
||||
@@ -946,7 +946,7 @@ async function selectAdminTemplate(templateId) {
|
||||
|
||||
async function refreshTemplates() {
|
||||
try {
|
||||
const data = await api.listTopics()
|
||||
const data = await api.listAdminTemplates()
|
||||
templates.value = data.topics || []
|
||||
featuredTemplateIds.value = templates.value
|
||||
.filter((template) => template.displayRank != null)
|
||||
|
||||
441
frontend/src/views/CommentInboxView.vue
Normal file
441
frontend/src/views/CommentInboxView.vue
Normal file
@@ -0,0 +1,441 @@
|
||||
<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 tierListThumbnailUrl(notification) {
|
||||
return notification.tierListThumbnailSrc ? toApiUrl(notification.tierListThumbnailSrc) : ''
|
||||
}
|
||||
|
||||
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 notificationLead(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__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(notification)"
|
||||
class="commentInboxCard__thumb"
|
||||
:src="tierListThumbnailUrl(notification)"
|
||||
:alt="notification.tierListTitle || '티어표 썸네일'"
|
||||
draggable="false"
|
||||
/>
|
||||
<div v-else class="commentInboxCard__thumbFallback">티어표</div>
|
||||
</div>
|
||||
<div class="commentInboxCard__main">
|
||||
<div class="commentInboxCard__titleRow">
|
||||
<div>
|
||||
<div class="commentInboxCard__title">{{ notificationTitle(notification) }}</div>
|
||||
<div class="commentInboxCard__lead">{{ notificationLead(notification) }}</div>
|
||||
</div>
|
||||
<div class="commentInboxCard__status">
|
||||
<span v-if="!notification.isRead" class="commentInboxCard__dot" aria-label="안 읽음"></span>
|
||||
<span class="commentInboxCard__badge">{{ notification.notificationType === 'comment_reply' ? '답글' : '댓글' }}</span>
|
||||
</div>
|
||||
</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__thread">
|
||||
<div v-if="notification.parentCommentContent" class="commentInboxCard__threadBlock">
|
||||
<div class="commentInboxCard__threadLabel">원래 댓글</div>
|
||||
<div class="commentInboxCard__threadText">{{ notification.parentCommentContent }}</div>
|
||||
</div>
|
||||
<div class="commentInboxCard__threadBlock commentInboxCard__threadBlock--accent">
|
||||
<div class="commentInboxCard__threadLabel">{{ notification.notificationType === 'comment_reply' ? '새 답글' : '새 댓글' }}</div>
|
||||
<div class="commentInboxCard__threadText">{{ notification.commentContent }}</div>
|
||||
</div>
|
||||
</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;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-surface) 92%, var(--theme-surface-soft)) 0%, var(--theme-surface) 100%);
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, white 5%, transparent),
|
||||
0 14px 28px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.commentInboxCard--unread {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--theme-accent) 38%, transparent),
|
||||
0 14px 28px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.commentInboxCard__body {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
grid-template-columns: 140px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.commentInboxCard__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: var(--theme-thumb-fallback-bg);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--theme-card-border) 26%, transparent);
|
||||
}
|
||||
|
||||
.commentInboxCard__thumb,
|
||||
.commentInboxCard__thumbFallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.commentInboxCard__thumb {
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.commentInboxCard__thumbFallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--theme-text-faint);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.commentInboxCard__titleRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.commentInboxCard__title {
|
||||
font-size: 17px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.commentInboxCard__lead {
|
||||
margin-top: 6px;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.commentInboxCard__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.commentInboxCard__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: #ff4d67;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.commentInboxCard__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--theme-accent) 18%, var(--theme-surface-soft));
|
||||
color: var(--theme-text);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.commentInboxCard__thread {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.commentInboxCard__threadBlock {
|
||||
padding: 14px 15px;
|
||||
border-radius: 18px;
|
||||
background: color-mix(in srgb, var(--theme-surface) 88%, var(--theme-surface-soft));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--theme-card-border) 20%, transparent),
|
||||
0 8px 18px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.commentInboxCard__threadBlock--accent {
|
||||
background: color-mix(in srgb, var(--theme-accent) 10%, var(--theme-surface));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--theme-accent) 24%, transparent),
|
||||
0 10px 20px rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.commentInboxCard__threadLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
|
||||
.commentInboxCard__threadText {
|
||||
margin-top: 8px;
|
||||
color: var(--theme-text);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.commentInboxToolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.commentInboxPanel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.commentInboxCard__body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@ import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { editorPath, loginPath } from '../lib/paths'
|
||||
import { displayInitialFrom } from '../lib/display'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -30,7 +31,7 @@ function avatarSrcOf(tierList) {
|
||||
}
|
||||
|
||||
function avatarFallbackOf(tierList) {
|
||||
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||
return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
|
||||
}
|
||||
|
||||
function tierListThumbnailUrl(tierList) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api } from '../lib/api'
|
||||
import { editorPath, loginPath, userProfilePath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { displayInitialFrom } from '../lib/display'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -38,7 +39,7 @@ function avatarSrcOf(tierList) {
|
||||
}
|
||||
|
||||
function avatarFallbackOf(tierList) {
|
||||
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||
return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
|
||||
}
|
||||
|
||||
function tierListThumbnailUrl(tierList) {
|
||||
|
||||
@@ -2,137 +2,166 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import SvgIcon from '../components/SvgIcon.vue'
|
||||
import kidStarIcon from '../assets/icons/kid_star.svg'
|
||||
import { editorPath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { loginPath, topicPath } from '../lib/paths'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { displayInitialFrom } from '../lib/display'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const templateRecords = ref([])
|
||||
const featuredTierLists = ref([])
|
||||
const tierLists = ref([])
|
||||
const error = ref('')
|
||||
const isLoadingTemplates = ref(false)
|
||||
const hasLoadedTemplates = ref(false)
|
||||
const loadingFavoriteId = ref('')
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||
const templates = computed(() => {
|
||||
const filtered = templateRecords.value
|
||||
.filter((item) => item.id !== 'freeform')
|
||||
.filter((item) => {
|
||||
if (!query.value) return true
|
||||
const haystack = `${item.name || ''} ${item.slug || ''}`.toLowerCase()
|
||||
return haystack.includes(query.value)
|
||||
})
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim() : ''))
|
||||
const brokenThumbnailIds = ref({})
|
||||
|
||||
return filtered.slice().sort((a, b) => {
|
||||
if (!!a.isFavorited !== !!b.isFavorited) return a.isFavorited ? -1 : 1
|
||||
const rankA = a.displayRank == null ? Number.MAX_SAFE_INTEGER : a.displayRank
|
||||
const rankB = b.displayRank == null ? Number.MAX_SAFE_INTEGER : b.displayRank
|
||||
if (rankA !== rankB) return rankA - rankB
|
||||
if (Number(a.createdAt || 0) !== Number(b.createdAt || 0)) {
|
||||
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
|
||||
}
|
||||
return (a.name || '').localeCompare(b.name || '', 'ko')
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
function displayNameOf(tierList) {
|
||||
return tierList.authorName || '알 수 없음'
|
||||
}
|
||||
|
||||
function avatarSrcOf(tierList) {
|
||||
return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : ''
|
||||
}
|
||||
|
||||
function avatarFallbackOf(tierList) {
|
||||
return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
|
||||
}
|
||||
|
||||
function tierListThumbnailUrl(tierList) {
|
||||
if (!tierList?.id || brokenThumbnailIds.value[tierList.id]) return ''
|
||||
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
||||
}
|
||||
|
||||
function handleThumbnailError(tierListId) {
|
||||
if (!tierListId || brokenThumbnailIds.value[tierListId]) return
|
||||
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
|
||||
}
|
||||
|
||||
async function loadHomeFeed() {
|
||||
try {
|
||||
isLoadingTemplates.value = true
|
||||
error.value = ''
|
||||
const data = await api.listTopics()
|
||||
templateRecords.value = data.topics || []
|
||||
const data = await api.searchAllPublicTierLists(query.value)
|
||||
brokenThumbnailIds.value = {}
|
||||
featuredTierLists.value = data.featuredTierLists || []
|
||||
tierLists.value = data.tierLists || []
|
||||
} catch (e) {
|
||||
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
||||
} finally {
|
||||
isLoadingTemplates.value = false
|
||||
hasLoadedTemplates.value = true
|
||||
error.value = '공개 티어표를 불러오지 못했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadTemplates)
|
||||
watch(() => auth.user?.id, loadTemplates)
|
||||
|
||||
function openTopic(template) {
|
||||
router.push(topicPath(template?.slug || template?.id || ''))
|
||||
function openTierList(tierList) {
|
||||
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
|
||||
}
|
||||
|
||||
async function toggleFavorite(template, event) {
|
||||
event?.stopPropagation()
|
||||
if (!auth.user) {
|
||||
router.push(loginPath(route.fullPath || '/'))
|
||||
return
|
||||
}
|
||||
if (!template?.id || loadingFavoriteId.value === template.id) return
|
||||
|
||||
try {
|
||||
loadingFavoriteId.value = template.id
|
||||
const res = template.isFavorited ? await api.unfavoriteTopic(template.id) : await api.favoriteTopic(template.id)
|
||||
templateRecords.value = templateRecords.value.map((entry) => (entry.id === template.id ? { ...entry, ...(res.topic || {}) } : entry))
|
||||
} catch (e) {
|
||||
error.value = '즐겨찾기 변경에 실패했어요.'
|
||||
} finally {
|
||||
loadingFavoriteId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function templateThumbUrl(template) {
|
||||
return template.thumbnailSrc ? toApiUrl(template.thumbnailSrc) : ''
|
||||
}
|
||||
onMounted(loadHomeFeed)
|
||||
watch(() => route.query.q, loadHomeFeed)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Topic</div>
|
||||
<h1 class="pageHead__title">주제 선택</h1>
|
||||
<p class="pageHead__desc">자주 쓰는 주제 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 주제 템플릿만 보고 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section v-else-if="isLoadingTemplates && !hasLoadedTemplates" class="libraryLoading" aria-live="polite">
|
||||
<div class="libraryLoading__spinner" aria-hidden="true"></div>
|
||||
<div>
|
||||
<div class="libraryLoading__title">주제 템플릿을 불러오고 있어요.</div>
|
||||
<div class="libraryLoading__desc">처음 접속 시 서버 응답에 따라 잠시 걸릴 수 있어요.</div>
|
||||
</div>
|
||||
</section>
|
||||
<TransitionGroup v-else-if="templates.length" name="libraryCard" tag="section" class="libraryGrid">
|
||||
<article v-for="template in templates" :key="template.id" class="libraryCard">
|
||||
<button
|
||||
class="libraryCard__favorite"
|
||||
type="button"
|
||||
:class="{ 'libraryCard__favorite--active': template.isFavorited }"
|
||||
:disabled="loadingFavoriteId === template.id"
|
||||
@click.stop="toggleFavorite(template, $event)"
|
||||
>
|
||||
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
|
||||
</button>
|
||||
<button class="libraryCard__main" type="button" @click="openTopic(template)">
|
||||
<div class="libraryCard__thumbWrap">
|
||||
<img v-if="templateThumbUrl(template)" class="libraryCard__thumb" :src="templateThumbUrl(template)" :alt="template.name" draggable="false" />
|
||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||
<section class="pageWrap">
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Feed</div>
|
||||
<h1 class="pageHead__title">홈</h1>
|
||||
<div class="pageHead__desc">사용자가 공개한 티어표를 최신순으로 살펴보고, 추천 티어표는 상단에서 바로 볼 수 있어요.</div>
|
||||
<div v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 공개 티어표만 보고 있어요.</div>
|
||||
</div>
|
||||
<div class="libraryCard__body">
|
||||
<div class="libraryCard__title">{{ template.name }}</div>
|
||||
<div class="libraryCard__meta">{{ template.slug || template.id }}</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<section v-if="featuredTierLists.length" class="featuredPanel">
|
||||
<div class="featuredHead">
|
||||
<div>
|
||||
<div class="featuredHead__eyebrow">Featured</div>
|
||||
<h3 class="featuredHead__title">추천 티어표</h3>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</TransitionGroup>
|
||||
<div v-else-if="hasLoadedTemplates" class="libraryEmpty">{{ query ? '검색어에 맞는 주제 템플릿이 없어요.' : '표시할 주제 템플릿이 없어요.' }}</div>
|
||||
<div class="featuredHead__count">{{ featuredTierLists.length }}개</div>
|
||||
</div>
|
||||
<div class="list">
|
||||
<article v-for="tierList in featuredTierLists" :key="`featured-${tierList.id}`" class="boardCard boardCard--featured">
|
||||
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(tierList)"
|
||||
class="boardCard__thumb"
|
||||
:src="tierListThumbnailUrl(tierList)"
|
||||
:alt="tierList.title"
|
||||
draggable="false"
|
||||
@error="handleThumbnailError(tierList.id)"
|
||||
/>
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ tierList.title }}</div>
|
||||
<div class="favoriteStat">{{ tierList.isFavorited ? '♥' : '♡' }} {{ tierList.favoriteCount || 0 }}</div>
|
||||
</div>
|
||||
<div class="boardCard__topic">{{ tierList.topicName || tierList.topicSlug || tierList.topicId }}</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" draggable="false" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
||||
</div>
|
||||
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="sectionLabel">최신 공개 티어표</div>
|
||||
<div v-if="tierLists.length === 0" class="empty">{{ query ? '검색어에 맞는 공개 티어표가 없어요.' : '아직 공개 티어표가 없어요.' }}</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(tierList)"
|
||||
class="boardCard__thumb"
|
||||
:src="tierListThumbnailUrl(tierList)"
|
||||
:alt="tierList.title"
|
||||
draggable="false"
|
||||
@error="handleThumbnailError(tierList.id)"
|
||||
/>
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ tierList.title }}</div>
|
||||
<div class="favoriteStat">{{ tierList.isFavorited ? '♥' : '♡' }} {{ tierList.favoriteCount || 0 }}</div>
|
||||
</div>
|
||||
<div class="boardCard__topic">{{ tierList.topicName || tierList.topicSlug || tierList.topicId }}</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" draggable="false" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
||||
</div>
|
||||
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.libraryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
.pageHead__searchState {
|
||||
margin-top: 8px;
|
||||
color: var(--theme-text-muted);
|
||||
}
|
||||
.error {
|
||||
margin: 0 0 16px;
|
||||
@@ -142,178 +171,204 @@ function templateThumbUrl(template) {
|
||||
background: var(--theme-danger-bg);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.pageHead__searchState {
|
||||
margin-top: 8px;
|
||||
color: var(--theme-text-muted);
|
||||
.panel {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.libraryLoading {
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 28px;
|
||||
border-radius: 24px;
|
||||
.featuredPanel {
|
||||
margin-bottom: 28px;
|
||||
padding: 24px;
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
color: var(--theme-text);
|
||||
background: linear-gradient(180deg, var(--theme-surface-soft) 0%, var(--theme-surface) 100%);
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
}
|
||||
.libraryLoading__spinner {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
border: 3px solid color-mix(in srgb, var(--theme-text-faint) 32%, transparent);
|
||||
border-top-color: var(--theme-accent);
|
||||
animation: libraryLoadingSpin 820ms linear infinite;
|
||||
.featuredHead {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.libraryLoading__title {
|
||||
.featuredHead__eyebrow,
|
||||
.sectionLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
.libraryLoading__desc {
|
||||
margin-top: 4px;
|
||||
color: var(--theme-text-soft);
|
||||
.featuredHead__title {
|
||||
margin: 6px 0 0;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.featuredHead__count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--theme-text-muted);
|
||||
}
|
||||
@keyframes libraryLoadingSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
.sectionLabel {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.libraryCard {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
padding: 14px;
|
||||
.empty {
|
||||
opacity: 0.76;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.boardCard {
|
||||
min-width: 0;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
color: var(--theme-text);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
transition: transform 0.16s ease, background 0.16s ease;
|
||||
will-change: transform, opacity;
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease;
|
||||
}
|
||||
.libraryCard:hover {
|
||||
background: var(--theme-card-bg-hover);
|
||||
.boardCard:hover {
|
||||
transform: translateY(-2px);
|
||||
background: var(--theme-card-bg-hover);
|
||||
}
|
||||
.libraryCard__main {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
.boardCard__body {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
}
|
||||
.libraryCard__favorite {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 14px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--theme-favorite-border);
|
||||
background: var(--theme-favorite-bg);
|
||||
color: var(--theme-favorite-icon);
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.libraryCard__favorite--active {
|
||||
background: var(--theme-favorite-active-bg);
|
||||
border-color: var(--theme-favorite-active-border);
|
||||
}
|
||||
.libraryCard__favoriteIcon {
|
||||
opacity: 0.76;
|
||||
color: var(--theme-favorite-icon);
|
||||
}
|
||||
.libraryCard__favorite--active .libraryCard__favoriteIcon {
|
||||
opacity: 1;
|
||||
color: var(--theme-favorite-active-icon);
|
||||
}
|
||||
.libraryCard__thumbWrap {
|
||||
.boardCard__thumbWrap {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-surface-soft-2);
|
||||
background: var(--theme-thumb-fallback-bg);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 14px 14px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.libraryCard__thumb {
|
||||
.boardCard__thumb,
|
||||
.boardCard__thumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.boardCard__thumb {
|
||||
object-fit: cover;
|
||||
}
|
||||
.libraryCard__thumbFallback {
|
||||
font-size: 14px;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
.libraryCard__body {
|
||||
.boardCard__thumbPlaceholder {
|
||||
background: var(--theme-thumb-fallback-bg);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--theme-text-faint);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.libraryCard__title {
|
||||
.boardCard__head {
|
||||
min-width: 0;
|
||||
padding: 16px 18px 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard__titleRow,
|
||||
.boardCard__metaRow {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
.boardCard__titleRow {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.boardCard__metaRow {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.boardCard__title {
|
||||
min-width: 0;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
.libraryCard__meta {
|
||||
.boardCard__topic {
|
||||
min-width: 0;
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.libraryCard-move,
|
||||
.libraryCard-enter-active,
|
||||
.libraryCard-leave-active {
|
||||
transition: transform 280ms ease, opacity 220ms ease;
|
||||
.boardCard__author {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
opacity: 0.86;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.libraryCard-enter-from,
|
||||
.libraryCard-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.985);
|
||||
.boardCard__authorName {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.libraryCard-leave-active {
|
||||
position: absolute;
|
||||
width: calc(100% - 0px);
|
||||
pointer-events: none;
|
||||
.boardCard__avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.libraryEmpty {
|
||||
padding: 20px 0;
|
||||
color: var(--theme-text-muted);
|
||||
.boardCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.boardCard__date,
|
||||
.favoriteStat {
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.favoriteStat {
|
||||
font-weight: 800;
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.libraryGrid {
|
||||
.list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.libraryGrid {
|
||||
.list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.libraryGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.libraryGrid {
|
||||
grid-template-columns: 1fr;
|
||||
.list {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { editorPath, loginPath } from '../lib/paths'
|
||||
import { displayInitialFrom } from '../lib/display'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -35,7 +36,7 @@ function avatarSrcOf(tierList) {
|
||||
}
|
||||
|
||||
function avatarFallbackOf(tierList) {
|
||||
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||
return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
|
||||
}
|
||||
|
||||
function tierListThumbnailUrl(tierList) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { editorPath } from '../lib/paths'
|
||||
import { displayInitialFrom } from '../lib/display'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -30,7 +31,7 @@ function avatarSrcOf(tierList) {
|
||||
}
|
||||
|
||||
function avatarFallbackOf(tierList) {
|
||||
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||
return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
|
||||
}
|
||||
|
||||
function tierListThumbnailUrl(tierList) {
|
||||
|
||||
263
frontend/src/views/TemplatesView.vue
Normal file
263
frontend/src/views/TemplatesView.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import SvgIcon from '../components/SvgIcon.vue'
|
||||
import kidStarIcon from '../assets/icons/kid_star.svg'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { loginPath, topicPath } from '../lib/paths'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const templateRecords = ref([])
|
||||
const error = ref('')
|
||||
const loadingFavoriteId = ref('')
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||
const templates = computed(() => {
|
||||
const filtered = templateRecords.value
|
||||
.filter((item) => item.id !== 'freeform')
|
||||
.filter((item) => {
|
||||
if (!query.value) return true
|
||||
const haystack = `${item.name || ''} ${item.slug || ''}`.toLowerCase()
|
||||
return haystack.includes(query.value)
|
||||
})
|
||||
|
||||
return filtered.slice().sort((a, b) => {
|
||||
if (!!a.isFavorited !== !!b.isFavorited) return a.isFavorited ? -1 : 1
|
||||
const rankA = a.displayRank == null ? Number.MAX_SAFE_INTEGER : a.displayRank
|
||||
const rankB = b.displayRank == null ? Number.MAX_SAFE_INTEGER : b.displayRank
|
||||
if (rankA !== rankB) return rankA - rankB
|
||||
if (Number(a.createdAt || 0) !== Number(b.createdAt || 0)) {
|
||||
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
|
||||
}
|
||||
return (a.name || '').localeCompare(b.name || '', 'ko')
|
||||
})
|
||||
})
|
||||
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const data = await api.listTopics()
|
||||
templateRecords.value = data.topics || []
|
||||
} catch (e) {
|
||||
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadTemplates)
|
||||
watch(() => auth.user?.id, loadTemplates)
|
||||
|
||||
function openTopic(template) {
|
||||
router.push(topicPath(template?.slug || template?.id || ''))
|
||||
}
|
||||
|
||||
async function toggleFavorite(template, event) {
|
||||
event?.stopPropagation()
|
||||
if (!auth.user) {
|
||||
router.push(loginPath(route.fullPath || '/templates'))
|
||||
return
|
||||
}
|
||||
if (!template?.id || loadingFavoriteId.value === template.id) return
|
||||
|
||||
try {
|
||||
loadingFavoriteId.value = template.id
|
||||
const res = template.isFavorited ? await api.unfavoriteTopic(template.id) : await api.favoriteTopic(template.id)
|
||||
templateRecords.value = templateRecords.value.map((entry) => (entry.id === template.id ? { ...entry, ...(res.topic || {}) } : entry))
|
||||
} catch (e) {
|
||||
error.value = '즐겨찾기 변경에 실패했어요.'
|
||||
} finally {
|
||||
loadingFavoriteId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function templateThumbUrl(template) {
|
||||
return template.thumbnailSrc ? toApiUrl(template.thumbnailSrc) : ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Topic</div>
|
||||
<h1 class="pageHead__title">템플릿</h1>
|
||||
<p class="pageHead__desc">자주 쓰는 주제 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 주제 템플릿만 보고 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<TransitionGroup v-if="templates.length" name="libraryCard" tag="section" class="libraryGrid">
|
||||
<article v-for="template in templates" :key="template.id" class="libraryCard">
|
||||
<button
|
||||
class="libraryCard__favorite"
|
||||
type="button"
|
||||
:class="{ 'libraryCard__favorite--active': template.isFavorited }"
|
||||
:disabled="loadingFavoriteId === template.id"
|
||||
@click.stop="toggleFavorite(template, $event)"
|
||||
>
|
||||
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
|
||||
</button>
|
||||
<button class="libraryCard__main" type="button" @click="openTopic(template)">
|
||||
<div class="libraryCard__thumbWrap">
|
||||
<img v-if="templateThumbUrl(template)" class="libraryCard__thumb" :src="templateThumbUrl(template)" :alt="template.name" draggable="false" />
|
||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="libraryCard__body">
|
||||
<div class="libraryCard__title">{{ template.name }}</div>
|
||||
<div class="libraryCard__meta">{{ template.slug || template.id }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</TransitionGroup>
|
||||
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 주제 템플릿이 없어요.' : '표시할 주제 템플릿이 없어요.' }}</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.libraryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.error {
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--theme-danger-border);
|
||||
background: var(--theme-danger-bg);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.pageHead__searchState {
|
||||
margin-top: 8px;
|
||||
color: var(--theme-text-muted);
|
||||
}
|
||||
.libraryCard {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
padding: 14px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
color: var(--theme-text);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
transition: transform 0.16s ease, background 0.16s ease;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.libraryCard:hover {
|
||||
background: var(--theme-card-bg-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.libraryCard__main {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.libraryCard__favorite {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 14px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--theme-favorite-border);
|
||||
background: var(--theme-favorite-bg);
|
||||
color: var(--theme-favorite-icon);
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.libraryCard__favorite--active {
|
||||
background: var(--theme-favorite-active-bg);
|
||||
border-color: var(--theme-favorite-active-border);
|
||||
}
|
||||
.libraryCard__favoriteIcon {
|
||||
opacity: 0.76;
|
||||
color: var(--theme-favorite-icon);
|
||||
}
|
||||
.libraryCard__favorite--active .libraryCard__favoriteIcon {
|
||||
opacity: 1;
|
||||
color: var(--theme-favorite-active-icon);
|
||||
}
|
||||
.libraryCard__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-surface-soft-2);
|
||||
background: var(--theme-thumb-fallback-bg);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.libraryCard__thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.libraryCard__thumbFallback {
|
||||
font-size: 14px;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
.libraryCard__body {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.libraryCard__title {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 18px;
|
||||
}
|
||||
.libraryCard__meta {
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.libraryCard-move,
|
||||
.libraryCard-enter-active,
|
||||
.libraryCard-leave-active {
|
||||
transition: transform 280ms ease, opacity 220ms ease;
|
||||
}
|
||||
.libraryCard-enter-from,
|
||||
.libraryCard-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.985);
|
||||
}
|
||||
.libraryCard-leave-active {
|
||||
position: absolute;
|
||||
width: calc(100% - 0px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.libraryEmpty {
|
||||
padding: 20px 0;
|
||||
color: var(--theme-text-muted);
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.libraryGrid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.libraryGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.libraryGrid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</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">
|
||||
@@ -1756,16 +1778,6 @@ onUnmounted(() => {
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<button
|
||||
v-if="canRemoveEditorItem(id) && !isExporting"
|
||||
class="cellDeleteBtn"
|
||||
type="button"
|
||||
title="커스텀 이미지 제거"
|
||||
@pointerdown.stop
|
||||
@click.stop="deleteEditorItem(id)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1807,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">
|
||||
@@ -1852,16 +1872,6 @@ onUnmounted(() => {
|
||||
draggable="false"
|
||||
/>
|
||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||
<button
|
||||
v-if="canRemoveEditorItem(id)"
|
||||
class="poolItem__deleteBtn"
|
||||
type="button"
|
||||
title="커스텀 이미지 제거"
|
||||
@pointerdown.stop
|
||||
@click.stop="deleteEditorItem(id)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2189,7 +2199,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.viewerSidebar__section {
|
||||
margin-top: auto;
|
||||
margin-top: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
@@ -3210,7 +3220,6 @@ onUnmounted(() => {
|
||||
}
|
||||
.poolItem--selected {
|
||||
border-color: rgba(96, 165, 250, 0.58);
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.92), 0 0 0 6px rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
.poolItem .thumb {
|
||||
width: 100%;
|
||||
@@ -3296,7 +3305,7 @@ onUnmounted(() => {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.chosen {
|
||||
outline: 2px solid rgba(110, 231, 183, 0.5);
|
||||
border: 1px solid rgba(110, 231, 183, 0.5);
|
||||
border-radius: 14px;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { editorNewPath, editorPath, loginPath } from '../lib/paths'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { displayInitialFrom } from '../lib/display'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -39,7 +40,7 @@ function avatarSrcOf(tierList) {
|
||||
}
|
||||
|
||||
function avatarFallbackOf(tierList) {
|
||||
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||
return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
|
||||
}
|
||||
|
||||
function tierListThumbnailUrl(tierList) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { editorPath, followingFeedPath, loginPath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { displayInitialFrom } from '../lib/display'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -23,7 +24,7 @@ const brokenThumbnailIds = ref({})
|
||||
|
||||
const profileAvatarUrl = computed(() => (profile.value?.avatarSrc ? toApiUrl(profile.value.avatarSrc) : ''))
|
||||
const profileDisplayName = computed(() => profile.value?.nickname || profile.value?.accountName || '알 수 없음')
|
||||
const profileFallback = computed(() => (profile.value?.accountName || 'u').trim().charAt(0).toUpperCase() || '?')
|
||||
const profileFallback = computed(() => displayInitialFrom(profile.value?.nickname, profile.value?.accountName, '?'))
|
||||
const canFollow = computed(() => !!auth.user && !!profile.value && !profile.value.isSelf)
|
||||
|
||||
watch(error, (message) => {
|
||||
@@ -49,7 +50,7 @@ function avatarSrcOf(tierList) {
|
||||
}
|
||||
|
||||
function avatarFallbackOf(tierList) {
|
||||
return (tierList.authorAccountName || profile.value?.accountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||
return displayInitialFrom(tierList.authorName, tierList.authorAccountName || profile.value?.accountName, '?')
|
||||
}
|
||||
|
||||
function tierListThumbnailUrl(tierList) {
|
||||
|
||||
Reference in New Issue
Block a user