Compare commits

...

29 Commits

Author SHA1 Message Date
110242f8e9 로딩 문구와 제목 보정 2026-04-07 16:36:44 +09:00
4fbd4a2845 초기 로딩 스켈레톤 정리 2026-04-07 16:29:54 +09:00
76de4b940a 설정과 모바일 동선 정리 2026-04-07 16:19:01 +09:00
3163a671de 닉네임 기간 예시 추가 2026-04-07 15:50:55 +09:00
b6e382468c 설정 이메일 문구 정리 2026-04-07 15:39:59 +09:00
f9702a50a1 설정 안내 정리 2026-04-07 15:38:01 +09:00
a8019add16 설정 액션 정리 2026-04-07 15:23:38 +09:00
51170b2ff7 설정 제한 보정 2026-04-07 15:10:43 +09:00
923a9af83d 설정 화면 정리 2026-04-07 15:03:30 +09:00
6fdd780859 가이드 모달 정리 2026-04-07 14:52:39 +09:00
f273233c41 전역 단축키 확장 2026-04-07 14:46:52 +09:00
bc5a34bbb7 내 티어표 검색 연결 2026-04-07 14:43:05 +09:00
ede348be96 검색 동선 통일 2026-04-07 14:41:20 +09:00
a952d2a062 팔로우 피드 보기 전환 추가 2026-04-07 14:37:10 +09:00
d2273fa723 목록 보기 전환 정리 2026-04-07 14:33:13 +09:00
de304c98a7 즐겨찾기 페이지 정리 2026-04-07 14:26:13 +09:00
68481c3ebf 댓글 읽음과 즐겨찾기 정리 2026-04-07 14:24:16 +09:00
31e266e79e 댓글 관리 카드 정리 2026-04-07 14:19:05 +09:00
63dc8f871c 댓글 더보기와 컨트롤 정리 2026-04-07 14:09:45 +09:00
d9aa6a6922 댓글 썸네일 비율 고정 2026-04-07 14:06:51 +09:00
09b9036bbe 댓글 정렬과 뷰어 레일 정리 2026-04-07 14:00:29 +09:00
9f143d4a89 댓글 UI 톤 정리 2026-04-07 13:45:50 +09:00
d5575d3028 댓글 카드 디자인 개선 2026-04-07 13:36:39 +09:00
173f547d8b 답글 입력 UX 정리 2026-04-07 13:31:00 +09:00
db037c6163 댓글 알림 스키마 보정 2026-04-07 13:25:41 +09:00
6bac13006a 댓글 시스템 복구 2026-04-07 12:44:24 +09:00
2c0b5268fa 홈 피드와 템플릿 분리 2026-04-07 12:30:20 +09:00
8fc8872114 v1.0.104 기준 복원 2026-04-07 12:13:09 +09:00
9b265c070b admin: count actual item placements 2026-04-06 15:15:18 +09:00
35 changed files with 4260 additions and 665 deletions

13
.env.production Normal file
View File

@@ -0,0 +1,13 @@
MARIADB_ROOT_PASSWORD=wps!xldj180204
MARIADB_DATABASE=tier_db
MARIADB_USER=zenn
MARIADB_PASSWORD=wps!xldj180204
SESSION_SECRET=291fbf7d5d112786a84d8ff62dd2839ce343a820c47d88da746ec78e11d640b3
APP_ORIGIN=https://tmaker.sori.studio
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=zenn.sori.studio@gmail.com
SMTP_PASS=kcasoehxcspqdoxz
SMTP_FROM="Tier Maker <zenn.sori.studio@gmail.com>"
NICKNAME_CHANGE_INTERVAL_DAYS=14

View File

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

View File

@@ -90,6 +90,7 @@ function mapUserRow(row) {
id: row.id,
email: row.email,
nickname: row.nickname || '',
nicknameUpdatedAt: Number(row.nickname_updated_at || 0),
emailVerified: row.email_verified == null ? true : !!row.email_verified,
isAdmin: !!row.is_admin,
avatarSrc: row.avatar_src || '',
@@ -241,6 +242,63 @@ 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 || '',
parentCommentCreatedAt: Number(row.parent_comment_created_at || 0),
parentAuthorName: getUserDisplayName({
nickname: row.parent_author_nickname,
email: row.parent_author_email,
}),
parentAuthorAccountName: getUserAccountName({
email: row.parent_author_email,
}),
parentAuthorAvatarSrc: row.parent_author_avatar_src || '',
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 {
@@ -355,6 +413,7 @@ async function ensureSchema() {
id VARCHAR(64) PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
nickname VARCHAR(80) NOT NULL DEFAULT '',
nickname_updated_at BIGINT NOT NULL DEFAULT 0,
password_hash VARCHAR(255) NOT NULL,
email_verified TINYINT(1) NOT NULL DEFAULT 1,
is_admin TINYINT(1) NOT NULL DEFAULT 0,
@@ -437,6 +496,8 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS nickname_updated_at BIGINT NOT NULL DEFAULT 0 AFTER nickname`)
await query(`UPDATE users SET nickname_updated_at = created_at WHERE nickname_updated_at = 0`)
await query(`ALTER TABLE topics ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER thumbnail_src`)
await query(`ALTER TABLE topic_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`)
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`)
@@ -499,6 +560,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,
@@ -594,7 +700,7 @@ async function countUsers() {
async function findUserByEmail(email) {
const rows = await query(
'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1',
'SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1',
[email]
)
const row = rows[0]
@@ -608,7 +714,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
const rows = excludeUserId
? await query(
`
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
FROM users
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ?
LIMIT 1
@@ -617,7 +723,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
)
: await query(
`
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
FROM users
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?))
LIMIT 1
@@ -631,7 +737,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
async function findUserById(id) {
const rows = await query(
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE id = ? LIMIT 1',
'SELECT id, email, nickname, nickname_updated_at, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE id = ? LIMIT 1',
[id]
)
return mapUserRow(rows[0])
@@ -641,10 +747,10 @@ async function createUser({ id, email, nickname, passwordHash, emailVerified = t
const createdAt = now()
await query(
`
INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt]
[id, email, nickname || '', createdAt, passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt]
)
return findUserById(id)
}
@@ -773,11 +879,21 @@ async function consumePasswordResetToken(tokenId) {
await query('UPDATE password_reset_tokens SET consumed_at = ? WHERE id = ? AND consumed_at = 0', [now(), tokenId])
}
async function updateUserProfile({ id, nickname, avatarSrc }) {
async function updateUserProfile({ id, nickname, avatarSrc, touchNicknameUpdatedAt = false }) {
if (typeof avatarSrc === 'string') {
await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id])
await query(
`UPDATE users
SET nickname = ?, avatar_src = ?, nickname_updated_at = CASE WHEN ? = 1 THEN ? ELSE nickname_updated_at END
WHERE id = ?`,
[nickname || '', avatarSrc, touchNicknameUpdatedAt ? 1 : 0, touchNicknameUpdatedAt ? now() : 0, id]
)
} else {
await query('UPDATE users SET nickname = ? WHERE id = ?', [nickname || '', id])
await query(
`UPDATE users
SET nickname = ?, nickname_updated_at = CASE WHEN ? = 1 THEN ? ELSE nickname_updated_at END
WHERE id = ?`,
[nickname || '', touchNicknameUpdatedAt ? 1 : 0, touchNicknameUpdatedAt ? now() : 0, id]
)
}
return findUserById(id)
}
@@ -1725,7 +1841,7 @@ async function countTierListsUsingTopicItem(itemId) {
const rows = await query(
`
SELECT id, is_public, groups_json, pool_json
SELECT id, is_public, groups_json
FROM tierlists
`
)
@@ -1736,10 +1852,8 @@ async function countTierListsUsingTopicItem(itemId) {
rows.forEach((row) => {
const groups = parseJson(row.groups_json, [])
const pool = parseJson(row.pool_json, [])
const inGroups = groups.some((group) => (group?.itemIds || []).includes(itemId))
const inPool = pool.some((item) => item?.id === itemId)
if (!inGroups && !inPool) return
if (!inGroups) return
totalCount += 1
if (row.is_public) publicCount += 1
else privateCount += 1
@@ -1819,7 +1933,7 @@ async function findCustomItemById(id) {
async function getCustomItemUsageMeta() {
const rows = await query(
`
SELECT t.topic_id, tp.name AS topic_name, t.groups_json, t.pool_json
SELECT t.topic_id, tp.name AS topic_name, t.groups_json
FROM tierlists t
LEFT JOIN topics tp ON tp.id = t.topic_id
`
@@ -1829,7 +1943,6 @@ async function getCustomItemUsageMeta() {
rows.forEach((row) => {
const groups = parseJson(row.groups_json, [])
const pool = parseJson(row.pool_json, [])
const seenItemIds = new Set()
groups.forEach((group) => {
@@ -1839,13 +1952,6 @@ async function getCustomItemUsageMeta() {
})
})
pool.forEach((item) => {
if (item?.id) {
usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1)
seenItemIds.add(item.id)
}
})
if (!row.topic_id) return
seenItemIds.forEach((itemId) => {
@@ -2013,7 +2119,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
createdAt: Number(row.created_at),
ownerName: row.topic_name || row.topic_id,
ownerEmail: '',
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
usageCount: usageMeta.usageMap.get(row.id) || 0,
linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
assetKind: resolveLibraryAssetKind(row.src),
sourceType: 'template',
@@ -2622,6 +2728,200 @@ 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,
parent.created_at AS parent_comment_created_at,
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,
parent_author.nickname AS parent_author_nickname,
parent_author.email AS parent_author_email,
parent_author.avatar_src AS parent_author_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
LEFT JOIN users parent_author ON parent_author.id = parent.author_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) => {
@@ -3285,6 +3585,14 @@ module.exports = {
updateTierListFeaturedStatus,
favoriteTopic,
unfavoriteTopic,
listTierListComments,
findTierListCommentById,
createTierListComment,
deleteTierListComment,
createCommentNotificationsForComment,
listCommentNotifications,
countUnreadCommentNotifications,
markCommentNotificationsRead,
followUser,
unfollowUser,
favoriteTierList,

View File

@@ -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]+)*$/),

View File

@@ -31,6 +31,35 @@ const router = express.Router()
const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000
function resolveNicknameChangeIntervalMs() {
const rawHours = String(process.env.NICKNAME_CHANGE_INTERVAL_HOURS || '').trim()
if (rawHours) {
const parsed = Number(rawHours)
if (Number.isFinite(parsed) && parsed >= 0) return parsed * 60 * 60 * 1000
}
const rawDays = String(process.env.NICKNAME_CHANGE_INTERVAL_DAYS || '').trim()
if (rawDays) {
const parsed = Number(rawDays)
if (Number.isFinite(parsed) && parsed >= 0) return parsed * 24 * 60 * 60 * 1000
}
return 0
}
function formatNicknameChangeIntervalLabel(intervalMs) {
if (!intervalMs || intervalMs <= 0) return '제한 없음'
const oneDayMs = 24 * 60 * 60 * 1000
const wholeDays = intervalMs / oneDayMs
if (Number.isInteger(wholeDays) && wholeDays >= 7 && wholeDays % 7 === 0) {
return `${wholeDays / 7}`
}
if (Number.isInteger(wholeDays)) {
return `${wholeDays}`
}
return `${Math.ceil(wholeDays)}`
}
const signupSchema = z.object({
email: z.string().email(),
nickname: z.string().trim().min(2).max(40),
@@ -60,7 +89,7 @@ const changePasswordSchema = z.object({
})
const profileSchema = z.object({
nickname: z.string().trim().min(1).max(40),
nickname: z.string().trim().min(2).max(40),
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
})
@@ -82,11 +111,17 @@ async function serializeUser(user) {
if (!user) return null
const primaryAdmin = await findPrimaryAdminUser()
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
const nicknameChangeIntervalMs = resolveNicknameChangeIntervalMs()
const nicknameChangeAvailableAt = nicknameChangeIntervalMs > 0 ? (user.nicknameUpdatedAt || 0) + nicknameChangeIntervalMs : 0
return {
id: user.id,
email: user.email,
nickname: user.nickname || '',
nicknameUpdatedAt: user.nicknameUpdatedAt || 0,
nicknameChangeAvailableAt,
nicknameChangeIntervalMs,
nicknameChangeIntervalLabel: formatNicknameChangeIntervalLabel(nicknameChangeIntervalMs),
isAdmin: !!user.isAdmin,
isPrimaryAdmin,
isOperator: !!user.isAdmin && !isPrimaryAdmin,
@@ -358,8 +393,21 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
const user = await findUserById(req.session.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
if (isReservedNickname(parsed.data.nickname)) return res.status(400).json({ error: 'nickname_reserved' })
const nicknameExists = await findUserByNickname(parsed.data.nickname, user.id)
const normalizedNickname = parsed.data.nickname.trim()
const nicknameChanged = normalizedNickname !== (user.nickname || '').trim()
const nicknameChangeIntervalMs = resolveNicknameChangeIntervalMs()
if (isReservedNickname(normalizedNickname)) return res.status(400).json({ error: 'nickname_reserved' })
if (nicknameChanged && nicknameChangeIntervalMs > 0 && user.nicknameUpdatedAt && Date.now() < user.nicknameUpdatedAt + nicknameChangeIntervalMs) {
return res.status(429).json({
error: 'nickname_change_locked',
nicknameChangeAvailableAt: user.nicknameUpdatedAt + nicknameChangeIntervalMs,
nicknameChangeIntervalMs,
nicknameChangeIntervalLabel: formatNicknameChangeIntervalLabel(nicknameChangeIntervalMs),
})
}
const nicknameExists = await findUserByNickname(normalizedNickname, user.id)
if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' })
const optimized = req.file
@@ -377,8 +425,9 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || ''
const updated = await updateUserProfile({
id: user.id,
nickname: parsed.data.nickname,
nickname: normalizedNickname,
avatarSrc: nextAvatarSrc,
touchNicknameUpdatedAt: nicknameChanged,
})
res.json({ user: await serializeUser(updated) })

View 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

View File

@@ -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' })

View File

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

View File

@@ -13,6 +13,7 @@
- API 호출은 `frontend/src/lib/api.js` 또는 런타임 유틸을 통해 통합한다.
- 정적 파일 URL 조합은 `toApiUrl()`로 처리한다.
- 화면 상태는 `ref`, `computed`, `onMounted` 중심의 단순한 Composition API 패턴을 유지한다.
- 설정/계정처럼 자주 바꾸지 않는 정보는 상시 입력 폼보다 `현재 상태 요약 + 필요 시 모달 편집` 흐름을 우선한다.
## 백엔드
- 라우트 검증은 `zod`로 처리한다.

View File

@@ -1,5 +1,105 @@
# 의사결정 이력
## 2026-04-07 v1.1.26
- `주제 불러오는 중...` 같은 임시 문구는 스켈레톤보다 더 거슬리게 보일 수 있으므로, 짧은 로딩 구간에서는 텍스트 fallback 대신 빈 자리 + 스켈레톤으로 처리하는 편이 낫다고 정리했다.
- 저장용 랜덤 제목이나 내부 `tierListId`는 시스템 식별자 역할일 뿐 화면 표시용 카피가 아니므로, 사용자가 보는 제목 fallback 으로 직접 노출하지 않는 쪽이 맞다고 정리했다.
- 같은 컴포넌트 안에서 라우트 파라미터만 바뀌는 화면은 일반 watch 보다 더 이른 시점에 로딩 상태를 세우는 편이 깜빡임 완화에 유리하다고 판단했다.
## 2026-04-07 v1.1.25
- 이번 로딩 문제는 “매우 짧은 대기시간 + 미완성 실제 화면 노출”이 핵심이므로, 풀스크린 로딩 화면을 계속 번쩍 띄우는 것보다 `초기 인증 게이트 + 페이지 스켈레톤` 조합이 더 맞다고 정리했다.
- 로그인 상태가 아직 hydrate 되지 않았을 때는 비로그인 UI를 잠깐 먼저 보여주지 않고, 최소 시간 있는 부트 게이트로 가려 두는 편이 완성도가 높다고 판단했다.
- 티어표 화면도 실제 데이터가 오기 전 내부 ID나 빈 보드가 먼저 보이면 사이트가 덜 다듬어진 인상을 주므로, “미완성 진짜 화면”보다 “완성형 뼈대 화면”을 먼저 보여주는 쪽으로 정리했다.
- 에디터 내부에서 인증 복원을 매번 다시 치는 구조는 최초 진입 외에는 체감 지연만 늘릴 수 있으므로, 앱 셸에서 이미 hydrate 된 경우에는 중복 `refresh()`를 피하는 편이 맞다고 정리했다.
## 2026-04-07 v1.1.18
- 설정 화면은 자주 바꾸지 않는 계정 정보를 상시 입력 폼으로 펼쳐두기보다, 현재 상태를 먼저 보여주고 필요할 때만 모달로 수정하는 편이 더 차분하고 완성도 높게 보인다고 정리했다.
- 닉네임은 공개 작성자 이름에 직접 반영되는 정보라 악용 가능성을 줄이기 위해 2주 제한을 두는 편이 맞다고 판단했다. 초기 가입 시점의 닉네임도 같은 규칙에 포함되도록 가입 시각을 기본 기준선으로 삼는다.
- 다만 이 제한은 테스트와 운영 상황에 따라 조절할 수 있어야 하므로, 기간 자체는 코드 고정보다 환경변수로 바꾸는 편이 맞다고 정리했다. `0`으로 꺼서 QA하거나, `1일` 같은 짧은 값으로 운영 실험을 할 수 있어야 한다.
- 프로필 이미지는 자주 다루지 않는 항목이고 변경 직후 결과를 바로 확인할 수 있으므로, 별도 저장 버튼보다 자동 저장이 더 자연스럽다고 정리했다. 대신 닉네임/비밀번호/로그아웃처럼 명시적 행위가 필요한 액션은 작은 아이콘 버튼으로 분리한다.
- 닉네임 제한은 설정 본문에 계속 설명을 남기기보다, 버튼이 나타나는 조건과 모달 내부 안내로만 전달하는 편이 더 깔끔하다고 정리했다.
- 이메일은 현재 시스템에서 개인 설정 수정 흐름보다 로그인 식별자 의미가 더 강하고, 인증/중복/세션 전환을 함께 다뤄야 하므로 일단 읽기 전용으로 분리해 두는 편이 맞다고 정리했다.
- 환경변수 이름만 설명하는 것보다 실제 배포 파일에 샘플 값을 한 줄 남겨두는 편이 운영자 입장에서 훨씬 덜 헷갈리므로, `.env.production`에 20일 예시를 직접 두는 편이 낫다고 정리했다.
- 모바일에서는 왼쪽 레일까지 상단에 고정해 둘 필요가 없고, 콘텐츠 영역을 넓히는 편이 더 중요하다고 판단했다. 그래서 `860px` 이하에서는 좌우 레일을 모두 오버레이로 띄우고, 목록 보기 전환 버튼도 모바일에서는 숨기는 쪽이 더 단순하다고 정리했다.
- 편집 화면 상단의 템플릿 제목은 같은 화면 안 스크롤보다 “이 주제의 다른 공개 티어표와 원본 템플릿으로 돌아가는 입구” 역할이 더 중요하다고 판단했다. 그래서 제목 클릭을 주제 허브 이동으로 바꾸되, 미저장 변경 보호는 기존 확인 모달을 재사용하는 편이 맞다고 정리했다.
## 2026-04-07 v1.1.17
- 가이드 모달은 같은 기능의 이동 수단을 중복으로 두기보다, 화살표와 점 네비게이션만 유지하는 편이 더 깔끔하다고 정리했다.
- 설명 길이 때문에 페이지 전환마다 미디어 영역이 출렁이면 완성도가 떨어져 보이므로, 설명 블록에 최소 높이를 두는 방식으로 페이지 간 높이를 최대한 통일하기로 했다.
## 2026-04-07 v1.1.16
- 전역 단축키는 영문 키만 처리하지 말고 두벌식 한글 자판 입력도 같은 의미로 받아들이는 편이 실제 사용성에 더 맞다고 정리했다.
- `S/ㄴ`은 화면 문맥에 따라 “편집기에서는 아이템 검색, 일반 목록에서는 공통 검색창 포커스”로 나누는 것이 기존 습관과 새 검색 동선을 모두 살리는 절충안이라고 판단했다.
## 2026-04-07 v1.1.15
- `나의 티어표`도 주요 목록 화면 중 하나이므로 왼쪽 공통 검색창 범위에서 빼는 것보다 포함하는 편이 더 일관적이라고 정리했다.
## 2026-04-07 v1.1.14
- 티어표 목록 화면마다 별도 검색창을 두기보다, 왼쪽 공통 검색창이 “현재 보고 있는 화면 범위만 검색한다”는 규칙으로 통일하는 편이 더 단순하고 예측 가능하다고 정리했다.
- 즐겨찾기 목록에서는 해제 버튼을 즉시 노출하기보다, 해당 티어표를 방문해 한 번 더 확인한 뒤 우측 사이드 CTA로 해제하는 흐름이 실수 방지 측면에서 낫다고 판단했다.
## 2026-04-07 v1.1.13
- 팔로우 피드도 본질적으로는 “공개 티어표 목록 화면”이므로, 홈/템플릿/즐겨찾기와 같은 `viewToggle` 문법을 공유하는 편이 맞다고 정리했다.
## 2026-04-07 v1.1.12
- `viewToggle`은 특정 주제 화면에만 남겨둘 기능이 아니라, 카드형/리스트형을 공통 문법으로 쓰는 주요 목록 화면 전반에서 일관되게 제공하는 편이 맞다고 정리했다.
- 현재 주요 목록 화면은 데이터 규모가 아직 크지 않아 전부 한 번에 조회하는 구조를 유지하되, 이후 공개 티어표와 즐겨찾기 수가 늘어나면 페이지네이션이나 점진 로딩을 후속 과제로 검토하기로 했다.
- 티어표 즐겨찾기는 “다른 사람 작품 보관”만이 아니라 “내가 자주 참고하는 내 작업 고정” 용도로도 쓸 수 있으므로, 작성자 본인 티어표도 프런트에서 막지 않는 방향이 더 자연스럽다고 판단했다.
## 2026-04-07 v1.1.11
- 즐겨찾기 페이지는 단순 모아보기만으로 끝나면 관리 화면 역할이 약하므로, 카드 안에서 바로 해제할 수 있게 두는 편이 맞다고 정리했다. 별도 상세 화면으로 들어가서 해제하는 흐름은 불필요하게 길다.
## 2026-04-07 v1.1.10
- 댓글 관리함은 기본적으로 “안 읽은 것부터 처리하는 공간”이므로, 첫 진입 기본값을 전체 목록보다 `안 읽은 댓글만 보기 활성화`로 두는 편이 맞다고 정리했다.
- 댓글 관리 카드의 상단 배지는 정보 라벨보다 행동 버튼이 더 유용하다고 판단했다. `댓글/답글` 구분은 제목과 본문 구조만으로 충분히 이해되므로, 같은 자리는 `읽음 처리`처럼 즉시 처리 가능한 액션에 쓰는 쪽이 효율적이다.
- 티어표 즐겨찾기는 이미 API와 목록 화면이 있으므로 새 기능을 늘리기보다, 보기 화면 우측 레일에 단독 CTA로 명확히 드러내는 편이 더 중요하다고 정리했다.
## 2026-04-07 v1.1.9
- 댓글 관리함은 단순 알림 문구보다 `어느 티어표에서 어떤 루트 댓글이 있었고 그 아래 어떤 새 댓글/답글이 달렸는지`를 카드 한 장 안에서 읽히게 하는 편이 더 중요하다고 정리했다. 그래서 좌측에는 대상 티어표 정보, 우측에는 댓글 흐름 자체를 배치하는 2열 구조를 기본으로 삼는다.
- `commentInboxCard__lead`처럼 제목을 다시 설명하는 보조 문구는 상태 전달에 비해 공간만 차지하므로 제거하고, 대신 실제 댓글 작성자/시간/본문 정보를 바로 보여주는 방향이 낫다고 판단했다.
## 2026-04-07 v1.1.8
- 댓글은 처음부터 전부 렌더링하지 않고 일부만 보여준 뒤 `더 보기`로 확장하는 방향을 채택했다. 이 프로젝트는 본문이 긴 티어표 프리뷰와 함께 댓글을 보여주므로, 기본 노출 개수를 제한하는 편이 가독성과 레일 안정성에 모두 유리하다.
- 댓글 관리 화면 컨트롤은 별도 체크박스 문법을 만들지 않고, 설정/에디터에서 이미 쓰는 토글 스위치와 저장 CTA 톤을 재사용하는 것이 일관성에 맞다고 판단했다.
## 2026-04-07 v1.1.7
- 카드 내부 그리드에서 썸네일 비율을 맞출 때는 `aspect-ratio`만 두지 않고, 부모 그리드의 `stretch` 영향을 함께 차단해야 한다고 정리했다. 댓글 관리 카드 썸네일은 16:9 규칙을 CSS 정렬까지 포함해 고정한다.
## 2026-04-07 v1.1.6
- 댓글 영역은 과하게 화려한 새로운 카드보다, 우측 뷰어 카드와 통일되는 단정한 서비스 톤이 더 적합하다고 판단했다. 같은 화면 안에서 카드 문법이 지나치게 갈라지면 오히려 UI 완성도가 떨어지므로 댓글 카드도 공통 서비스 톤에 맞춘다.
- 댓글 정렬은 `루트 최신순 / 답글 오래된순`으로 고정한다. 최신 댓글을 먼저 보는 편이 전체 참여 흐름엔 유리하고, 답글은 작성 순서가 유지되어야 문맥 이해가 쉽다.
- 뷰어 우측 레일은 본문 길이와 독립적으로 위에서부터 쌓이도록 유지한다. 댓글처럼 본문이 길어질 수 있는 요소가 생겨도, 공유/복사 같은 보조 액션은 스폰서 카드 아래에서 바로 보여야 한다.
## 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만 사용 횟수로 집계한다.
- 템플릿 아이템도 같은 이미지가 몇 개 템플릿에 연결됐는지와 실제 저장 티어표에서 사용됐는지는 별도 개념이므로, `usageCount`는 실제 배치 기준으로 바꾸고 템플릿 연결 정보는 별도 `linkedTemplates`로 유지한다.
## 2026-04-06 v1.0.103
- 기존 Git 태그와 커밋 메시지를 직접 재작성하면 원격 히스토리와 배포 참조가 꼬일 수 있으므로, 실제 태그는 그대로 두고 문서 기준 버전만 보정하기로 정리했다.
- 다음 작업자와 AI에게 전달할 기준은 다음과 같다. `v0.1`은 개발 시작, `v0.2`는 Figma 기반 리디자인, `v0.3`은 이미지 최적화와 운영 기능 구현, `v0.4`는 게임 티어 중심 구조에서 범용 티어 메이커로 전환한 단계이며, 이 흐름을 공개 기준 `v1.0`으로 승격해 이어간다.

View File

@@ -2,18 +2,28 @@
## `/`
- 화면 파일: `frontend/src/views/HomeView.vue`
- 역할: 데스크톱 기본 4열 주제 카드 라이브러리 대시보드, 상단 메인 썸네일과 `주제명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 주제 카드 클릭 이동, `직접 티어표 만들기` 진입
- 연동 API: `GET /api/topics`
- 역할: 공개 티어표 홈 피드, 상단 `추천 티어표`와 아래 `최신 공개 티어표` 목록을 같은 카드 문법으로 표시, 검색어(`q`)가 있으면 공개 티어표 제목/작성자 기준으로 필터링, 상단 공통 `viewToggle`로 카드형/리스트형 전환, 카드 클릭 시 해당 티어표 화면으로 이동
- 연동 API: `GET /api/tierlists/public?q=...`
## `/templates`
- 화면 파일: `frontend/src/views/TemplatesView.vue`
- 역할: 공개 템플릿 전용 목록, 관리자 수동 순서와 즐겨찾기 여부를 반영한 주제 템플릿 카드 목록 표시, 템플릿 즐겨찾기 토글, 왼쪽 공통 검색창의 검색어(`q`)가 있으면 템플릿 이름/slug 기준으로 즉시 필터링, 상단 공통 `viewToggle`로 카드형/리스트형 전환
- 연동 API: `GET /api/topics`, `POST /api/topics/:topicId/favorite`, `DELETE /api/topics/:topicId/favorite`
## `/topics/:topicId`
- 화면 파일: `frontend/src/views/TopicHubView.vue`
- 역할: 선택한 주제 slug 기준 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
- 역할: 선택한 주제 slug 기준 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 왼쪽 공통 검색창으로 해당 주제의 공개 티어표만 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입하며, 주제명은 데이터 준비 전 `주제 불러오는 중...` 같은 텍스트 대신 제목 영역 스켈레톤으로 대기
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
## `/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 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 상단 템플릿 제목 클릭 시 해당 주제 허브로 이동, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 읽기 전용 상태의 즐겨찾기 단독 CTA, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, 데이터가 준비되기 전에는 편집 모드/프리뷰 모드 모두 전용 스켈레톤 레이아웃을 먼저 보여주고, 저장용 랜덤 제목이나 내부 ID는 화면 표시용 제목으로 직접 노출하지 않으며, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰와 하단 댓글 카드를 렌더링하며, 우측 뷰어 카드(`공유 티어표 보기`)는 스폰서 카드 바로 아래에서 유지하고 즐겨찾기 CTA도 함께 노출
- 연동 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`
@@ -22,17 +32,17 @@
## `/me`
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
- 역할: 내 티어표 목록 조회, 4열 라이브러리 카드형 썸네일 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
- 역할: 내 티어표 목록 조회, 왼쪽 공통 검색창으로 내 저장 티어표 범위만 검색, 상단 공통 `viewToggle`로 카드형/리스트형 전환, 편집 화면으로 이동, 작성자 본인 티어표 삭제
- 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id`
## `/favorites`
- 화면 파일: `frontend/src/views/FavoriteTierListsView.vue`
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 라이브러리 카드형 표시, 편집 화면 이동, 즐겨찾기 상태 확인
- 역할: 즐겨찾기한 티어표 목록 조회, 왼쪽 공통 검색창으로 내 즐겨찾기 범위만 검색, 정렬 셀렉트 유지, 상단 공통 `viewToggle`로 카드형/리스트형 전환, 편집 화면 이동
- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite`
## `/following`
- 화면 파일: `frontend/src/views/FollowingFeedView.vue`
- 역할: 팔로우한 작성자의 공개 티어표를 최신 업데이트순 카드 목록으로 모아보기, 제목/주제/작성자 검색, 티어표 상세 이동, 작성자 프로필 이동
- 역할: 팔로우한 작성자의 공개 티어표를 최신 업데이트순 카드 목록으로 모아보기, 상단 공통 `viewToggle`로 카드형/리스트형 전환, 왼쪽 공통 검색창으로 팔로우 피드 범위만 검색, 티어표 상세 이동, 작성자 프로필 이동
- 연동 API: `GET /api/users/following-feed`
## `/users/:userId`
@@ -48,21 +58,23 @@
## `/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`
- 역할: 넓은 화면에서는 왼쪽 프로필 정보 카드와 오른쪽 비밀번호 변경 카드로 나뉘는 설정 화면, 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 중복/예약어 닉네임 오류 안내, 현재 비밀번호 확인 기반 비밀번호 변경, 설정 화면 로그아웃 처리
- 역할: `settingsThemePanel` 계열 톤의 요약 카드 중심 설정 화면, 아바타 원형 버튼 클릭 기반 프로필 이미지 선택/삭제 즉시 저장, 닉네임 변경 가능 시점에만 노출되는 아이콘 버튼 기반 닉네임 변경 모달, 비밀번호 변경 아이콘 버튼, 로그인 계정 이메일의 읽기 전용 표시, 로그아웃 아이콘 버튼 처리
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`, `POST /api/auth/password`
## 공통 레이아웃
- 앱 셸 파일: `frontend/src/App.vue`
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링, 관리자/에디터 화면이 Teleport로 사용하는 `#local-right-rail-root` 대상 DOM을 상시 유지해 라우트 전환 중 우측 레일 언마운트 순서를 안정화
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `홈 / 템플릿 / 댓글 관리` 네비게이션과 화면별 검색 placeholder 전환, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 댓글 알림 unread dot, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링, 앱 최초 진입 시 `auth.refresh()`와 최소 140ms를 묶은 `bootGate` 초기 스켈레톤 표시, 관리자/에디터 화면이 Teleport로 사용하는 `#local-right-rail-root` 대상 DOM을 상시 유지해 라우트 전환 중 우측 레일 언마운트 순서를 안정화, `S/ㄴ`, `G/ㅎ`, `L/ㅣ`, `A/ㅁ` 같은 전역 단축키 처리, 설정 가이드 모달 단계 이동/높이 안정화
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 모바일(`860px` 이하)에서는 좌우 패널 모두 오버레이로 뜨며, 중앙 헤더 오른쪽 버튼으로 각각 열고 닫는다. 중앙 헤더 브랜드 `Tier Maker`는 홈(`/`)으로 이동하는 터치 타겟으로 유지한다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
## 백엔드 진입점
- 서버 엔트리: `backend/index.js`
- 데이터 초기화: `backend/src/db.js`
- 세부: 댓글/알림 관련 테이블(`tierlist_comments`, `comment_notifications`)은 여기서 생성되고, 기존 DB에 누락된 컬럼이 있으면 서버 시작 시 자동 보강한다.
- 운영 환경 변수 예시: 프로젝트 루트 [`.env.production`](/Users/bicute/Desktop/zenn.dev/tmaker/.env.production) 에 `NICKNAME_CHANGE_INTERVAL_DAYS=20` 를 두면 닉네임 재변경 대기 기간을 20일로 맞출 수 있다.
- 로컬 DB 실행 설정: `docker-compose.yml`
- 로컬 MariaDB 가이드: `docs/local-mariadb.md`
- 인증 라우트: `backend/src/routes/auth.js`

View File

@@ -12,6 +12,8 @@
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, `preview=1` 모드에서도 같은 셸을 유지한 채 중앙 본문만 완성본 프리뷰로 렌더링한다.
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
- 모바일(`860px` 이하)에서는 좌측 패널도 고정 열을 차지하지 않고, 우측 패널과 같은 오버레이 방식으로 띄운다.
- 앱 최초 진입 시에는 인증 상태 복원과 최소 140ms 대기 시간을 함께 묶은 `bootGate` 초기 게이트를 먼저 보여주고, 그 뒤에만 실제 앱 셸을 렌더링한다.
- 이 3단 셸 구조는 홈, 게임 허브, 에디터, 관리자 등 일반 페이지 전반의 공통 뼈대로 유지하고, 페이지별 차이는 중앙/우측에 어떤 콘텐츠를 넣는지만 달라지도록 관리한다.
- 비로그인 상태의 로그인 유도는 좌측 하단 버튼으로만 노출하고, 좌측 상단 사용자 카드 영역에는 별도 게스트 안내 카드를 렌더링하지 않는다.
- 공통 셸의 좌측 내비, 우측 패널, 빠른 점프 버튼은 간단한 선형 SVG 아이콘과 두꺼운 카드형 버튼 문법을 공유한다.
@@ -33,17 +35,58 @@
- 상단 토글 버튼은 항상 고정되어 있고, 패널을 축소하면 텍스트를 숨기고 아이콘 중심 레일로 전환한다.
- `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`: 추천 제외 최신 공개 티어표
- 홈, 템플릿, 나의 티어표, 즐겨찾기, 팔로우 피드 화면은 공통 `viewToggle``그리드 / 리스트` 보기를 전환하며, 상태는 현재 라우트의 `?view=list` 쿼리로 반영한다.
- 단, 모바일 브레이크포인트(`860px` 이하)에서는 `viewToggle`을 노출하지 않는다.
- 전역 단축키
- `S/ㄴ`: 검색 포커스. 편집 화면에서는 아이템 검색창, 그 외 화면에서는 왼쪽 공통 검색창
- `G/ㅎ`: 그리드 보기
- `L/ㅣ`: 리스트 보기
- `A/ㅁ`: 관리자 계정일 때 관리자 화면으로 이동
- 설정의 가이드 모달은 좌우 화살표, 점 네비게이션, 좌측 단계 목록으로만 이동하고, 설명 영역은 최소 4줄 높이를 유지해 페이지별 높이 차이를 줄인다.
- 설정(`/profile`) 화면은 상시 입력 폼보다 `현재 상태 요약 카드 + 필요 시 모달 편집` 흐름을 기본으로 한다. 닉네임과 비밀번호는 작은 액션 버튼으로만 모달을 열어 변경하고, 이메일은 현재 로그인 계정 정보로 읽기 전용 표시를 유지한다. 프로필 이미지는 아바타 원형 버튼 자체를 눌러 변경하며, 선택/삭제 시 즉시 자동 저장한다.
- 이메일은 로그인 계정 식별자 역할을 하므로 현재 개인 설정 화면에서는 변경 기능을 제공하지 않는다.
- 닉네임 카드 본문에는 제한 설명을 상시 노출하지 않고, 변경 가능한 시점에만 아이콘 버튼을 보여준다. 제한 안내는 닉네임 변경 모달과 가이드 문구에서만 전달한다.
- 닉네임 변경 제한 기간은 기본 14일이지만, 서버 환경변수 `NICKNAME_CHANGE_INTERVAL_MS` 또는 `NICKNAME_CHANGE_INTERVAL_DAYS`로 조절할 수 있다. `0`이면 제한을 끈다. 인증 응답에는 `nicknameUpdatedAt`, `nicknameChangeAvailableAt`, `nicknameChangeIntervalMs`, `nicknameChangeIntervalLabel`를 함께 포함하고, 프로필 저장 API는 제한 기간 안의 닉네임 변경 요청에 `nickname_change_locked` 오류를 반환한다.
- 운영 환경 예시로는 `.env.production``NICKNAME_CHANGE_INTERVAL_DAYS=20`처럼 정수 일수를 넣어 주기를 바꾼다.
- 왼쪽 공통 검색창은 현재 화면 범위만 검색한다.
- 홈: 전체 공개 티어표
- 템플릿: 공개 템플릿
- 나의 티어표: 내 저장 티어표
- 특정 주제 화면: 해당 주제의 공개 티어표
- 팔로우 피드: 팔로우한 작성자의 공개 티어표
- 즐겨찾기: 내가 즐겨찾기한 티어표
- 위 네 화면의 목록 데이터는 현재 페이지네이션이나 무한 스크롤 없이 조회 결과 전체를 한 번에 렌더링한다.
- 특정 주제 화면(`TopicHubView`)의 헤더는 데이터 준비 전 문자열 fallback 대신 제목/설명 스켈레톤만 보여준다.
- 저장된 티어표에는 댓글 스레드가 붙을 수 있다. 작성자 본인 편집 화면에서는 `작업 팁` 아래, 작성자가 아닌 사용자의 보기 전용 화면에서는 `preview` 보드 아래에서 같은 댓글 카드를 사용한다.
- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다.
- 댓글 관리(`/comments`)는 기본적으로 `안 읽은 댓글만 보기`를 켠 상태로 시작한다.
- 댓글 정렬은 루트 댓글 최신순, 각 루트 내부의 답글은 오래된순을 기본 규칙으로 유지한다.
- 댓글 표시 밀도 제어를 위해 기본 노출 개수는 루트 댓글 10개, 각 루트의 답글 3개로 제한하고 `더 보기` 버튼으로 추가 노출한다.
- 댓글 관리 카드(`/comments`)는 좌측 `16:9 썸네일 + 티어표 제목 + 템플릿 이름`, 우측 `알림 제목 + 루트 댓글 정보 + 새 댓글/답글 정보`의 2열 구조를 사용한다.
- 댓글 관리 카드의 상단 우측 배지는 상태 라벨이 아니라 개별 `읽음 처리` 액션으로 사용한다.
- 티어표 즐겨찾기 API(`POST/DELETE /api/tierlists/:id/favorite`)는 이미 존재하며, 보기 화면 우측 레일에는 이를 직접 호출하는 단독 CTA를 노출한다.
- 티어표 즐겨찾기는 작성자 본인 저장 티어표에도 사용할 수 있다.
- 우측 패널
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
- 공통 토글 버튼은 패널이 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 각각 아이콘만 표시하는 방식으로 동작한다.
- 오른쪽 패널 토글은 열기/닫기 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘으로 통일한다.
- 모바일에서는 중앙 `workspaceHead` 오른쪽에 좌/우 패널 버튼을 함께 두고, 브랜드 타이틀을 터치하면 홈(`/`)으로 이동한다.
- 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
- 하단 액션은 화면 바닥에 바로 붙지 않도록 푸터 내부에 추가 하단 여백을 둔다.
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
@@ -52,7 +95,10 @@
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
- 편집 모드와 `preview=1` 뷰어 모드 모두 제목/보드/우측 패널 데이터가 준비되기 전까지는 실제 화면 대신 전용 스켈레톤 레이아웃을 먼저 보여준다.
- 저장용 랜덤 제목은 내부 저장/도배 방지용으로만 쓰고, 화면 표시용 제목 fallback 에서는 내부 `tierListId`나 랜덤 문자열을 직접 노출하지 않는다.
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. 템플릿 등록/업데이트 요청 버튼은 저장된 티어표가 있을 때만 노출하며, 제목이 비어 있는 상태에서 저장하면 랜덤 고유 제목을 먼저 부여해 저장본을 만든다.
- 상단 템플릿 제목은 해당 주제 허브로 이동하는 액션으로 사용하며, 미저장 변경이 있으면 이동 전에 확인 모달을 띄운다.
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
- 관리자 화면
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
@@ -65,6 +111,10 @@
- `id`: string
- `email`: string
- `nickname`: string
- `nicknameUpdatedAt`: number
- `nicknameChangeAvailableAt`: number
- `nicknameChangeIntervalMs`: number
- `nicknameChangeIntervalLabel`: string
- `passwordHash`: string
- `emailVerified`: boolean
- `isAdmin`: boolean
@@ -139,6 +189,27 @@
- `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에 예전 형태 테이블이 남아 있어도 서버 시작 시 스키마 보정으로 누락 컬럼을 자동 추가한다.
- 댓글 관리 카드 구성을 위해 조회 응답에는 `parentCommentContent`, `parentCommentCreatedAt`, `parentAuthorName`, `parentAuthorAccountName`, `parentAuthorAvatarSrc`를 함께 내려준다.
- `templateRequests`
- `id`: string
- `type`: string
@@ -191,6 +262,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 +281,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`는 서버가 자동 생성한다.

View File

@@ -1,5 +1,75 @@
# 할 일 및 이슈
## 단기 확인
- `v1.1.26` 이후 템플릿 카드 클릭 시 `주제 불러오는 중...` 문구가 더 이상 보이지 않고, 주제명 헤더 스켈레톤 뒤에 실제 이름이 자연스럽게 붙는지 확인한다.
- `v1.1.26` 이후 템플릿 A에서 템플릿 B로 빠르게 연속 이동해도 이전 주제의 공개 티어표 카드가 잠깐 남지 않는지 확인한다.
- `v1.1.26` 이후 저장 제목이 없는 티어표를 열 때 내부 ID나 자동 생성 문자열 대신 주제명 기반 표시 제목만 보이는지 확인한다.
- `v1.1.25` 이후 첫 진입 시 로그인 상태가 비로그인처럼 잠깐 보였다가 바뀌는 깜빡임이 실제로 줄었는지, 느린 네트워크/빠른 네트워크 양쪽에서 확인한다.
- `v1.1.25` 이후 티어표 화면 진입 시 제목 자리에 내부 ID가 먼저 노출되지 않고, 편집 모드/프리뷰 모드 둘 다 스켈레톤 뒤에 실제 데이터가 자연스럽게 붙는지 확인한다.
- 초기 인증 게이트는 최소 140ms를 기다리므로, 초고속 응답에서도 오히려 부트 스켈레톤이 과하게 눈에 띄지 않는지 확인한다.
- `v1.1.18` 이후 설정 화면이 데스크톱/태블릿/모바일에서 너무 넓게 퍼지지 않고, 요약 카드 간 간격과 제목/설명 밀도가 다른 대시보드 화면과 자연스럽게 맞는지 확인한다.
- 프로필 이미지 변경 후 저장, 이미지 제거 후 저장, 저장하지 않고 페이지 이탈 세 경우가 모두 의도대로 동작하는지 확인한다.
- 프로필 이미지 자동 저장으로 바뀐 뒤, 파일 선택 직후와 삭제 직후에 즉시 반영되고 연속 클릭 시 중복 저장 요청이 과도하게 쌓이지 않는지 확인한다.
- 닉네임 변경 모달에서 제한 안내 문구와 실제 저장 차단 시점이 일치하는지, 제한 중에는 버튼이 비활성화되고 다음 가능 시각이 자연스럽게 보이는지 확인한다.
- `NICKNAME_CHANGE_INTERVAL_DAYS=1`, `NICKNAME_CHANGE_INTERVAL_MS=0` 같은 운영/테스트 값에서 설정 화면 문구와 백엔드 차단 동작이 함께 바뀌는지 확인한다.
- 설정 화면의 아이콘 버튼(`닉네임 변경`, `비밀번호 변경`, `로그아웃`)이 좁은 화면에서도 겹치지 않고, `title/aria-label` 기준 접근성도 자연스러운지 확인한다.
- 닉네임 변경 제한 중일 때 설정 카드에서 아이콘이 완전히 사라지는 흐름이 사용성 측면에서 충분히 명확한지 실제 계정으로 확인한다.
- 이메일 카드의 읽기 전용 문구만으로도 “로그인용 계정 이메일은 여기서 바꾸지 않는다”는 점이 충분히 전달되는지 확인한다.
- 운영 배포 환경에서 닉네임 변경 주기를 바꿀 때는 `.env.production``NICKNAME_CHANGE_INTERVAL_DAYS` 값만 바꾸면 된다는 점을 배포 문서에도 추후 분리해둘지 검토한다.
- `860px` 이하 모바일 폭에서 좌우 레일이 모두 오버레이로 동작할 때, 헤더 버튼으로 열기/닫기와 바깥 영역 탭 닫기가 자연스러운지 확인한다.
- 모바일에서는 목록 화면 `viewToggle`을 숨기도록 바뀌었으니 홈/템플릿/나의 티어표/즐겨찾기/팔로우 피드에서 헤더 액션 영역이 더 여유롭게 보이는지 확인한다.
- 편집 화면 상단 템플릿 제목 클릭 시 해당 주제 허브로 이동하고, 미저장 변경이 있을 때는 `저장 없이 이동` 확인 모달이 먼저 뜨는지 확인한다.
- 오래전에 가입한 기존 계정은 `nickname_updated_at` 백필 후에도 바로 변경 가능하고, 최근 가입/최근 변경 계정은 정확히 14일 제한이 걸리는지 서버 기준으로 확인한다.
- 비밀번호 변경이 요약 카드의 작은 액션으로만 열리더라도 접근성이 떨어지지 않는지, 모달 `Esc` 닫기와 포커스 이동이 자연스러운지 확인한다.
- `v1.1.17` 이후 설정의 가이드 모달에서 페이지를 넘길 때 썸네일 영역 위치가 이전보다 안정적으로 유지되는지 확인한다.
- `v1.1.17` 이후 가이드 하단 `다음` 버튼이 사라지고, 좌우 화살표/점 네비게이션만으로도 단계 이동이 충분히 자연스러운지 확인한다.
- `v1.1.16` 이후 `S/ㄴ`이 편집 화면에서는 아이템 검색, 일반 목록 화면에서는 왼쪽 공통 검색창 포커스로 정확히 나뉘어 동작하는지 확인한다.
- `v1.1.16` 이후 `G/ㅎ`, `L/ㅣ`가 목록 화면에서만 그리드/리스트 전환을 수행하고, 입력칸을 타이핑 중일 때는 단축키가 발동하지 않는지 확인한다.
- 관리자 계정에서만 `A/ㅁ`이 관리자 화면으로 이동하고, 일반 계정에서는 무시되는지 확인한다.
- `v1.1.15` 이후 `나의 티어표`에서도 왼쪽 공통 검색창이 정상 동작하고, 검색 결과가 없을 때 전용 빈 상태 문구가 자연스럽게 보이는지 확인한다.
- `v1.1.14` 이후 왼쪽 공통 검색창이 홈/템플릿/주제/팔로우 피드/즐겨찾기 각각의 범위만 정확히 검색하는지 확인한다.
- `v1.1.14` 이후 주제 허브, 팔로우 피드, 즐겨찾기 화면 상단에서 중복 검색창이 모두 사라졌는지 확인한다.
- 즐겨찾기 목록에서는 해제 버튼이 숨겨지고, 실제 해제는 해당 티어표 화면 우측 CTA에서만 가능한 흐름이 사용자 의도와 맞는지 확인한다.
- `v1.1.13` 이후 팔로우 피드에서도 공통 `viewToggle`이 보이고, 리스트형 보기에서 작성자 카드와 썸네일 정렬이 어색하지 않은지 확인한다.
- `v1.1.12` 이후 홈/템플릿/나의 티어표/즐겨찾기에서 공통 `viewToggle`이 모두 같은 위치/같은 동작으로 보이는지 확인한다.
- 리스트형 보기에서 홈/템플릿/나의 티어표/즐겨찾기 카드가 데스크톱과 모바일 모두에서 썸네일 비율과 제목 overflow 없이 안정적으로 보이는지 확인한다.
- 내가 만든 저장 티어표도 즐겨찾기에 추가되고 `/favorites`에 나타나는지, 비공개 내 티어표를 즐겨찾기했을 때 접근/표시 규칙이 자연스러운지 확인한다.
- 현재 주요 목록 화면은 전체 데이터를 한 번에 가져오는 구조이므로, 실제 데이터가 많아졌을 때 페이지네이션 또는 무한 스크롤이 필요한 시점을 추후 점검한다.
- `v1.1.11` 이후 즐겨찾기 페이지 카드 우측 상단 `즐겨찾기 해제` 버튼이 카드 열기와 충돌하지 않는지, 해제 직후 목록에서 즉시 빠지고 새로고침 후에도 유지되는지 확인한다.
- `v1.1.10` 이후 댓글 관리 화면이 기본적으로 안 읽은 댓글만 보이므로, 사용자가 처음 들어왔을 때 빈 화면처럼 느끼지 않는지와 `전체 보기`로 돌렸을 때도 자연스러운지 확인한다.
- 개별 `읽음 처리` 버튼을 눌렀을 때 카드가 즉시 사라지고 좌측 메뉴 unread dot도 함께 줄어드는지, 마지막 unread 카드까지 처리하면 dot이 사라지는지 확인한다.
- 티어표 보기 화면 우측 즐겨찾기 단독 CTA가 편집 라우트의 읽기 전용 상태와 `preview=1` 뷰어 모드 양쪽에서 모두 자연스럽게 보이는지 확인한다.
- `v1.1.9` 이후 댓글 관리 카드에서 좌측 썸네일/티어표 정보와 우측 루트 댓글/새 댓글 정보가 실제로 한눈에 읽히는지, 특히 답글 알림에서 부모 댓글 작성자 정보가 자연스럽게 보이는지 확인한다.
- `v1.1.9` 이후 `commentInboxCard__lead` 제거로 정보가 부족해지지 않았는지, 제목과 댓글 블록만으로 상태를 이해할 수 있는지 데스크톱/모바일에서 다시 확인한다.
- `v1.1.8` 이후 댓글 더 보기 규칙(루트 10개, 답글 3개)과 남은 개수 표기가 실제 데이터에서 자연스럽게 동작하는지 확인한다.
- 댓글 관리 화면의 `안 읽은 댓글만 보기` 토글과 `모두 읽음 처리` 버튼이 설정/에디터의 공통 컨트롤 톤과 이질감이 없는지 확인한다.
- `v1.1.7` 이후 댓글 관리 카드 썸네일이 실제로 모든 카드에서 16:9로 유지되는지 데스크톱/모바일에서 다시 확인한다.
- `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한다.

View File

@@ -1,5 +1,163 @@
# 업데이트 로그
## 2026-04-07 v1.1.26
- 템플릿 주제 화면(`TopicHubView`)의 제목 fallback 문자열 `주제 불러오는 중...`을 제거했다. 이제 주제 데이터가 준비되기 전에는 실제 문구 대신 제목/설명 영역 스켈레톤을 보여준다.
- 주제 전환 시 이전 템플릿의 공개 티어표 목록이 잠깐 남아 보이지 않도록, `topicName`, `featuredTierLists`, `tierLists`, `brokenThumbnailIds`를 먼저 비우고 다시 불러오게 정리했다.
- 티어표 편집 화면은 저장용 랜덤 제목과 화면 표시용 제목을 분리했다. 이제 로딩 전후에 내부 `tierListId`나 자동 생성 문자열이 사용자 제목처럼 먼저 드러나지 않고, 표시용 제목은 `주제명 티어표` 또는 `제목 없는 티어표`로만 보여준다.
- 편집 화면 라우트 감시 watch 는 `flush: 'sync'`로 조정해, 같은 컴포넌트 안에서 파라미터가 바뀔 때도 로딩 상태가 더 이른 시점에 반영되도록 보강했다.
- 확인: `npm run build`
## 2026-04-07 v1.1.25
- 앱 최초 진입 시 로그인 상태가 잠깐 비로그인처럼 보였다가 다시 로그인 상태로 바뀌는 깜빡임을 줄이기 위해, 공통 앱 셸에 `bootGate` 초기 게이트를 추가했다. `App.vue``auth.refresh()`와 최소 140ms 대기 시간을 함께 기다린 뒤 셸을 렌더링해, 아주 짧은 인증 복원에서도 화면이 번쩍 바뀌지 않게 한다.
- `TierEditorView`는 제목/보드/우측 아이템 풀이 준비되기 전까지 실제 편집 화면 대신 같은 서비스 톤의 스켈레톤 레이아웃을 먼저 보여주도록 바꿨다. 이로써 티어표 진입 직후 내부 ID나 빈 보드가 먼저 드러나는 현상을 줄였다.
- 에디터 라우트는 앱 전체에서 이미 인증 복원이 끝난 뒤에는 다시 `auth.refresh()`를 중복 호출하지 않도록 보정했다. 최초 진입이 아니면 현재 인증 상태를 그대로 사용해 편집 화면 초기 로딩 지연을 줄인다.
- 스켈레톤은 `preview=1` 뷰어 모드와 편집 모드 모두에 대응하며, 제목/행/셀/우측 패널이 실제 화면 구조와 비슷한 밀도로 먼저 자리잡은 뒤 데이터가 준비되면 한 번에 교체된다.
- 확인: `npm run build`
## 2026-04-07 v1.1.18
- 설정(`/profile`) 화면을 전면 재구성했다. 기존처럼 넓은 2단 입력 폼을 상시 노출하지 않고, `settingsThemePanel` 톤을 참고한 요약 카드 레이아웃으로 바꿔 더 차분하고 통일된 계정 화면으로 정리했다.
- 프로필 영역은 `닉네임 / 이메일 / 프로필 이미지`의 현재 상태를 먼저 보여주고, 자주 바꾸지 않는 정보는 필요할 때만 모달을 열어 변경하도록 바꿨다. 비밀번호 변경도 별도 카드 전체를 차지하지 않고 작은 액션으로 열리는 모달 흐름으로 정리했다.
- 닉네임 변경에는 2주 제한을 추가했다. 백엔드 `users` 테이블에 `nickname_updated_at`을 저장하고, 기존 DB는 서버 시작 시 자동 보강·백필한다. 닉네임이 실제로 바뀐 경우에만 갱신 시각을 다시 찍고, 14일이 지나기 전에는 `nickname_change_locked` 오류와 다음 변경 가능 시각을 돌려준다.
- 인증 직렬화 응답에는 `nicknameUpdatedAt`, `nicknameChangeAvailableAt`를 포함해 프런트가 설정 화면에서 남은 제한 상태를 직접 보여줄 수 있게 했다.
- 프로필 이미지는 아바타 원형 버튼 자체를 누르면 파일 선택기가 열리므로, 중복 동작이던 별도 `프로필 이미지 변경` 버튼은 제거했다.
- 닉네임 변경 제한 기간은 고정 14일 상수가 아니라 환경변수로 바꿨다. `NICKNAME_CHANGE_INTERVAL_MS` 또는 `NICKNAME_CHANGE_INTERVAL_DAYS`로 조절할 수 있고, `0`이면 제한을 끌 수 있다. 프런트 문구도 응답의 `nicknameChangeIntervalLabel`을 따라가도록 맞췄다.
- 프로필 이미지 요약 카드는 제거하고, 아바타 선택/삭제 시 즉시 저장되는 자동 저장 흐름으로 바꿨다. 비밀번호 변경과 로그아웃 액션은 좁은 화면에서도 문구가 뭉개지지 않도록 아이콘 버튼(`key.svg`, `logout.svg`)으로 바꿨다.
- 닉네임은 `open_in_new.svg` 아이콘 버튼으로 모달을 여는 구조로 정리했고, 이메일은 현재 백엔드 기능 범위를 유지해 읽기 전용 상태임을 카드 안에서 더 명확히 표시했다.
- 닉네임 카드 본문에서는 변경 제한 설명을 제거하고, 실제 변경 가능한 시점에만 아이콘 버튼이 나타나도록 바꿨다. 제한 안내는 닉네임 변경 모달 안에서만 `한 번 변경하면 X가 지나야 다시 바꿀 수 있다`는 문구로 보여준다.
- 설정 가이드 마지막 단계에도 닉네임 변경 버튼은 제한 기간이 지나야 다시 나타난다는 안내를 추가했다.
- 이메일 카드는 `현재 로그인에 사용하는 계정 이메일이며, 설정 화면에서는 변경할 수 없다`는 안내로 보정해 로그인 계정 이메일의 성격을 더 분명하게 드러냈다.
- 운영 환경에서 바로 이해할 수 있도록 `.env.production``NICKNAME_CHANGE_INTERVAL_DAYS=20` 샘플 값을 추가했다. 닉네임 제한을 20일로 바꾸고 싶다면 이 값을 그대로 두고, 다른 기간을 원하면 숫자만 바꾸면 된다.
- 모바일 브레이크포인트(`860px` 이하)에서는 목록 화면의 `그리드/리스트` 전환 버튼을 숨기고, 왼쪽 레일도 오른쪽 레일처럼 오버레이 패널로 띄우도록 앱 셸 구조를 바꿨다. 모바일 상단 헤더 오른쪽에는 왼쪽 패널 열기와 오른쪽 패널 열기/닫기 버튼을 함께 배치한다.
- 티어표 편집 화면 상단의 템플릿 제목은 더 이상 본문 상단 스크롤 버튼이 아니다. 이제 제목을 누르면 해당 주제 템플릿 화면으로 이동하고, 편집 중 미저장 변경이 있으면 기존 `저장 없이 이동` 확인 모달을 먼저 보여준다.
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/auth.js`, `npm run build`
## 2026-04-07 v1.1.17
- 설정의 `가이드 보기` 모달 9페이지 단축키 설명은 최근 추가된 전역 단축키 기준으로 최신 상태를 유지하도록 다시 확인했다.
- 가이드 하단 `다음` 버튼은 좌우 화살표와 역할이 겹쳐 제거했다. 이제 단계 이동은 좌우 화살표, 점 네비게이션, 좌측 단계 목록만 사용한다.
- `guideModal__stepDescription`에는 최소 4줄 높이를 부여해, 설명 길이에 따라 페이지를 넘길 때 미디어 영역이 위아래로 튀어 보이던 현상을 줄였다.
- 확인: `npm run build`
## 2026-04-07 v1.1.16
- 전역 단축키를 보강했다. `S/ㄴ`은 검색 포커스로 동작하며, 편집 화면에서는 기존처럼 아이템 검색창에, 그 외 화면에서는 왼쪽 공통 검색창에 포커스를 준다.
- 새 보기 전환 단축키를 추가했다. `G/ㅎ`는 그리드 보기, `L/ㅣ`는 리스트 보기로 전환하며, 공통 `viewToggle`이 있는 목록 화면에서만 동작한다.
- 관리자 계정에서는 `A/ㅁ` 단축키로 바로 관리자 화면(`/admin/featured`)으로 이동할 수 있게 했다.
- 한글 두벌식 자판 상태에서도 `ㄴ/ㅎ/ㅣ/ㅁ`이 각각 `S/G/L/A`와 같은 의미로 처리되도록 맞췄다.
- 가이드 모달의 단축키 설명도 현재 동작에 맞게 갱신했다.
- 확인: `npm run build`
## 2026-04-07 v1.1.15
- `나의 티어표`도 왼쪽 공통 검색창 범위에 포함했다. 이제 `/me` 화면에서 입력한 검색어는 내 저장 티어표 제목/주제명/작성자 이름 기준으로 즉시 필터링된다.
- `MyTierListsView``route.query.q`를 받아 클라이언트에서 목록을 필터링하고, 검색 결과가 없으면 전용 빈 상태 문구를 표시한다.
- 확인: `npm run build`
## 2026-04-07 v1.1.14
- 왼쪽 공통 검색창이 현재 화면 문맥만 검색하도록 라우팅을 정리했다. 이제 홈은 전체 공개 티어표, 템플릿은 공개 템플릿, 특정 템플릿 화면은 해당 주제의 공개 티어표, 팔로우 피드는 팔로우한 작성자의 공개 티어표, 즐겨찾기는 내가 즐겨찾기한 티어표 안에서만 검색한다.
- `TopicHubView`, `FollowingFeedView`, `FavoriteTierListsView` 상단의 중복 검색 입력창은 제거했다. 검색은 왼쪽 공통 검색창 하나로만 수행하도록 정리했다.
- 즐겨찾기 목록에서는 카드 우측 상단 `즐겨찾기 해제` 버튼을 다시 숨겼다. 실수 방지를 위해 목록에서는 방문만 하고, 실제 해제는 해당 티어표 화면 우측 즐겨찾기 CTA에서 하도록 흐름을 되돌렸다.
- 즐겨찾기 화면은 검색 입력을 제거하고 정렬 셀렉트만 유지한다.
- 확인: `npm run build`
## 2026-04-07 v1.1.13
- 팔로우 피드도 다른 티어표 목록 화면과 같은 성격이므로, 공통 `viewToggle` 대상에 포함했다.
- `FollowingFeedView`에 그리드형/리스트형 보기 전환을 추가했다. 이제 팔로우한 작성자의 공개 티어표도 상단 공통 토글로 카드형과 가로 리스트형을 오갈 수 있다.
- 팔로우 피드 리스트형 레이아웃에서는 썸네일 좌측, 정보 우측 구조로 정리해 다른 목록 화면과 같은 문법을 따른다.
- 확인: `npm run build`
## 2026-04-07 v1.1.12
- 티어표 즐겨찾기 프런트 제한을 풀었다. 이제 내가 만든 저장된 티어표도 즐겨찾기에 넣을 수 있고, 같은 즐겨찾기 목록(`/favorites`)에서 다시 확인할 수 있다.
- 홈, 템플릿, 나의 티어표, 즐겨찾기 화면에 공통 `viewToggle`을 다시 연결했다. 기존처럼 카드형 그리드와 가로 리스트형 보기 전환을 URL `?view=list` 기준으로 같은 방식으로 유지한다.
- 홈/나의 티어표/즐겨찾기 화면에는 리스트형 레이아웃을 추가했고, 템플릿 화면도 같은 토글로 카드형과 리스트형을 오갈 수 있게 맞췄다.
- 즐겨찾기 페이지 카드에서 썸네일/제목/메타가 카드 밖으로 넘치거나 잘려 보이던 구조를 `min-width`, overflow, title row grid 정리로 보정했다.
- 현재 홈, 템플릿, 나의 티어표, 즐겨찾기 목록은 모두 페이지네이션이나 무한 스크롤 없이 “현재 조회 결과 전체를 한 번에 로드”하는 구조임을 다시 확인했다.
- 확인: `npm run build`
## 2026-04-07 v1.1.11
- `즐겨찾기` 페이지 카드에서도 바로 해제할 수 있게 정리했다. 이제 목록 화면에서 각 카드 우측 상단 `즐겨찾기 해제` 버튼으로 해당 티어표를 즉시 제거할 수 있다.
- 카드 본문 열기와 해제 버튼 동작이 섞이지 않도록 분리했다. 버튼은 카드 클릭과 독립적으로 처리되고, 성공 시 목록에서도 바로 빠져 정리 흐름이 자연스럽다.
- 확인: `npm run build`
## 2026-04-07 v1.1.10
- 댓글 관리 화면은 기본 진입 시 `안 읽은 댓글만 보기`가 켜진 상태로 시작하도록 바꿨다. 처음 들어왔을 때 가장 중요한 미확인 알림만 먼저 보이게 하는 쪽이 관리 흐름에 더 자연스럽다.
- 댓글 관리 카드의 우측 배지는 더 이상 `댓글/답글` 구분용이 아니라 개별 `읽음 처리` 버튼으로 동작한다. 이제 해당 티어표 화면으로 들어가지 않아도 카드 단위로 바로 읽음 처리할 수 있다.
- 개별 읽음 처리는 `안 읽은 댓글만 보기`가 켜진 상태와 자연스럽게 연결되도록, 처리한 카드는 즉시 목록에서 빠지고 좌측 메뉴 unread dot도 함께 갱신되게 정리했다.
- 티어표 보기 화면의 즐겨찾기 액션을 더 명확하게 드러냈다. 일반 보기 화면 우측 사이드에는 `즐겨찾기에 추가하기 / 즐겨찾기 해제하기` 단독 버튼을 노출하고, 편집 라우트의 읽기 전용 상태에서도 같은 톤의 단일 CTA로 정리했다.
- 즐겨찾기 수는 버튼 안 숫자 대신 보조 문구로 분리해, 액션 자체를 더 또렷하게 읽히게 바꿨다.
- 확인: `npm run build`
## 2026-04-07 v1.1.9
- 댓글 관리 화면의 패널과 카드 톤을 댓글 카드(`commentsCard`) 계열과 더 가깝게 다시 정리했다. 바깥 패널은 같은 배경/보더 문법을 쓰고, 개별 알림 카드는 장식성 그림자 대신 단정한 카드 레이어로 맞췄다.
- 댓글 관리 카드의 정보 구조를 다시 설계했다. 왼쪽에는 `16:9 썸네일 / 티어표 제목 / 템플릿 이름`만 모으고, 오른쪽에는 `알림 제목 / 루트 댓글 / 새 댓글 또는 새 답글` 흐름으로 읽히게 정리했다.
- 중복 설명 역할이던 `commentInboxCard__lead`는 제거했다. 이제 카드 제목만 봐도 상태를 이해할 수 있고, 실제 내용 이해는 바로 아래 댓글 정보 블록이 담당한다.
- 댓글 관리 API는 답글 알림에서 부모 댓글 작성자 아바타/이름/작성시간도 함께 내려주도록 확장했다. 그래서 프런트는 `루트 댓글``새 답글`을 각각 작성자 단위로 한눈에 비교해 보여줄 수 있다.
- 확인: `node --check backend/src/db.js`, `npm run build`
## 2026-04-07 v1.1.8
- 댓글 관리 화면의 상단 컨트롤을 정리했다. `안 읽은 댓글만 보기`는 체크박스 대신 프로젝트 공통 토글 스위치 문법으로 바꾸고, `모두 읽음 처리` 버튼도 저장 CTA 계열과 같은 톤으로 맞췄다.
- 댓글 영역에는 `더 보기` 흐름을 추가했다. 루트 댓글은 처음 10개, 답글은 처음 3개만 보여주고, 남은 개수를 표시하는 `댓글 n개 더 보기`, `답글 n개 더 보기` 버튼으로 단계적으로 펼친다.
- 댓글 카드 세로 밀도도 함께 낮췄다. 댓글/답글 카드 간격과 본문 패딩, 답글 입력 카드 여백을 줄여 긴 스레드에서도 화면을 덜 차지하게 정리했다.
- 확인: `npm run build`
## 2026-04-07 v1.1.7
- 댓글 관리 카드 썸네일이 CSS grid의 세로 stretch 영향으로 16:9로 보이지 않을 수 있어, 썸네일 셀을 `align-self: start`로 고정하고 카드 본문 정렬도 `align-items: start`로 바꿨다. 이제 댓글 관리 썸네일은 항상 16:9 비율로 표시된다.
- 확인: `npm run build`
## 2026-04-07 v1.1.6
- 댓글 영역 스타일을 다시 전면 정리했다. 과한 장식/그림자 중심 디자인 대신 `viewerSidebar__section` 계열과 같은 단정한 카드 문법으로 맞추고, 댓글/답글은 배경과 간격 위주로 읽히게 재구성했다.
- 댓글 등록/답글 등록 버튼은 불필요한 shadow 없이 에디터 저장 계열과 같은 `btn--save` 톤으로 다시 맞췄다.
- 댓글 정렬 규칙을 조정했다. 루트 댓글은 `최신 댓글이 가장 위`, 답글은 `가장 먼저 달린 답글이 가장 위`로 유지되도록 바꿨다.
- 뷰어 모드 우측 카드가 댓글 길이에 따라 아래로 밀려 보이지 않도록, 우측 로컬 레일 루트와 `viewerSidebar__section` 정렬을 `스폰서 카드 바로 아래` 기준으로 고정했다.
- 확인: `npm run build`
## 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`에 실제 배치된 아이템만 사용된 것으로 계산한다.
- 템플릿 아이템 사용 횟수도 같은 이미지가 연결된 템플릿 개수가 아니라, 해당 기본 아이템이 저장된 티어표 보드에 실제로 배치된 횟수를 기준으로 계산한다.
- 템플릿 기본 아이템 삭제 영향 안내도 실제 배치된 티어표 기준으로 맞췄다.
- 확인: `node --check backend/src/db.js`, `npm run build`
## 2026-04-06 v1.0.103
- 문서상의 버전 표기 기준을 정리했다. 기존에 공개 전 개발 흐름에서 잘못 올라간 `v1.2.x``v0.2.x`, `v1.3.x``v0.3.x`, `v1.4.x``v1.0.x` 흐름으로 해석한다.
- 다음 작업자와 AI는 이 문서 기준으로 현재 최신 버전을 `v1.0.103`로 이어가야 한다. 기존 Git 태그/커밋은 히스토리 재작성 위험이 있어 그대로 두고, 문서 기준 버전만 보정했다.

View File

@@ -1,8 +1,10 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { computed, nextTick, 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,16 @@ const leftRailCollapsed = ref(false)
const mobileLeftNavOpen = ref(false)
const rightRailOpen = ref(true)
const searchQuery = ref('')
const leftRailSearchPlaceholder = '주제 템플릿 검색'
const searchInputEl = ref(null)
const collapsedSearchInputEl = ref(null)
const leftRailSearchPlaceholder = computed(() => {
if (route.name === 'templates') return '주제 템플릿 검색'
if (route.name === 'topicHub') return '이 템플릿의 공개 티어표 검색'
if (route.name === 'followingFeed') return '팔로우한 사람의 공개 티어표 검색'
if (route.name === 'favorites') return '즐겨찾기한 티어표 검색'
if (route.name === 'me') return '내 티어표 검색'
return '공개 티어표 검색'
})
const isCollapsedSearchOpen = ref(false)
const isGuideModalOpen = ref(false)
const themeMode = ref('dark')
@@ -39,6 +50,8 @@ const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 14
const backendState = ref('online')
const backendMessage = ref('')
const isFullscreenActive = ref(false)
const unreadCommentCount = ref(0)
const isBootReady = ref(false)
provide('rightRailOpen', rightRailOpen)
provide('localRightRailTarget', '#local-right-rail-root')
@@ -70,10 +83,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))
@@ -143,7 +158,7 @@ const guideSteps = [
title: '단축키로 빠른 조작',
summary: '사이드 패널과 전체 화면을 키보드로 빠르게 전환합니다.',
description:
'[ 키는 왼쪽 사이드를 열고 닫고, ] 키는 오른쪽 사이드를 열고 닫습니다. F 키는 전체 화면 보기 토글, S 키는 티어표 편집 화면 아이템 검색창으로 바로 이동할 때 사용할 수 있어요. 한글 입력 상태에서는 F 자리의 ㄹ, S 자리의 ㄴ 키도 같은 단축키로 처리됩니다. 각종 모달은 Esc 키로 닫을 수 있습니다. 단, 검색창이나 입력칸에 글을 쓰는 중에는 단축키가 동작하지 않도록 처리되어 있어요.',
'[ 키는 왼쪽 사이드를 열고 닫고, ] 키는 오른쪽 사이드를 열고 닫습니다. F/ㄹ은 전체 화면, S/ㄴ은 검색 포커스(편집 화면에서는 아이템 검색), G/ㅎ은 그리드 보기, L/ㅣ는 리스트 보기입니다. 설정 화면의 닉네임 변경 버튼은 변경 가능 기간이 지난 뒤에만 다시 나타납니다. 각종 모달은 Esc 키로 닫을 수 있고, 입력칸에 글을 쓰는 중에는 단축키가 동작하지 않도록 처리되어 있습니다.',
},
]
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
@@ -152,7 +167,7 @@ const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.le
const isLightTheme = computed(() => themeMode.value === 'light')
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
const showSettingsThemePanel = computed(() => route.name === 'profile')
const showTopicViewToggle = computed(() => route.name === 'topicHub')
const showTopicViewToggle = computed(() => !isMobileLayout.value && ['home', 'templates', 'topicHub', 'me', 'favorites', 'followingFeed'].includes(String(route.name || '')))
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
const shouldLockRightRailBodyScroll = computed(() => isRightRailOverlay.value && rightRailOpen.value && !showBackendFallback.value)
@@ -161,6 +176,9 @@ const leftBottomPrimaryAction = computed(() => {
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 }
@@ -171,10 +189,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())
@@ -244,6 +274,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: '작성자 프로필',
@@ -296,6 +336,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)
@@ -322,11 +379,16 @@ onMounted(async () => {
if (savedTheme === 'light' || savedTheme === 'dark') applyTheme(savedTheme)
else applyTheme('dark')
}
await auth.refresh()
await Promise.all([
auth.refresh(),
new Promise((resolve) => setTimeout(resolve, 140)),
])
isBootReady.value = true
if (typeof window !== 'undefined') {
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)
@@ -343,9 +405,11 @@ onMounted(async () => {
rightRailOpen.value = true
}
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
await refreshUnreadCommentCount()
})
function handleGlobalKeydown(event) {
const normalizedKey = String(event.key || '').toLowerCase()
if (event.key === 'Escape' && isGuideModalOpen.value) {
closeGuideModal()
return
@@ -367,14 +431,33 @@ function handleGlobalKeydown(event) {
toggleRightRail()
return
}
if (['f', 'ㄹ'].includes(String(event.key || '').toLowerCase())) {
if (['f', 'ㄹ'].includes(normalizedKey)) {
event.preventDefault()
toggleFullscreen()
return
}
if (['s', 'ㄴ'].includes(String(event.key || '').toLowerCase()) && ['editEditor', 'newEditor'].includes(String(route.name || ''))) {
if (['s', 'ㄴ'].includes(normalizedKey)) {
event.preventDefault()
window.dispatchEvent(new CustomEvent('tier-maker:focus-editor-item-search'))
if (['editEditor', 'newEditor'].includes(String(route.name || ''))) {
window.dispatchEvent(new CustomEvent('tier-maker:focus-editor-item-search'))
return
}
focusGlobalSearch()
return
}
if (['g', 'ㅎ'].includes(normalizedKey) && showTopicViewToggle.value) {
event.preventDefault()
setTopicViewMode('grid')
return
}
if (['l', 'ㅣ'].includes(normalizedKey) && showTopicViewToggle.value) {
event.preventDefault()
setTopicViewMode('list')
return
}
if (['a', 'ㅁ'].includes(normalizedKey) && isAdmin.value) {
event.preventDefault()
router.push('/admin/featured')
}
}
@@ -406,6 +489,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)
@@ -424,6 +508,14 @@ watch(
mobileLeftNavOpen.value = false
rightRailOpen.value = false
}
refreshUnreadCommentCount()
}
)
watch(
() => auth.user?.id,
() => {
refreshUnreadCommentCount()
}
)
@@ -489,7 +581,7 @@ function toggleRightRail() {
}
function setTopicViewMode(mode) {
if (route.name !== 'topicHub') return
if (!showTopicViewToggle.value) return
const nextQuery = { ...route.query }
if (mode === 'list') nextQuery.view = 'list'
else delete nextQuery.view
@@ -505,6 +597,23 @@ function closeCollapsedSearch() {
isCollapsedSearchOpen.value = false
}
async function focusGlobalSearch() {
if (leftRailCollapsed.value && !isMobileLayout.value) {
openCollapsedSearch()
await nextTick()
if (collapsedSearchInputEl.value?.focus) {
collapsedSearchInputEl.value.focus()
collapsedSearchInputEl.value.select?.()
}
return
}
await nextTick()
if (searchInputEl.value?.focus) {
searchInputEl.value.focus()
searchInputEl.value.select?.()
}
}
function openGuideModal(stepIndex = 0) {
guideStepIndex.value = Math.min(Math.max(Number(stepIndex) || 0, 0), guideSteps.length - 1)
isGuideModalOpen.value = true
@@ -539,7 +648,14 @@ function handleLeftRailSearch() {
function submitGlobalSearch() {
const query = (searchQuery.value || '').trim()
isCollapsedSearchOpen.value = false
router.push(homePath(query))
if (['home', 'templates', 'topicHub', 'followingFeed', 'favorites', 'me'].includes(String(route.name || ''))) {
const nextQuery = { ...route.query }
if (query) nextQuery.q = query
else delete nextQuery.q
router.push({ path: route.path, query: nextQuery })
return
}
router.push(route.name === 'templates' ? templatesPath(query) : homePath(query))
}
function reloadApp() {
@@ -580,10 +696,30 @@ function reloadApp() {
</section>
</main>
</template>
<template v-else-if="!isBootReady">
<main class="bootGate">
<section class="bootGate__shell">
<div class="bootGate__brand">
<div class="bootGate__logo"></div>
<div class="bootGate__copy">
<div class="bootGate__title">Tier Maker</div>
<div class="bootGate__desc">계정 상태와 화면 구성을 준비하고 있어요.</div>
</div>
</div>
<div class="bootGate__panels">
<div class="bootGate__panel bootGate__panel--nav"></div>
<div class="bootGate__panel bootGate__panel--main"></div>
<div class="bootGate__panel bootGate__panel--side"></div>
</div>
</section>
</main>
</template>
<template v-else>
<aside class="leftRail">
<button v-if="isMobileLayout && mobileLeftNavOpen" class="leftRailBackdrop" type="button" aria-label="왼쪽 패널 닫기" @click="toggleLeftRail"></button>
<aside class="leftRail" :class="{ 'leftRail--overlay': isMobileLayout }" :aria-hidden="isMobileLayout && !mobileLeftNavOpen">
<div class="leftRail__top railHeader">
<button v-if="!isMobileLayout" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="왼쪽 패널 토글" @click="toggleLeftRail">
<button class="ghostIcon ghostIcon--iconOnly" type="button" :aria-label="isMobileLayout ? '왼쪽 패널 닫기' : '왼쪽 패널 토글'" @click="toggleLeftRail">
<SvgIcon :src="iconDockToRight" :size="24" />
</button>
</div>
@@ -593,21 +729,11 @@ 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>
</div>
<button
v-if="isMobileLayout"
class="appUserCard__navToggle"
type="button"
:aria-label="mobileLeftNavOpen ? '네비게이션 메뉴 닫기' : '네비게이션 메뉴 열기'"
:aria-expanded="mobileLeftNavOpen"
@click="toggleLeftRail"
>
<SvgIcon :src="mobileLeftNavOpen ? iconDockToLeft : iconDockToRight" :size="24" />
</button>
</div>
</div>
@@ -618,7 +744,7 @@ function reloadApp() {
<SvgIcon :src="iconSearch" :size="24" />
</span>
</button>
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
<input ref="searchInputEl" v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
</form>
<nav
@@ -639,6 +765,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>
@@ -682,9 +809,15 @@ function reloadApp() {
<SvgIcon :src="iconLists" :size="24" />
</button>
</div>
<button v-if="isMobileLayout" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="왼쪽 패널 열기" @click="toggleLeftRail">
<SvgIcon :src="iconDockToRight" :size="24" />
</button>
<button v-if="!rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 열기" @click="toggleRightRail">
<SvgIcon :src="iconDockToLeft" :size="24" />
</button>
<button v-else-if="isMobileLayout" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="오른쪽 패널 닫기" @click="toggleRightRail">
<SvgIcon :src="iconDockToLeft" :size="24" />
</button>
</div>
</header>
<div class="workspaceBody" :class="{ 'workspaceBody--localRail': usesLocalRightRail }">
@@ -698,7 +831,7 @@ function reloadApp() {
<span class="collapsedSearchBar__icon">
<SvgIcon :src="iconSearch" :size="24" />
</span>
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="leftRailSearchPlaceholder" autofocus />
<input ref="collapsedSearchInputEl" v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="leftRailSearchPlaceholder" autofocus />
</form>
</div>
@@ -757,9 +890,6 @@ function reloadApp() {
@click="selectGuideStep(index)"
></button>
</div>
<button class="guideModal__next" type="button" @click="isGuideNextDisabled ? closeGuideModal() : showNextGuideStep()">
{{ isGuideNextDisabled ? '닫기' : '다음' }}
</button>
</div>
</div>
<button class="guideModal__arrow" type="button" aria-label="다음 단계" :disabled="isGuideNextDisabled" @click="showNextGuideStep"></button>
@@ -897,6 +1027,95 @@ function reloadApp() {
cursor: pointer;
}
.bootGate {
min-width: 100dvw;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
background: var(--theme-shell-bg);
}
.bootGate__shell {
width: min(100%, 1180px);
display: grid;
gap: 20px;
}
.bootGate__brand {
display: flex;
align-items: center;
gap: 16px;
padding: 18px 20px;
border-radius: 24px;
border: 1px solid var(--theme-border);
background: var(--theme-rail-bg);
}
.bootGate__logo,
.bootGate__panel {
position: relative;
overflow: hidden;
}
.bootGate__logo::after,
.bootGate__panel::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent);
animation: bootGateShimmer 1.4s ease-in-out infinite;
}
.bootGate__logo {
width: 52px;
height: 52px;
border-radius: 18px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
}
.bootGate__copy {
display: grid;
gap: 6px;
}
.bootGate__title {
font-size: 20px;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--theme-text-strong);
}
.bootGate__desc {
color: var(--theme-text-muted);
line-height: 1.6;
}
.bootGate__panels {
display: grid;
grid-template-columns: 248px minmax(0, 1fr) 320px;
gap: 20px;
}
.bootGate__panel {
border-radius: 24px;
border: 1px solid var(--theme-border);
background: var(--theme-rail-bg);
min-height: 72dvh;
}
.bootGate__panel--main {
background: var(--theme-shell-bg);
}
@keyframes bootGateShimmer {
100% {
transform: translateX(100%);
}
}
.leftRail,
.rightRail {
min-height: 100dvh;
@@ -1243,12 +1462,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;
}
@@ -1658,6 +1889,7 @@ function reloadApp() {
font-weight: 900;
line-height: 1.1;
letter-spacing: -0.04em;
word-break: keep-all;
}
.guideModal__mobilePicker {
@@ -1791,6 +2023,7 @@ function reloadApp() {
.guideModal__text {
display: grid;
gap: 8px;
min-height: 0;
}
.guideModal__stepLabel {
@@ -1815,6 +2048,7 @@ function reloadApp() {
.guideModal__stepDescription {
margin: 0;
max-width: 720px;
min-height: calc(1.7em * 4);
line-height: 1.7;
color: var(--theme-text-soft);
}
@@ -1933,6 +2167,8 @@ function reloadApp() {
min-height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: 14px;
}
@@ -2025,10 +2261,10 @@ function reloadApp() {
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr);
}
.rightRailBackdrop {
position: fixed;
inset: 0;
display: block;
.rightRailBackdrop {
position: fixed;
inset: 0;
display: block;
border: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 29;
@@ -2055,6 +2291,32 @@ function reloadApp() {
}
@media (max-width: 860px) {
.bootGate {
padding: 16px;
}
.bootGate__shell {
gap: 16px;
}
.bootGate__brand {
padding: 16px;
border-radius: 20px;
}
.bootGate__panels {
grid-template-columns: 1fr;
}
.bootGate__panel--nav,
.bootGate__panel--side {
display: none;
}
.bootGate__panel--main {
min-height: 72dvh;
}
.guideModal {
padding: 20px 12px;
}
@@ -2137,19 +2399,49 @@ function reloadApp() {
}
.leftRail {
min-height: auto;
height: auto;
min-height: 100dvh;
border-right: 0;
border-bottom: 1px solid var(--theme-border);
border-bottom: 0;
}
.leftRailBackdrop {
position: fixed;
inset: 0;
display: block;
border: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 29;
}
.leftRail--overlay {
position: fixed;
top: 0;
left: 0;
width: min(340px, calc(100vw - 20px));
height: 100dvh;
z-index: 30;
background: var(--theme-shell-bg);
border-right: 1px solid var(--theme-border);
box-shadow: 18px 0 36px rgba(0, 0, 0, 0.34);
transition:
transform 220ms ease,
opacity 220ms ease;
}
.appShell--mobileNavClosed .leftRail--overlay {
transform: translateX(calc(-100% - 24px));
opacity: 0;
pointer-events: none;
}
.leftRail__top {
display: none;
display: flex;
justify-content: flex-end;
}
.leftRail__body {
max-height: none;
padding: 12px 14px;
padding: 12px 14px calc(18px + env(safe-area-inset-bottom));
}
.appUserCard {
@@ -2164,10 +2456,6 @@ function reloadApp() {
max-width: none;
}
.appUserCard__navToggle {
display: inline-flex;
}
.workspaceHead .ghostIcon--iconOnly,
.rightRail__top .ghostIcon--iconOnly {
width: 42px;
@@ -2266,18 +2554,6 @@ function reloadApp() {
margin: 14px 14px 0;
}
.appShell--mobileNavClosed .leftRail__mobileMenu {
max-height: 0;
margin-top: -8px;
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
}
.appShell--mobileNavClosed .leftRail__bottom {
display: none;
}
.rightRail--overlay .rightRail__body {
padding: 14px 20px calc(32px + env(safe-area-inset-bottom));
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M223.5-423.5Q200-447 200-480t23.5-56.5Q247-560 280-560t56.5 23.5Q360-513 360-480t-23.5 56.5Q313-400 280-400t-56.5-23.5ZM280-240q-100 0-170-70T40-480q0-100 70-170t170-70q67 0 121.5 33t86.5 87h352l120 120-180 180-80-60-80 60-85-60h-47q-32 54-86.5 87T280-240Zm0-80q56 0 98.5-34t56.5-86h125l58 41 82-61 71 55 75-75-40-40H435q-14-52-56.5-86T280-640q-66 0-113 47t-47 113q0 66 47 113t113 47Z"/></svg>

After

Width:  |  Height:  |  Size: 508 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h280v80H200Zm440-160-55-58 102-102H360v-80h327L585-622l55-58 200 200-200 200Z"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"/></svg>

After

Width:  |  Height:  |  Size: 299 B

View File

@@ -0,0 +1,689 @@
<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({})
const visibleRootCount = ref(10)
const visibleReplyCounts = ref({})
let activeCommentRetryTimer = 0
const totalCommentCount = computed(() =>
comments.value.reduce((count, comment) => count + 1 + (comment.replies?.length || 0), 0)
)
const visibleComments = computed(() => comments.value.slice(0, visibleRootCount.value))
const hasMoreRootComments = computed(() => comments.value.length > visibleRootCount.value)
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 : []
visibleRootCount.value = 10
visibleReplyCounts.value = {}
scrollToHighlightedComment()
} catch (loadError) {
error.value = '댓글을 불러오지 못했어요.'
} finally {
isLoading.value = false
}
}
function visibleRepliesOf(comment) {
const replies = Array.isArray(comment?.replies) ? comment.replies : []
const limit = visibleReplyCounts.value[comment?.id] || 3
return replies.slice(0, limit)
}
function hasMoreReplies(comment) {
const replies = Array.isArray(comment?.replies) ? comment.replies : []
return replies.length > (visibleReplyCounts.value[comment?.id] || 3)
}
function remainingReplyCount(comment) {
const replies = Array.isArray(comment?.replies) ? comment.replies : []
return Math.max(0, replies.length - (visibleReplyCounts.value[comment?.id] || 3))
}
const remainingRootCount = computed(() => Math.max(0, comments.value.length - visibleRootCount.value))
function showMoreRootComments() {
visibleRootCount.value += 10
}
function showMoreReplies(commentId) {
if (!commentId) return
visibleReplyCounts.value = {
...visibleReplyCounts.value,
[commentId]: (visibleReplyCounts.value[commentId] || 3) + 3,
}
}
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 visibleComments"
: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 visibleRepliesOf(comment)"
: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>
<button v-if="hasMoreReplies(comment)" class="commentMoreButton" type="button" @click="showMoreReplies(comment.id)">
답글 {{ remainingReplyCount(comment) }} 보기
</button>
</div>
</article>
<button v-if="hasMoreRootComments" class="commentMoreButton commentMoreButton--root" type="button" @click="showMoreRootComments">
댓글 {{ remainingRootCount }} 보기
</button>
</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: 10px;
}
.commentMoreButton {
justify-self: flex-start;
margin-top: 10px;
padding: 0;
border: 0;
background: transparent;
color: var(--theme-text-muted);
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.commentMoreButton--root {
margin-top: 4px;
}
.commentItem {
position: relative;
padding: 12px 0 0;
}
.commentItem--reply {
margin-top: 8px;
margin-left: 20px;
padding-top: 8px;
}
.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: 6px;
}
.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: 12px 14px;
border-radius: 16px;
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: 12px;
padding: 12px;
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>

View File

@@ -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) => {

View 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
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,621 @@
<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(true)
const isMarkingAllRead = ref(false)
const markingNotificationId = ref('')
const unreadCount = computed(() => notifications.value.filter((item) => !item.isRead).length)
function avatarUrlOf(notification) {
return notification.actorAvatarSrc ? toApiUrl(notification.actorAvatarSrc) : ''
}
function parentAvatarUrlOf(notification) {
return notification.parentAuthorAvatarSrc ? toApiUrl(notification.parentAuthorAvatarSrc) : ''
}
function tierListThumbnailUrl(notification) {
return notification.tierListThumbnailSrc ? toApiUrl(notification.tierListThumbnailSrc) : ''
}
function avatarFallbackOf(notification) {
return displayInitialFrom(notification.actorName, notification.actorAccountName, '?')
}
function parentAvatarFallbackOf(notification) {
return displayInitialFrom(notification.parentAuthorName, notification.parentAuthorAccountName, '?')
}
function parentDisplayNameOf(notification) {
return notification.parentAuthorName || '알 수 없음'
}
function formatDate(ts) {
return new Date(Number(ts || 0)).toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function notificationTitle(notification) {
return notification.notificationType === 'comment_reply' ? '내 댓글에 답글이 달렸어요.' : '내 티어표에 새 댓글이 달렸어요.'
}
function emitUnreadCount(unread) {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent('tier-maker:comment-inbox-updated', { detail: { unreadCount: unread } }))
}
async function loadInbox() {
isLoading.value = true
try {
const data = await api.listCommentInbox({ unreadOnly: unreadOnly.value })
notifications.value = Array.isArray(data.notifications) ? data.notifications : []
emitUnreadCount(unreadCount.value)
} catch (error) {
toast.error('댓글 알림을 불러오지 못했어요.')
} finally {
isLoading.value = false
}
}
async function markOneAsRead(notificationId) {
const target = notifications.value.find((item) => item.id === notificationId)
if (!target || target.isRead) return
const original = notifications.value.map((item) => ({ ...item }))
target.isRead = true
if (unreadOnly.value) {
notifications.value = notifications.value.filter((item) => item.id !== notificationId)
}
emitUnreadCount(unreadCount.value)
try {
await api.markCommentInboxRead({ notificationIds: [notificationId] })
} catch (error) {
notifications.value = original
emitUnreadCount(unreadCount.value)
}
}
async function markNotificationButton(notificationId) {
if (!notificationId || markingNotificationId.value) return
markingNotificationId.value = notificationId
try {
await markOneAsRead(notificationId)
} finally {
markingNotificationId.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="toggleSwitch commentInboxToolbar__toggle">
<input v-model="unreadOnly" type="checkbox" />
<span class="toggleSwitch__label"> 읽은 댓글만 보기</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<button class="btn btn--save commentInboxToolbar__action" 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__aside">
<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__targetTitle">{{ notification.tierListTitle || '제목 없는 티어표' }}</div>
<div class="commentInboxCard__targetMeta">{{ notification.topicName || notification.topicSlug || notification.topicId }}</div>
</div>
<div class="commentInboxCard__main">
<div class="commentInboxCard__titleRow">
<div class="commentInboxCard__title">{{ notificationTitle(notification) }}</div>
<div class="commentInboxCard__status">
<span v-if="!notification.isRead" class="commentInboxCard__dot" aria-label="안 읽음"></span>
<button
v-if="!notification.isRead"
class="commentInboxCard__badge"
type="button"
:disabled="!!markingNotificationId"
@click.stop="markNotificationButton(notification.id)"
>
{{ markingNotificationId === notification.id ? '처리 중...' : '읽음 처리' }}
</button>
</div>
</div>
<div class="commentInboxCard__thread">
<div v-if="notification.parentCommentContent" class="commentInboxThread">
<div class="commentInboxThread__label">루트 댓글</div>
<div class="commentInboxThread__body">
<img
v-if="parentAvatarUrlOf(notification)"
class="commentInboxThread__avatar"
:src="parentAvatarUrlOf(notification)"
:alt="parentDisplayNameOf(notification)"
draggable="false"
/>
<div v-else class="commentInboxThread__avatar commentInboxThread__avatar--fallback">{{ parentAvatarFallbackOf(notification) }}</div>
<div class="commentInboxThread__content">
<div class="commentInboxThread__meta">
<span class="commentInboxThread__name">{{ parentDisplayNameOf(notification) }}</span>
<span class="commentInboxThread__separator">·</span>
<span class="commentInboxThread__date">{{ formatDate(notification.parentCommentCreatedAt) }}</span>
</div>
<div class="commentInboxThread__text">{{ notification.parentCommentContent }}</div>
</div>
</div>
</div>
<div class="commentInboxThread commentInboxThread--accent">
<div class="commentInboxThread__label">{{ notification.notificationType === 'comment_reply' ? '새 답글' : '새 댓글' }}</div>
<div class="commentInboxThread__body">
<img
v-if="avatarUrlOf(notification)"
class="commentInboxThread__avatar"
:src="avatarUrlOf(notification)"
:alt="notification.actorName || '작성자'"
draggable="false"
/>
<div v-else class="commentInboxThread__avatar commentInboxThread__avatar--fallback">{{ avatarFallbackOf(notification) }}</div>
<div class="commentInboxThread__content">
<div class="commentInboxThread__meta">
<span class="commentInboxThread__name">{{ notification.actorName }}</span>
<span class="commentInboxThread__separator">·</span>
<span class="commentInboxThread__date">{{ formatDate(notification.createdAt) }}</span>
</div>
<div class="commentInboxThread__text">{{ notification.commentContent }}</div>
</div>
</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 {
min-width: 220px;
}
.commentInboxToolbar__action {
min-width: 148px;
}
.commentInboxPanel {
padding: 18px;
border-radius: 22px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
}
.commentInboxEmpty {
color: var(--theme-text-muted);
}
.commentInboxList {
display: grid;
gap: 14px;
}
.commentInboxCard {
border-radius: 20px;
border: 1px solid var(--theme-border);
background: var(--theme-surface);
overflow: hidden;
}
.commentInboxCard--unread {
border-color: color-mix(in srgb, var(--theme-accent) 28%, var(--theme-border));
background: color-mix(in srgb, var(--theme-surface) 92%, var(--theme-accent) 8%);
}
.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: start;
}
.commentInboxCard__aside {
min-width: 0;
display: grid;
gap: 10px;
}
.commentInboxCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
align-self: start;
flex: 0 0 auto;
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__targetTitle {
font-size: 15px;
font-weight: 800;
line-height: 1.45;
word-break: break-word;
}
.commentInboxCard__targetMeta {
color: var(--theme-text-faint);
font-size: 12px;
font-weight: 700;
}
.commentInboxCard__titleRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.commentInboxCard__title {
font-size: 17px;
font-weight: 900;
}
.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;
justify-content: center;
min-height: 28px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--theme-accent) 22%, var(--theme-border));
background: color-mix(in srgb, var(--theme-accent) 18%, var(--theme-surface-soft));
color: var(--theme-text);
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.commentInboxCard__badge:disabled {
cursor: default;
opacity: 0.7;
}
.commentInboxCard__thread {
margin-top: 14px;
display: grid;
gap: 12px;
}
.commentInboxThread {
display: grid;
gap: 8px;
}
.commentInboxThread__label {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--theme-text-faint);
}
.commentInboxThread__body {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px;
border-radius: 18px;
background: var(--theme-pill-bg);
}
.commentInboxThread--accent .commentInboxThread__body {
background: color-mix(in srgb, var(--theme-accent) 10%, var(--theme-pill-bg));
}
.commentInboxThread__avatar {
width: 36px;
height: 36px;
border-radius: 999px;
object-fit: cover;
border: 1px solid var(--theme-avatar-border);
background: var(--theme-border);
flex: 0 0 auto;
}
.commentInboxThread__avatar--fallback {
display: grid;
place-items: center;
font-size: 13px;
font-weight: 900;
}
.commentInboxThread__content {
min-width: 0;
flex: 1 1 auto;
}
.commentInboxThread__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
color: var(--theme-text-muted);
font-size: 13px;
}
.commentInboxThread__name {
color: var(--theme-text);
font-weight: 800;
}
.commentInboxThread__separator {
color: var(--theme-text-faint);
}
.commentInboxThread__date {
color: var(--theme-text-faint);
}
.commentInboxThread__text {
margin-top: 8px;
color: var(--theme-text);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.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);
}
.toggleSwitch {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
cursor: pointer;
user-select: none;
}
.toggleSwitch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.toggleSwitch__track {
position: relative;
width: 42px;
height: 24px;
border-radius: 999px;
background: var(--theme-surface-soft-3);
border: 1px solid var(--theme-border-strong);
transition: background 160ms ease, border-color 160ms ease;
flex: 0 0 auto;
}
.toggleSwitch__thumb {
position: absolute;
top: 50%;
left: 3px;
width: 16px;
height: 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
transform: translateY(-50%);
transition: transform 180ms ease;
}
:root[data-theme='light'] .toggleSwitch__thumb {
background: rgba(15, 23, 42, 0.82);
}
.toggleSwitch__label {
min-width: 0;
color: var(--theme-text-muted);
font-size: 14px;
font-weight: 700;
}
.toggleSwitch input:checked ~ .toggleSwitch__track {
background: rgba(96, 165, 250, 0.34);
border-color: rgba(96, 165, 250, 0.46);
}
.toggleSwitch input:checked ~ .toggleSwitch__track .toggleSwitch__thumb {
transform: translate(18px, -50%);
}
@media (max-width: 860px) {
.commentInboxToolbar {
flex-direction: column;
align-items: stretch;
}
.commentInboxPanel {
padding: 20px;
}
.commentInboxCard__body {
grid-template-columns: 1fr;
}
.commentInboxCard__titleRow {
flex-direction: column;
}
.commentInboxThread__body {
padding: 12px;
}
}
</style>

View File

@@ -1,17 +1,20 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 route = useRoute()
const toast = useToast()
const favorites = ref([])
const query = ref('')
const sort = ref('favorited')
const isListView = computed(() => route.query.view === 'list')
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim() : ''))
function fmt(ts) {
return new Date(ts).toLocaleDateString(undefined, {
@@ -30,7 +33,7 @@ function avatarSrcOf(tierList) {
}
function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
}
function tierListThumbnailUrl(tierList) {
@@ -52,6 +55,7 @@ function openTierList(tierList) {
}
onMounted(loadFavorites)
watch([query, sort], loadFavorites)
</script>
<template>
@@ -63,20 +67,18 @@ onMounted(loadFavorites)
<div class="pageHead__desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 있어요.</div>
</div>
<div class="pageHead__aside toolbar">
<input v-model="query" class="input" placeholder="제목, 주제, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
<select v-model="sort" class="select" @change="loadFavorites">
<select v-model="sort" class="select">
<option value="favorited">즐겨찾기한 </option>
<option value="updated">최신 업데이트순</option>
<option value="favorites">인기순</option>
</select>
<button class="btn" @click="loadFavorites">검색</button>
</div>
</div>
<div v-if="favorites.length === 0" class="empty">즐겨찾기한 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
<button class="boardCard__body" @click="openTierList(tierList)">
<div v-else class="list" :class="{ 'list--table': isListView }">
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(tierList)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" draggable="false" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
@@ -115,15 +117,6 @@ onMounted(loadFavorites)
background: var(--theme-surface-soft);
color: var(--theme-text);
}
.btn {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft-2);
color: var(--theme-text);
font-weight: 800;
cursor: pointer;
}
.empty {
opacity: 0.76;
}
@@ -132,7 +125,12 @@ onMounted(loadFavorites)
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.list--table {
grid-template-columns: 1fr;
}
.boardCard {
position: relative;
min-width: 0;
border-radius: 22px;
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
@@ -148,6 +146,7 @@ onMounted(loadFavorites)
background: var(--theme-card-bg-hover);
}
.boardCard__body {
min-width: 0;
border: 0;
background: transparent;
color: inherit;
@@ -155,8 +154,11 @@ onMounted(loadFavorites)
text-align: left;
cursor: pointer;
display: grid;
width: 100%;
overflow: hidden;
}
.boardCard__thumbWrap {
min-width: 0;
width: 100%;
aspect-ratio: 16 / 9;
padding: 14px 14px 0;
@@ -181,16 +183,35 @@ onMounted(loadFavorites)
font-weight: 700;
}
.boardCard__head {
min-width: 0;
padding: 16px 18px 18px;
display: grid;
gap: 6px;
overflow: hidden;
}
.boardCard--list .boardCard__head {
align-content: center;
padding: 16px 18px 16px 0;
}
.boardCard__body--list {
grid-template-columns: 200px minmax(0, 1fr);
align-items: stretch;
}
.boardCard__body--list .boardCard__thumbWrap {
height: 100%;
padding: 14px;
}
.boardCard__body--list .boardCard__thumb,
.boardCard__body--list .boardCard__thumbPlaceholder {
min-height: 100%;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.boardCard__metaRow {
@@ -200,17 +221,23 @@ onMounted(loadFavorites)
min-width: 0;
font-weight: 800;
font-size: 18px;
white-space: nowrap;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.boardCard__author {
min-width: 0;
max-width: 100%;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
opacity: 0.86;
overflow: hidden;
}
.boardCard__authorName {
min-width: 0;
@@ -236,9 +263,13 @@ onMounted(loadFavorites)
.boardCard__date,
.favoriteStat {
flex: 0 0 auto;
min-width: 0;
max-width: 100%;
font-size: 13px;
color: var(--theme-text-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__date {
@@ -258,12 +289,19 @@ onMounted(loadFavorites)
.list {
grid-template-columns: 1fr;
}
.boardCard__body--list {
grid-template-columns: 1fr;
}
.boardCard--list .boardCard__head {
padding: 16px 18px 18px;
}
.toolbar {
width: 100%;
}
.input,
.select,
.btn {
.select {
width: 100%;
}
}

View File

@@ -1,19 +1,22 @@
<script setup>
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 route = useRoute()
const toast = useToast()
const tierLists = ref([])
const query = ref('')
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim() : ''))
const isLoading = ref(false)
const error = ref('')
const brokenThumbnailIds = ref({})
const isListView = computed(() => route.query.view === 'list')
watch(error, (message) => {
if (!message) return
@@ -38,7 +41,7 @@ function avatarSrcOf(tierList) {
}
function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
}
function tierListThumbnailUrl(tierList) {
@@ -75,6 +78,7 @@ function openAuthorProfile(tierList) {
}
onMounted(loadFollowingFeed)
watch(query, loadFollowingFeed)
</script>
<template>
@@ -85,18 +89,14 @@ onMounted(loadFollowingFeed)
<h2 class="pageHead__title">팔로우 피드</h2>
<div class="pageHead__desc">팔로우한 작성자가 공개한 티어표를 최신 업데이트순으로 모아봅니다.</div>
</div>
<div class="pageHead__aside toolbar">
<input v-model="query" class="input" placeholder="제목, 주제, 작성자 검색" @keydown.enter.prevent="loadFollowingFeed" />
<button class="btn" :disabled="isLoading" @click="loadFollowingFeed">{{ isLoading ? '검색중...' : '검색' }}</button>
</div>
</section>
<section class="panel">
<div v-if="isLoading" class="empty">팔로우 피드를 불러오고 있어요.</div>
<div v-else-if="tierLists.length === 0" class="empty">아직 팔로우한 작성자의 공개 티어표가 없어요.</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 v-else class="list" :class="{ 'list--table': isListView }">
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" type="button" @click="openTierList(tierList)">
<div class="boardCard__thumbWrap">
<img
v-if="tierListThumbnailUrl(tierList)"
@@ -143,28 +143,6 @@ onMounted(loadFollowingFeed)
border-radius: 0;
padding: 0;
}
.toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.input {
min-width: 240px;
padding: 11px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
.btn {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft-2);
color: var(--theme-text);
font-weight: 800;
cursor: pointer;
}
.empty {
opacity: 0.75;
}
@@ -173,6 +151,9 @@ onMounted(loadFollowingFeed)
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.list--table {
grid-template-columns: 1fr;
}
.boardCard {
min-width: 0;
border-radius: 22px;
@@ -199,6 +180,19 @@ onMounted(loadFollowingFeed)
color: inherit;
padding: 0;
display: grid;
overflow: hidden;
}
.boardCard__body--list {
grid-template-columns: 200px minmax(0, 1fr);
align-items: stretch;
}
.boardCard__body--list .boardCard__thumbWrap {
height: 100%;
padding: 14px;
}
.boardCard__body--list .boardCard__thumb,
.boardCard__body--list .boardCard__thumbPlaceholder {
min-height: 100%;
}
.boardCard__thumbWrap {
width: 100%;
@@ -229,6 +223,11 @@ onMounted(loadFollowingFeed)
padding: 16px 18px 0;
display: grid;
gap: 6px;
overflow: hidden;
}
.boardCard--list .boardCard__head {
align-content: center;
padding: 16px 18px 16px 0;
}
.boardCard__titleRow {
min-width: 0;
@@ -320,9 +319,13 @@ onMounted(loadFollowingFeed)
grid-template-columns: 1fr;
}
.input {
min-width: 0;
width: 100%;
.boardCard__body--list {
grid-template-columns: 1fr;
}
.boardCard--list .boardCard__head {
padding: 16px 18px 0;
}
}
</style>

View File

@@ -2,123 +2,167 @@
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 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 isListView = computed(() => route.query.view === 'list')
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 {
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 서버가 실행 중인지 확인해주세요.'
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>
<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>
<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 class="libraryEmpty">{{ query ? '검색어에 맞는 주제 템플릿이 없어요.' : '표시할 주제 템플릿이 없어요.' }}</div>
<div class="featuredHead__count">{{ featuredTierLists.length }}</div>
</div>
<div class="list" :class="{ 'list--table': isListView }">
<article v-for="tierList in featuredTierLists" :key="`featured-${tierList.id}`" class="boardCard boardCard--featured" :class="{ 'boardCard--list': isListView }">
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" 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" :class="{ 'list--table': isListView }">
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" 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;
@@ -128,143 +172,231 @@ function templateThumbUrl(template) {
background: var(--theme-danger-bg);
color: var(--theme-text);
}
.pageHead__searchState {
margin-top: 8px;
.panel {
background: transparent;
border-radius: 0;
padding: 0;
}
.featuredPanel {
margin-bottom: 28px;
padding: 24px;
border-radius: 28px;
border: 1px solid var(--theme-card-border);
background: linear-gradient(180deg, var(--theme-surface-soft) 0%, var(--theme-surface) 100%);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
}
.featuredHead {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.featuredHead__eyebrow,
.sectionLabel {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--theme-text-faint);
}
.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);
}
.libraryCard {
position: relative;
text-align: left;
padding: 14px;
.sectionLabel {
margin-bottom: 14px;
}
.empty {
opacity: 0.76;
}
.list {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.list--table {
grid-template-columns: 1fr;
}
.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--list .boardCard__head {
align-content: center;
padding: 16px 18px 16px 0;
}
.boardCard__body--list {
grid-template-columns: 200px minmax(0, 1fr);
align-items: stretch;
}
.boardCard__body--list .boardCard__thumbWrap {
height: 100%;
padding: 14px;
}
.boardCard__body--list .boardCard__thumb,
.boardCard__body--list .boardCard__thumbPlaceholder {
min-height: 100%;
}
.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 {
.list {
grid-template-columns: minmax(0, 1fr);
}
.boardCard__body--list {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.libraryGrid {
grid-template-columns: 1fr;
.boardCard--list .boardCard__head {
padding: 16px 18px 18px;
}
}
</style>

View File

@@ -1,16 +1,27 @@
<script setup>
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 route = useRoute()
const toast = useToast()
const myLists = ref([])
const error = ref('')
const brokenThumbnailIds = ref({})
const isListView = computed(() => route.query.view === 'list')
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
const filteredMyLists = computed(() => {
if (!query.value) return myLists.value
return myLists.value.filter((tierList) => {
const haystack = `${tierList.title || ''} ${tierList.topicName || ''} ${tierList.authorName || ''}`.toLowerCase()
return haystack.includes(query.value)
})
})
watch(error, (message) => {
if (!message) return
@@ -35,7 +46,7 @@ function avatarSrcOf(tierList) {
}
function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
}
function tierListThumbnailUrl(tierList) {
@@ -69,15 +80,16 @@ function openList(t) {
<div class="pageHead__main">
<div class="pageHead__eyebrow">Tier Lists</div>
<h2 class="pageHead__title">나의 티어표</h2>
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</div>
<div class="pageHead__desc">직접 저장한 티어표를 관리할 있는 페이지입니다.</div>
</div>
</section>
<section class="panel">
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="t in myLists" :key="t.id" class="boardCard">
<button class="boardCard__body" @click="openList(t)">
<div v-else-if="filteredMyLists.length === 0" class="empty">검색어에 맞는 티어표가 없어요.</div>
<div v-else class="list" :class="{ 'list--table': isListView }">
<article v-for="t in filteredMyLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openList(t)">
<div class="boardCard__thumbWrap">
<img
v-if="tierListThumbnailUrl(t)"
@@ -123,6 +135,9 @@ function openList(t) {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.list--table {
grid-template-columns: 1fr;
}
.boardCard {
min-width: 0;
border-radius: 22px;
@@ -195,6 +210,22 @@ function openList(t) {
gap: 8px;
overflow: hidden;
}
.boardCard--list .boardCard__head {
align-content: center;
padding: 16px 18px 16px 0;
}
.boardCard__body--list {
grid-template-columns: 200px minmax(0, 1fr);
align-items: stretch;
}
.boardCard__body--list .boardCard__thumbWrap {
height: 100%;
padding: 14px;
}
.boardCard__body--list .boardCard__thumb,
.boardCard__body--list .boardCard__thumbPlaceholder {
min-height: 100%;
}
.boardCard__titleRow,
.boardCard__metaRow {
min-width: 0;
@@ -267,5 +298,13 @@ function openList(t) {
.list {
grid-template-columns: 1fr;
}
.boardCard__body--list {
grid-template-columns: 1fr;
}
.boardCard--list .boardCard__head {
padding: 16px 18px 18px;
}
}
</style>

View File

@@ -1,11 +1,16 @@
<script setup>
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { api } from '../lib/api'
import SvgIcon from '../components/SvgIcon.vue'
import { displayInitialFrom } from '../lib/display'
import { homePath, loginPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast'
import keyIcon from '../assets/icons/key.svg'
import logoutIcon from '../assets/icons/logout.svg'
import openInNewIcon from '../assets/icons/open_in_new.svg'
const router = useRouter()
const auth = useAuthStore()
@@ -25,6 +30,11 @@ const nextPassword = ref('')
const nextPasswordConfirm = ref('')
const currentPasswordError = ref('')
const nextPasswordError = ref('')
const activeModal = ref('')
const nicknameDraft = ref('')
const nicknameDraftError = ref('')
const nicknameInput = ref(null)
const currentPasswordInput = ref(null)
watch(error, (message) => {
if (!message) return
@@ -32,6 +42,17 @@ watch(error, (message) => {
error.value = ''
})
watch(
() => auth.user,
(user) => {
nickname.value = user?.nickname || ''
if (!activeModal.value || activeModal.value !== 'nickname') {
nicknameDraft.value = user?.nickname || ''
}
},
{ immediate: true }
)
const avatarUrl = computed(() => {
if (previewUrl.value) return previewUrl.value
if (removeAvatar.value) return ''
@@ -40,12 +61,17 @@ const avatarUrl = computed(() => {
})
const authReady = computed(() => auth.hydrated)
const displayInitial = computed(() => {
const email = auth.user?.email || 'U'
return email[0].toUpperCase()
const displayInitial = computed(() => displayInitialFrom(auth.user?.nickname, auth.user?.email, 'U'))
const authEmail = computed(() => auth.user?.email || '')
const nicknameUpdatedAt = computed(() => Number(auth.user?.nicknameUpdatedAt || 0))
const nicknameChangeAvailableAt = computed(() => Number(auth.user?.nicknameChangeAvailableAt || 0))
const nicknameChangeIntervalMs = computed(() => Number(auth.user?.nicknameChangeIntervalMs || 0))
const nicknameChangeIntervalLabel = computed(() => String(auth.user?.nicknameChangeIntervalLabel || '2주'))
const canChangeNicknameNow = computed(() => {
if (nicknameChangeIntervalMs.value <= 0) return true
if (!nicknameUpdatedAt.value) return true
return Date.now() >= nicknameChangeAvailableAt.value
})
onMounted(async () => {
if (!auth.hydrated) await auth.refresh()
if (!auth.user) {
@@ -53,13 +79,37 @@ onMounted(async () => {
return
}
nickname.value = auth.user?.nickname || ''
nicknameDraft.value = auth.user?.nickname || ''
removeAvatar.value = false
window.addEventListener('keydown', handleWindowKeydown)
})
onBeforeUnmount(() => {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
window.removeEventListener('keydown', handleWindowKeydown)
})
function formatDateTime(timestamp) {
if (!timestamp) return '제한 없음'
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(timestamp)
}
function handleWindowKeydown(event) {
if (event.key !== 'Escape' || !activeModal.value) return
closeActiveModal()
}
function closeActiveModal() {
if (activeModal.value === 'nickname') closeNicknameModal()
if (activeModal.value === 'password') closePasswordModal()
}
function openAvatarPicker() {
fileInput.value?.click()
}
@@ -73,10 +123,12 @@ function onAvatarChange(e) {
avatarFile.value = file
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = URL.createObjectURL(file)
saveAvatarChanges()
}
function clearProfileFieldErrors() {
nicknameError.value = ''
nicknameDraftError.value = ''
}
function clearPasswordFieldErrors() {
@@ -93,22 +145,24 @@ function clearAvatar() {
previewUrl.value = ''
}
if (fileInput.value) fileInput.value.value = ''
saveAvatarChanges()
}
async function saveProfile() {
async function saveProfile(nextNickname = nickname.value) {
error.value = ''
clearProfileFieldErrors()
if (nickname.value.trim().length < 2) {
if (String(nextNickname || '').trim().length < 2) {
nicknameError.value = '닉네임은 2자 이상 입력해주세요.'
nicknameDraftError.value = nicknameError.value
error.value = '닉네임을 확인해주세요.'
return
return false
}
saving.value = true
try {
const fd = new FormData()
fd.append('nickname', nickname.value)
fd.append('nickname', String(nextNickname || '').trim())
if (avatarFile.value) fd.append('avatar', avatarFile.value)
if (removeAvatar.value) fd.append('removeAvatar', '1')
@@ -125,6 +179,8 @@ async function saveProfile() {
throw requestError
}
auth.user = data.user
nickname.value = data.user?.nickname || ''
nicknameDraft.value = data.user?.nickname || ''
avatarFile.value = null
removeAvatar.value = false
if (previewUrl.value) {
@@ -133,22 +189,40 @@ async function saveProfile() {
}
if (fileInput.value) fileInput.value.value = ''
toast.success('프로필을 저장했어요.')
return true
} catch (e2) {
const code = e2?.data?.error
if (code === 'nickname_taken') {
nicknameError.value = '이미 사용 중인 닉네임입니다.'
nicknameDraftError.value = nicknameError.value
error.value = '닉네임이 이미 사용 중이에요.'
} else if (code === 'nickname_reserved') {
nicknameError.value = '운영자/관리자처럼 혼동될 수 있는 닉네임은 사용할 수 없어요.'
nicknameDraftError.value = nicknameError.value
error.value = '사용할 수 없는 닉네임이에요.'
} else if (code === 'nickname_change_locked') {
const intervalLabel = String(e2?.data?.nicknameChangeIntervalLabel || nicknameChangeIntervalLabel.value || '2주')
nicknameDraftError.value = `닉네임은 ${intervalLabel}에 한 번만 바꿀 수 있어요. ${formatDateTime(e2?.data?.nicknameChangeAvailableAt)} 이후 다시 시도해주세요.`
error.value = '닉네임 변경 가능 시점이 아직 아니에요.'
} else {
error.value = '프로필 저장에 실패했어요.'
}
return false
} finally {
saving.value = false
}
}
async function saveNickname() {
const ok = await saveProfile(nicknameDraft.value)
if (!ok) return
closeNicknameModal()
}
async function saveAvatarChanges() {
await saveProfile(nickname.value)
}
async function savePassword() {
error.value = ''
clearPasswordFieldErrors()
@@ -172,10 +246,8 @@ async function savePassword() {
nextPassword: nextPassword.value,
})
auth.user = data.user
currentPassword.value = ''
nextPassword.value = ''
nextPasswordConfirm.value = ''
toast.success('비밀번호를 변경했어요.')
closePasswordModal()
} catch (e2) {
if (e2?.data?.error === 'invalid_current_password') {
currentPasswordError.value = '현재 비밀번호가 일치하지 않아요.'
@@ -188,6 +260,41 @@ async function savePassword() {
}
}
function openNicknameModal() {
clearProfileFieldErrors()
nicknameDraft.value = nickname.value
activeModal.value = 'nickname'
nextTick(() => {
nicknameInput.value?.focus()
nicknameInput.value?.select?.()
})
}
function closeNicknameModal() {
activeModal.value = ''
nicknameDraftError.value = ''
nicknameDraft.value = nickname.value
}
function openPasswordModal() {
clearPasswordFieldErrors()
currentPassword.value = ''
nextPassword.value = ''
nextPasswordConfirm.value = ''
activeModal.value = 'password'
nextTick(() => {
currentPasswordInput.value?.focus()
})
}
function closePasswordModal() {
activeModal.value = ''
clearPasswordFieldErrors()
currentPassword.value = ''
nextPassword.value = ''
nextPasswordConfirm.value = ''
}
async function logout() {
await auth.logout()
toast.success('로그아웃했어요.')
@@ -201,7 +308,7 @@ async function logout() {
<div class="pageHead__main">
<div class="pageHead__eyebrow">Account</div>
<h2 class="pageHead__title">설정</h2>
<div class="pageHead__desc">계정 정보를 간결하게 정리하고, 프로필 이미지를 클릭해서 바로 변경할 있어.</div>
<div class="pageHead__desc">닉네임은 변경은 쿨타임이 있으니 신중하게 설정해주세.</div>
</div>
</header>
@@ -210,116 +317,183 @@ async function logout() {
</section>
<section v-else-if="auth.user" class="settingsScreen">
<div class="settingsGrid">
<article class="settingsPanel">
<div class="settingsIdentity">
<div class="avatarButtonWrap">
<button class="avatarButton" type="button" @click="openAvatarPicker">
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" draggable="false" />
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
<div class="avatarButton__overlay">
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
<div class="settingsDeck">
<article class="settingsThemeCard settingsThemeCard--hero">
<div class="settingsThemeCard__eyebrow">Profile</div>
<div class="settingsThemeCard__title">기본 계정 정보</div>
<div class="settingsHero">
<div class="settingsAvatarColumn">
<div class="avatarButtonWrap">
<button class="avatarButton" type="button" @click="openAvatarPicker">
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" draggable="false" />
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
<div class="avatarButton__overlay">
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
</div>
</button>
<button
v-if="avatarUrl || previewUrl"
class="avatarButton__remove"
type="button"
aria-label="프로필 이미지 삭제"
@click="clearAvatar"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
<input ref="fileInput" class="hiddenInput" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
</div>
<div class="settingsHero__body">
<div class="settingsSummaryList">
<div class="settingsSummaryItem">
<div class="settingsSummaryItem__label">닉네임</div>
<div class="settingsSummaryItem__valueRow">
<div class="settingsSummaryItem__value">{{ nickname || '미설정' }}</div>
<button
v-if="canChangeNicknameNow"
class="settingsIconAction"
type="button"
aria-label="닉네임 변경"
title="닉네임 변경"
@click="openNicknameModal"
>
<SvgIcon :src="openInNewIcon" :size="18" />
</button>
</div>
</div>
</button>
<button
v-if="avatarUrl || previewUrl"
class="avatarButton__remove"
type="button"
aria-label="프로필 이미지 삭제"
@click="clearAvatar"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
<div class="settingsSummaryItem">
<div class="settingsSummaryItem__label">ID (이메일)</div>
<div class="settingsSummaryItem__valueRow">
<div class="settingsSummaryItem__value">{{ authEmail }}</div>
</div>
</div>
</div>
</div>
<div class="identityMeta">
<div class="identityMeta__eyebrow">Profile Photo</div>
<div class="identityMeta__title">프로필 정보</div>
<div class="identityMeta__desc">아바타와 닉네임을 정리하고, 현재 계정 이메일을 확인할 있어요.</div>
</div>
<input ref="fileInput" class="hiddenInput" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
</div>
<div class="settingsFields">
<label class="field">
<span class="field__label">닉네임</span>
<input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
<span v-if="nicknameError" class="field__error">{{ nicknameError }}</span>
<span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40</span>
</label>
<label class="field">
<span class="field__label">이메일</span>
<input :value="auth.user.email" class="field__input field__input--readonly" readonly />
<span class="field__hint">현재 로그인에 사용 중인 계정입니다.</span>
</label>
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
</div>
<div class="settingsActions">
<button class="primaryAction" :disabled="saving" @click="saveProfile">{{ saving ? '저장 중...' : '변경사항 저장' }}</button>
<button class="secondaryAction" type="button" @click="logout">로그아웃</button>
</div>
</article>
<article class="settingsPanel">
<div class="identityMeta__eyebrow">Password</div>
<div class="identityMeta__title">비밀번호 변경</div>
<div class="identityMeta__desc">현재 비밀번호를 확인한 비밀번호로 바꿀 있어요.</div>
<div class="settingsFields settingsFields--password">
<label class="field">
<span class="field__label">현재 비밀번호</span>
<input
v-model="currentPassword"
class="field__input"
type="password"
autocomplete="current-password"
maxlength="120"
placeholder="현재 비밀번호"
/>
<span v-if="currentPasswordError" class="field__error">{{ currentPasswordError }}</span>
</label>
<label class="field">
<span class="field__label"> 비밀번호</span>
<input
v-model="nextPassword"
class="field__input"
type="password"
autocomplete="new-password"
maxlength="120"
placeholder="새 비밀번호"
/>
<span v-if="nextPasswordError" class="field__error">{{ nextPasswordError }}</span>
<span class="field__hint">6~120 입력 가능 · {{ nextPassword.length }}/120</span>
</label>
<label class="field">
<span class="field__label"> 비밀번호 확인</span>
<input
v-model="nextPasswordConfirm"
class="field__input"
type="password"
autocomplete="new-password"
maxlength="120"
placeholder="새 비밀번호 확인"
/>
</label>
</div>
<div class="settingsActions">
<button class="primaryAction" type="button" :disabled="passwordSaving" @click="savePassword">
{{ passwordSaving ? '변경 중...' : '비밀번호 변경' }}
<article class="settingsThemeCard settingsThemeCard--compact">
<div class="settingsThemeCard__eyebrow">Security</div>
<div class="settingsThemeCard__title">보안 설정</div>
<div class="settingsCompactRow">
<div>
<div class="settingsCompactRow__title">비밀번호</div>
<div class="settingsCompactRow__desc">현재 비밀번호 확인 비밀번호로 변경합니다.</div>
</div>
<button class="settingsIconAction settingsIconAction--solid" type="button" aria-label="비밀번호 변경" title="비밀번호 변경" @click="openPasswordModal">
<SvgIcon :src="keyIcon" :size="18" />
</button>
</div>
</article>
<article class="settingsThemeCard settingsThemeCard--compact">
<div class="settingsThemeCard__eyebrow">Session</div>
<div class="settingsThemeCard__title">계정 상태</div>
<div class="settingsCompactList">
<div class="settingsCompactRow">
<div>
<div class="settingsCompactRow__title">가입일</div>
<div class="settingsCompactRow__desc">{{ formatDateTime(auth.user.createdAt) }}</div>
</div>
</div>
<div class="settingsCompactRow">
<div>
<div class="settingsCompactRow__title">로그아웃</div>
<div class="settingsCompactRow__desc"> 기기에서 현재 세션을 종료합니다.</div>
</div>
<button class="settingsIconAction settingsIconAction--solid" type="button" aria-label="로그아웃" title="로그아웃" @click="logout">
<SvgIcon :src="logoutIcon" :size="18" />
</button>
</div>
</div>
</article>
</div>
</section>
<div v-if="activeModal === 'nickname'" class="settingsModalOverlay" @click.self="closeNicknameModal">
<div class="settingsModalCard" role="dialog" aria-modal="true" aria-labelledby="nicknameModalTitle">
<div id="nicknameModalTitle" class="settingsModalCard__title">닉네임 변경</div>
<div class="settingsModalCard__desc">
닉네임은 공개 티어표의 작성자 이름으로 보이며,
{{ nicknameChangeIntervalMs > 0 ? `한 번 변경하면 ${nicknameChangeIntervalLabel}이 지나야 다시 바꿀 수 있어요.` : '현재는 변경 주기 제한이 없습니다.' }}
</div>
<label class="field">
<span class="field__label"> 닉네임</span>
<input ref="nicknameInput" v-model="nicknameDraft" class="field__input" maxlength="40" placeholder="표시용 닉네임" />
<span v-if="nicknameDraftError" class="field__error">{{ nicknameDraftError }}</span>
<span class="field__hint">{{ nicknameDraft.length }}/40</span>
</label>
<div class="settingsModalCard__actions">
<button class="btn btn--ghost" type="button" @click="closeNicknameModal">취소</button>
<button class="btn btn--save" type="button" :disabled="saving || !canChangeNicknameNow" @click="saveNickname">
{{ saving ? '저장 중...' : '닉네임 저장' }}
</button>
</div>
</div>
</div>
<div v-if="activeModal === 'password'" class="settingsModalOverlay" @click.self="closePasswordModal">
<div class="settingsModalCard" role="dialog" aria-modal="true" aria-labelledby="passwordModalTitle">
<div id="passwordModalTitle" class="settingsModalCard__title">비밀번호 변경</div>
<div class="settingsModalCard__desc">현재 비밀번호를 확인한 비밀번호로 바꿉니다.</div>
<div class="settingsModalFields">
<label class="field">
<span class="field__label">현재 비밀번호</span>
<input
ref="currentPasswordInput"
v-model="currentPassword"
class="field__input"
type="password"
autocomplete="current-password"
maxlength="120"
placeholder="현재 비밀번호"
/>
<span v-if="currentPasswordError" class="field__error">{{ currentPasswordError }}</span>
</label>
<label class="field">
<span class="field__label"> 비밀번호</span>
<input
v-model="nextPassword"
class="field__input"
type="password"
autocomplete="new-password"
maxlength="120"
placeholder="새 비밀번호"
/>
<span v-if="nextPasswordError" class="field__error">{{ nextPasswordError }}</span>
<span class="field__hint">6~120 입력 가능 · {{ nextPassword.length }}/120</span>
</label>
<label class="field">
<span class="field__label"> 비밀번호 확인</span>
<input
v-model="nextPasswordConfirm"
class="field__input"
type="password"
autocomplete="new-password"
maxlength="120"
placeholder="새 비밀번호 확인"
/>
</label>
</div>
<div class="settingsModalCard__actions">
<button class="btn btn--ghost" type="button" @click="closePasswordModal">취소</button>
<button class="btn btn--save" type="button" :disabled="passwordSaving" @click="savePassword">
{{ passwordSaving ? '변경 중...' : '비밀번호 변경' }}
</button>
</div>
</div>
</div>
</section>
</template>
@@ -340,48 +514,82 @@ async function logout() {
font-size: 15px;
}
.settingsIdentity {
.settingsDeck {
width: min(100%, 1040px);
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 24px;
align-items: center;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.settingsGrid {
.settingsThemeCard {
display: grid;
grid-template-columns: minmax(360px, 1fr) minmax(360px, 1fr);
gap: 12px;
min-width: 0;
padding: 22px;
border-radius: 24px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
}
.settingsThemeCard--hero {
grid-column: 1 / -1;
}
.settingsThemeCard--compact {
grid-template-rows: auto auto minmax(0, 1fr);
}
.settingsThemeCard__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--theme-text-soft);
}
.settingsThemeCard__title {
font-size: 24px;
font-weight: 800;
color: var(--theme-text-strong);
letter-spacing: -0.03em;
}
.settingsThemeCard__desc {
color: var(--theme-text-muted);
line-height: 1.6;
}
.settingsHero {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 24px;
align-items: start;
padding-top: 4px;
}
.settingsPanel {
min-width: 0;
padding: 28px;
border: 1px solid var(--theme-border);
border-radius: 28px;
background: var(--theme-surface);
box-shadow: var(--theme-card-shadow);
.settingsAvatarColumn {
display: grid;
justify-items: center;
gap: 12px;
}
.avatarButtonWrap {
position: relative;
width: 120px;
height: 120px;
width: 132px;
height: 132px;
}
.avatarButton {
position: relative;
width: 120px;
height: 120px;
width: 132px;
height: 132px;
border: 1px solid var(--theme-border-strong);
border-radius: 9999px;
background: var(--theme-pill-bg);
background: var(--theme-surface);
color: var(--theme-text);
overflow: hidden;
cursor: pointer;
display: grid;
place-items: center;
box-shadow: var(--theme-card-shadow);
}
.avatarButton__image {
@@ -403,7 +611,7 @@ async function logout() {
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
font-size: 12px;
font-weight: 700;
color: var(--theme-text);
color: #fff;
}
.avatarButton__remove {
@@ -412,16 +620,13 @@ async function logout() {
right: 0;
width: 30px;
height: 30px;
border: 0;
border: 1px solid var(--theme-border-strong);
border-radius: 999px;
background: var(--theme-shell-bg);
background: var(--theme-surface);
color: var(--theme-text);
display: grid;
place-items: center;
cursor: pointer;
z-index: 2;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(10px);
}
.avatarButton__remove svg {
@@ -438,37 +643,92 @@ async function logout() {
color: var(--theme-accent-text);
}
.identityMeta {
display: grid;
gap: 6px;
}
.identityMeta__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--theme-text-soft);
}
.identityMeta__title {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
}
.identityMeta__desc {
color: var(--theme-text-muted);
line-height: 1.6;
}
.hiddenInput {
display: none;
}
.settingsFields {
.settingsHero__body {
display: grid;
gap: 20px;
gap: 16px;
}
.settingsSummaryList {
display: grid;
gap: 12px;
}
.settingsSummaryItem {
display: grid;
gap: 6px;
padding: 16px 18px;
border-radius: 18px;
border: 1px solid var(--theme-border);
background: var(--theme-surface);
}
.settingsSummaryItem__label {
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--theme-text-soft);
}
.settingsSummaryItem__value {
font-size: 18px;
font-weight: 700;
color: var(--theme-text-strong);
word-break: break-word;
}
.settingsSummaryItem__valueRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.settingsSummaryItem__meta {
font-size: 13px;
color: var(--theme-text-muted);
line-height: 1.6;
}
.settingsActionRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.settingsCompactList {
display: grid;
gap: 10px;
height: 100%;
}
.settingsCompactRow {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
min-height: 100%;
padding: 16px 18px;
border-radius: 18px;
border: 1px solid var(--theme-border);
background: var(--theme-surface);
}
.settingsCompactRow__title {
font-size: 16px;
font-weight: 700;
color: var(--theme-text-strong);
}
.settingsCompactRow__desc {
margin-top: 4px;
color: var(--theme-text-muted);
line-height: 1.6;
word-break: keep-all;
}
.field {
@@ -483,22 +743,18 @@ async function logout() {
.field__input {
width: 100%;
padding: 14px 0;
border: 0;
border-bottom: 1px solid var(--theme-border-strong);
background: transparent;
padding: 14px 16px;
border: 1px solid var(--theme-border-strong);
border-radius: 16px;
background: var(--theme-surface);
color: var(--theme-text);
outline: none;
font-size: 18px;
font-size: 16px;
letter-spacing: -0.02em;
}
.field__input:focus {
border-bottom-color: rgba(96, 165, 250, 0.9);
}
.field__input--readonly {
color: var(--theme-text-muted);
border-color: rgba(96, 165, 250, 0.9);
}
.field__hint {
@@ -523,59 +779,136 @@ async function logout() {
font-weight: 700;
}
.settingsActions {
display: flex;
gap: 12px;
flex-wrap: wrap;
padding-top: 8px;
}
.settingsFields--password {
padding-top: 24px;
}
.primaryAction,
.secondaryAction {
padding: 12px 18px;
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 18px;
border-radius: 999px;
border: 1px solid transparent;
font-weight: 700;
cursor: pointer;
}
.primaryAction {
border: 1px solid rgba(76, 133, 245, 0.96);
.btn:disabled {
opacity: 0.55;
cursor: default;
}
.btn--save {
border-color: rgba(76, 133, 245, 0.96);
background: var(--theme-accent-bg);
color: var(--theme-accent-text);
}
.secondaryAction {
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
.btn--ghost {
border-color: var(--theme-border-strong);
background: var(--theme-surface);
color: var(--theme-text);
}
@media (max-width: 720px) {
.settingsGrid {
.settingsIconAction {
width: 42px;
height: 42px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface);
color: var(--theme-text);
cursor: pointer;
}
.settingsIconAction:disabled {
opacity: 0.5;
cursor: default;
}
.settingsIconAction--solid {
background: var(--theme-pill-bg);
}
.settingsModalOverlay {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 24px;
background: rgba(8, 11, 18, 0.54);
backdrop-filter: blur(8px);
}
.settingsModalCard {
width: min(100%, 520px);
display: grid;
gap: 16px;
padding: 24px;
border-radius: 24px;
border: 1px solid var(--theme-border);
background: var(--theme-surface);
}
.settingsModalCard__title {
font-size: 22px;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--theme-text-strong);
}
.settingsModalCard__desc {
color: var(--theme-text-muted);
line-height: 1.6;
}
.settingsModalFields {
display: grid;
gap: 14px;
}
.settingsModalCard__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
@media (max-width: 860px) {
.settingsDeck {
grid-template-columns: minmax(0, 1fr);
}
.settingsPanel {
padding: 22px;
border-radius: 24px;
.settingsHero {
grid-template-columns: minmax(0, 1fr);
}
.settingsIdentity {
grid-template-columns: 1fr;
.settingsAvatarColumn {
justify-items: start;
}
}
@media (max-width: 640px) {
.settingsThemeCard {
padding: 18px;
border-radius: 20px;
}
.avatarButtonWrap {
width: 108px;
height: 108px;
.settingsCompactRow {
flex-direction: column;
align-items: flex-start;
}
.avatarButton {
width: 108px;
height: 108px;
.settingsModalOverlay {
padding: 16px;
}
.settingsModalCard {
padding: 20px;
border-radius: 20px;
}
}
</style>

View File

@@ -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) {

View File

@@ -0,0 +1,282 @@
<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 isListView = computed(() => route.query.view === 'list')
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" :class="{ 'libraryGrid--list': isListView }">
<article v-for="template in templates" :key="template.id" class="libraryCard" :class="{ 'libraryCard--list': isListView }">
<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" :class="{ 'libraryCard__main--list': isListView }" 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;
}
.libraryGrid--list {
grid-template-columns: 1fr;
}
.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__main--list {
grid-template-columns: 200px minmax(0, 1fr);
align-items: center;
}
.libraryCard__main--list .libraryCard__thumbWrap {
height: 100%;
}
.libraryCard--list .libraryCard__favorite {
top: 14px;
bottom: auto;
}
.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);
}
.libraryCard__main--list {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -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'
@@ -85,6 +86,7 @@ const itemContextMenu = ref({
y: 0,
itemId: '',
})
const isEditorLoading = ref(true)
let editorLoadToken = 0
const boardEl = ref(null)
@@ -123,13 +125,19 @@ const effectiveAuthorName = computed(() => {
return (authorAccountName.value || '').trim() || 'unknown'
})
const autoGeneratedTitle = ref(createAutoTierListTitle())
const effectiveTitle = computed(() => {
const saveTitle = computed(() => {
const customTitle = (title.value || '').trim()
if (customTitle) return customTitle
if (persistedTierListId.value) return persistedTierListId.value
if (tierListId.value && tierListId.value !== 'new') return tierListId.value
return autoGeneratedTitle.value
})
const displayTitle = computed(() => {
const customTitle = (title.value || '').trim()
if (customTitle) return customTitle
const topicName = (templateName.value || '').trim()
if (topicName) return `${topicName} 티어표`
if (isEditorLoading.value) return ''
return '제목 없는 티어표'
})
const displayThumbnailUrl = computed(() => thumbnailPreviewUrl.value || (thumbnailSrc.value ? resolveItemSrc({ src: thumbnailSrc.value }) : ''))
const untitledWarning = computed(
() =>
@@ -137,11 +145,12 @@ const untitledWarning = computed(
!hasCustomTitle.value &&
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
)
const canFavorite = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
const canFavorite = computed(() => !!auth.user && hasSavedTierList.value && !isNewTierList.value)
const canDuplicate = computed(() => !!auth.user && hasSavedTierList.value)
const canSwitchToViewerMode = computed(() => isOwnTierList.value && hasSavedTierList.value && !previewMode.value)
const canSwitchToEditMode = computed(() => isOwnTierList.value && hasSavedTierList.value && previewMode.value)
const duplicateActionLabel = computed(() => (isOwnTierList.value ? '복사본 만들기' : '내 티어표로 복사'))
const favoriteActionLabel = computed(() => (isFavorited.value ? '즐겨찾기 해제하기' : '즐겨찾기에 추가하기'))
const canOpenAuthorProfile = computed(() => !!ownerId.value && hasSavedTierList.value)
const copiedFromLabel = computed(() => {
if (!sourceTierListId.value) return ''
@@ -159,6 +168,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 || '선택한 주제')))
@@ -363,11 +374,6 @@ function closeItemContextMenu() {
}
}
function scrollWorkspaceBodyToTop() {
const workspaceBody = document.querySelector('.workspaceBody')
workspaceBody?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function updateEditorSidebarMaxHeight() {
if (typeof window === 'undefined' || !sidebarEl.value) return
const bottomGap = 14
@@ -916,7 +922,7 @@ async function downloadImage() {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${effectiveTitle.value.trim()}.png`
a.download = `${(displayTitle.value || 'tier-maker').trim()}.png`
document.body.appendChild(a)
a.click()
a.remove()
@@ -980,7 +986,7 @@ async function uploadPendingThumbnail() {
}
function buildPayload(existingId) {
const finalTitle = effectiveTitle.value
const finalTitle = saveTitle.value
return {
id: existingId || undefined,
topicId: templateId.value,
@@ -1061,12 +1067,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() {
@@ -1092,6 +1106,11 @@ function confirmNavigationDiscard() {
router.push(nextPath)
}
function openTemplateTopic() {
if (!templateId.value) return
requestEditorNavigation(topicPath(templateId.value))
}
function openSourceTierList() {
if (!sourceTierListId.value) return
requestEditorNavigation(editorPath(templateId.value, sourceTierListId.value))
@@ -1254,6 +1273,7 @@ function resetEditorStateForRoute() {
groups.value = normalizeLoadedGroups([], columns.value)
pool.value = []
itemsById.value = {}
templateName.value = ''
title.value = ''
persistedTierListId.value = ''
thumbnailSrc.value = ''
@@ -1290,6 +1310,7 @@ function resetEditorStateForRoute() {
selectedItemId.value = ''
recentDragFinishedAt.value = 0
savedEditorSnapshot.value = ''
isEditorLoading.value = true
closeItemContextMenu()
resetTemplateRequestDrafts()
}
@@ -1297,7 +1318,9 @@ function resetEditorStateForRoute() {
async function loadEditorState() {
const loadToken = ++editorLoadToken
resetEditorStateForRoute()
await auth.refresh()
if (!auth.hydrated) {
await auth.refresh()
}
if (loadToken !== editorLoadToken) return
authorName.value = (auth.user?.nickname || '').trim()
@@ -1354,7 +1377,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
}
@@ -1376,6 +1402,7 @@ async function loadEditorState() {
} catch (e) {
if (loadToken !== editorLoadToken) return
error.value = '티어표를 불러오지 못했어요.'
isEditorLoading.value = false
}
}
@@ -1387,6 +1414,8 @@ async function loadEditorState() {
if (canEdit.value) {
await initSortables()
}
if (loadToken !== editorLoadToken) return
isEditorLoading.value = false
}
watch(
@@ -1394,7 +1423,7 @@ watch(
() => {
loadEditorState()
},
{ immediate: true }
{ immediate: true, flush: 'sync' }
)
onMounted(() => {
@@ -1429,7 +1458,27 @@ onUnmounted(() => {
</script>
<template>
<section v-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
<section v-if="previewMode && isEditorLoading" class="editorSkeleton editorSkeleton--preview">
<header class="pageHead">
<div class="pageHead__main">
<div class="editorSkeleton__line editorSkeleton__line--eyebrow"></div>
<div class="editorSkeleton__line editorSkeleton__line--title"></div>
<div class="editorSkeleton__line editorSkeleton__line--desc"></div>
</div>
</header>
<div class="editorSkeleton__previewCard">
<div class="editorSkeleton__board">
<div class="editorSkeleton__row" v-for="index in 4" :key="`preview-row-${index}`">
<div class="editorSkeleton__label"></div>
<div class="editorSkeleton__cells">
<div class="editorSkeleton__cell" v-for="cellIndex in 3" :key="`preview-cell-${index}-${cellIndex}`"></div>
</div>
</div>
</div>
</div>
</section>
<section v-else-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
<header class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Preview</div>
@@ -1465,6 +1514,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">
@@ -1480,6 +1537,9 @@ onUnmounted(() => {
<button v-if="hasSavedTierList" class="btn btn--ghost viewerSidebar__button" type="button" @click="copyShareUrl">
공유하기
</button>
<button v-if="canFavorite" class="btn btn--save viewerSidebar__button" type="button" :disabled="isFavoriteBusy" @click="toggleFavorite">
{{ favoriteActionLabel }}
</button>
<button v-if="canDuplicate" class="btn btn--save viewerSidebar__button" type="button" @click="duplicateCurrentTierList">
{{ duplicateActionLabel }}
</button>
@@ -1495,6 +1555,29 @@ onUnmounted(() => {
</Teleport>
</section>
<template v-else-if="isEditorLoading">
<section class="editorSkeleton" :style="{ '--thumb-size': `${iconSize}px` }">
<div class="editorSkeleton__head">
<div class="editorSkeleton__line editorSkeleton__line--title"></div>
<div class="editorSkeleton__line editorSkeleton__line--desc"></div>
</div>
<div class="editorSkeleton__body">
<div class="editorSkeleton__board">
<div class="editorSkeleton__toolbar">
<div class="editorSkeleton__chip" v-for="index in 3" :key="`toolbar-${index}`"></div>
</div>
<div class="editorSkeleton__row" v-for="index in 4" :key="`editor-row-${index}`">
<div class="editorSkeleton__label"></div>
<div class="editorSkeleton__cells">
<div class="editorSkeleton__cell" v-for="cellIndex in 3" :key="`editor-cell-${index}-${cellIndex}`"></div>
</div>
</div>
</div>
<div class="editorSkeleton__side"></div>
</div>
</section>
</template>
<template v-else>
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
@@ -1509,7 +1592,7 @@ onUnmounted(() => {
<div v-if="isNavigationConfirmModalOpen" class="modalOverlay" @click.self="closeNavigationConfirmModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="navigationConfirmTitle">
<div id="navigationConfirmTitle" class="modalCard__title">원본 티어표 이동</div>
<div id="navigationConfirmTitle" class="modalCard__title">다른 화면으 이동</div>
<div class="modalCard__desc">
아직 저장하지 않은 수정 내용이 있어요. 이대로 이동하면 현재 변경 내용은 사라집니다.
</div>
@@ -1630,9 +1713,9 @@ onUnmounted(() => {
<button
class="editorMain__title editorMain__titleButton"
type="button"
title="본문을 화면로 이동"
@click="scrollWorkspaceBodyToTop"
@keydown.space.prevent="scrollWorkspaceBodyToTop"
title="이 템플릿 화면로 이동"
@click="openTemplateTopic"
@keydown.space.prevent="openTemplateTopic"
>
{{ templateName || templateId }}
</button>
@@ -1756,16 +1839,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 +1880,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 +1933,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>
@@ -1934,10 +2005,10 @@ onUnmounted(() => {
</div>
<div v-if="canFavorite" class="editorSidebar__section">
<button class="editorSidebar__favorite" :disabled="isFavoriteBusy" @click="toggleFavorite">
<span> 즐겨찾기</span>
<span>{{ favoriteCount }}</span>
<button class="btn btn--save editorSidebar__button editorSidebar__favoriteButton" :disabled="isFavoriteBusy" @click="toggleFavorite">
{{ favoriteActionLabel }}
</button>
<div class="editorSidebar__favoriteMeta">현재 {{ favoriteCount }}명이 티어표를 즐겨찾기했어요.</div>
</div>
<div v-if="canEdit && customItems.length" class="editorSidebar__section">
@@ -2009,6 +2080,127 @@ onUnmounted(() => {
</template>
<style scoped>
.editorSkeleton {
display: grid;
gap: 18px;
}
.editorSkeleton__head {
display: grid;
gap: 10px;
}
.editorSkeleton__body {
display: grid;
grid-template-columns: minmax(0, clamp(680px, 58vw, 960px)) minmax(280px, 1fr);
gap: 16px;
align-items: start;
}
.editorSkeleton__previewCard,
.editorSkeleton__board,
.editorSkeleton__side,
.editorSkeleton__line,
.editorSkeleton__label,
.editorSkeleton__cell,
.editorSkeleton__chip {
position: relative;
overflow: hidden;
}
.editorSkeleton__previewCard,
.editorSkeleton__board,
.editorSkeleton__side {
border-radius: 22px;
border: 1px solid var(--theme-border);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--theme-card-bg) 96%, transparent),
color-mix(in srgb, var(--theme-card-bg-hover) 92%, transparent)
);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.editorSkeleton__previewCard,
.editorSkeleton__board {
padding: 20px;
}
.editorSkeleton__side {
min-height: 540px;
}
.editorSkeleton__previewCard::after,
.editorSkeleton__board::after,
.editorSkeleton__side::after,
.editorSkeleton__line::after,
.editorSkeleton__label::after,
.editorSkeleton__cell::after,
.editorSkeleton__chip::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.08) 48%, transparent 100%);
transform: translateX(-100%);
animation: editorSkeletonShimmer 1.4s ease-in-out infinite;
}
.editorSkeleton__line {
border-radius: 999px;
background: color-mix(in srgb, var(--theme-surface-soft-2) 88%, transparent);
}
.editorSkeleton__line--eyebrow {
width: 92px;
height: 12px;
}
.editorSkeleton__line--title {
width: min(420px, 78%);
height: 34px;
}
.editorSkeleton__line--desc {
width: min(560px, 92%);
height: 14px;
}
.editorSkeleton__toolbar {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.editorSkeleton__chip {
width: 88px;
height: 40px;
border-radius: 12px;
background: color-mix(in srgb, var(--theme-surface-soft-2) 84%, transparent);
}
.editorSkeleton__row {
display: grid;
grid-template-columns: 132px 1fr;
gap: 10px;
}
.editorSkeleton__row + .editorSkeleton__row {
margin-top: 10px;
}
.editorSkeleton__label {
min-height: 110px;
border-radius: 14px;
background: color-mix(in srgb, var(--theme-surface-soft-2) 88%, transparent);
}
.editorSkeleton__cells {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.editorSkeleton__cell {
min-height: 110px;
border-radius: 14px;
background: color-mix(in srgb, var(--theme-surface-soft) 74%, transparent);
border: 1px solid color-mix(in srgb, var(--theme-border) 88%, transparent);
}
.editorSkeleton--preview .editorSkeleton__board {
background: transparent;
border: 0;
box-shadow: none;
padding: 0;
}
@keyframes editorSkeletonShimmer {
100% {
transform: translateX(100%);
}
}
.head {
display: grid;
gap: 8px;
@@ -2189,7 +2381,7 @@ onUnmounted(() => {
}
.viewerSidebar__section {
margin-top: auto;
margin-top: 0;
display: grid;
gap: 10px;
padding: 18px;
@@ -2986,19 +3178,15 @@ onUnmounted(() => {
color: var(--theme-text-soft);
word-break: break-word;
}
.editorSidebar__favorite {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
.editorSidebar__favoriteButton {
width: 100%;
padding: 11px 12px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
color: var(--theme-text);
font-weight: 800;
cursor: pointer;
}
.editorSidebar__favoriteMeta {
margin-top: 10px;
color: var(--theme-text-muted);
font-size: 13px;
line-height: 1.5;
}
.editorSidebar__section--footer {
padding-top: 12px;
@@ -3210,7 +3398,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,10 +3483,25 @@ 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) {
.editorSkeleton__body {
grid-template-columns: 1fr;
}
.editorSkeleton__side {
min-height: 320px;
}
.editorSkeleton__row {
grid-template-columns: 1fr;
}
.editorSkeleton__label {
min-height: 44px;
}
.editorSkeleton__cells {
grid-template-columns: 1fr;
}
.previewOnly__row,
.row {
grid-template-columns: 1fr;
@@ -3380,6 +3582,15 @@ onUnmounted(() => {
}
}
@media (max-width: 720px) {
.editorSkeleton__previewCard,
.editorSkeleton__board,
.editorSkeleton__side {
border-radius: 18px;
}
.editorSkeleton__previewCard,
.editorSkeleton__board {
padding: 16px;
}
.previewOnly {
padding: 14px;
}

View File

@@ -5,21 +5,22 @@ 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()
const auth = useAuthStore()
const topicId = computed(() => route.params.topicId)
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim() : ''))
const topicName = ref('')
const featuredTierLists = ref([])
const tierLists = ref([])
const error = ref('')
const query = ref('')
const brokenThumbnailIds = ref({})
const isTopicLoading = ref(false)
const isListView = computed(() => route.query.view === 'list')
const topicTitle = computed(() => topicName.value || (isTopicLoading.value ? '주제 불러오는 중...' : ''))
const topicTitle = computed(() => topicName.value || '')
const publicTierLists = computed(() => tierLists.value.filter((tierList) => !tierList.isFeatured))
function fmt(ts) {
@@ -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) {
@@ -83,14 +84,13 @@ function openTierList(id) {
router.push(editorPath(topicId.value, id))
}
function submitSearch() {
loadTierLists()
}
watch(
topicId,
[topicId, query],
() => {
topicName.value = ''
featuredTierLists.value = []
tierLists.value = []
brokenThumbnailIds.value = {}
error.value = ''
loadTierLists()
},
@@ -103,12 +103,16 @@ watch(
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Collection</div>
<h2 class="pageHead__title">{{ topicTitle }}</h2>
<div class="pageHead__desc"> 주제의 공개 티어표를 같은 카드 레이아웃으로 살펴보고 이어서 티어표를 만들 있어요.</div>
</div>
<div class="pageHead__aside toolbar">
<input v-model="query" class="input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
<button class="btn" @click="submitSearch">검색</button>
<template v-if="isTopicLoading && !topicTitle">
<div class="topicHeadSkeleton" aria-hidden="true">
<div class="topicHeadSkeleton__line topicHeadSkeleton__line--title"></div>
<div class="topicHeadSkeleton__line topicHeadSkeleton__line--desc"></div>
</div>
</template>
<template v-else>
<h2 class="pageHead__title">{{ topicTitle }}</h2>
<div class="pageHead__desc"> 주제의 공개 티어표를 같은 카드 레이아웃으로 살펴보고 이어서 티어표를 만들 있어요.</div>
</template>
</div>
</section>
@@ -201,6 +205,39 @@ watch(
</template>
<style scoped>
.topicHeadSkeleton {
display: grid;
gap: 10px;
}
.topicHeadSkeleton__line {
position: relative;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, var(--theme-surface-soft-2) 88%, transparent);
}
.topicHeadSkeleton__line::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.08) 48%, transparent 100%);
transform: translateX(-100%);
animation: topicHeadSkeletonShimmer 1.4s ease-in-out infinite;
}
.topicHeadSkeleton__line--title {
width: min(280px, 72%);
height: 34px;
}
.topicHeadSkeleton__line--desc {
width: min(560px, 94%);
height: 14px;
}
@keyframes topicHeadSkeletonShimmer {
100% {
transform: translateX(100%);
}
}
.panel {
background: transparent;
border-radius: 0;
@@ -244,28 +281,6 @@ watch(
.sectionLabel {
margin-bottom: 14px;
}
.toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.input {
min-width: 240px;
padding: 11px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
.btn {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft-2);
color: var(--theme-text);
font-weight: 800;
cursor: pointer;
}
.error {
margin: 10px 0 14px;
padding: 10px 12px;

View File

@@ -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) {