Compare commits

...

40 Commits

Author SHA1 Message Date
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
7de4452fb6 docs: advance normalized version marker 2026-04-06 14:22:44 +09:00
738caade0b docs: normalize release version history 2026-04-06 14:21:23 +09:00
a6b5fc8b81 ui: simplify local rail editor spacing 2026-04-06 14:00:28 +09:00
ecd3e69b5a ui: refine editor sidebar scroll spacing 2026-04-06 13:55:05 +09:00
3dba9b0a4d ui: preserve editor sidebar end spacing 2026-04-06 13:51:57 +09:00
56b0035a45 ui: support Korean shortcut keys 2026-04-06 13:49:14 +09:00
929ffb2ed6 ui: add keyboard shortcuts 2026-04-06 13:47:50 +09:00
08ec6f42d1 ui: measure editor sidebar height 2026-04-06 13:44:16 +09:00
360ec5ac3d ui: prevent title space scroll 2026-04-06 13:33:56 +09:00
71a13488d9 ui: let editor title focus workspace 2026-04-06 13:32:56 +09:00
ba9ba8013b ui: constrain tier editor item scroll 2026-04-06 13:28:31 +09:00
da35351747 ui: add editor tips panel 2026-04-06 12:41:46 +09:00
305160663d ui: restore rail menu spacing 2026-04-06 12:32:28 +09:00
58b8df51ab ui: improve modal escape and rail wrapping 2026-04-06 12:30:49 +09:00
bdc7ee42e2 admin: polish template item actions 2026-04-06 12:26:22 +09:00
fd3f61ca2b admin: refine template settings actions 2026-04-06 12:18:25 +09:00
47638b8b3e admin: streamline item modal actions 2026-04-06 12:10:46 +09:00
632bebb8f9 admin: simplify template tagging flow 2026-04-06 12:05:04 +09:00
fe79c91e82 feat: 템플릿 태그와 병합 가져오기 및 에디터 제거 추가 2026-04-06 11:48:22 +09:00
2d5506e35a feat: 미사용 이미지 기준과 관리자 자산 정리 통합 2026-04-06 11:29:06 +09:00
8b3d469503 fix: 대체 아이템 삭제와 미사용 정리 기준 보정 2026-04-06 11:17:19 +09:00
a2fc8f8cd4 feat: 관리자 대체 아이템 전용 필터 추가 2026-04-06 11:09:48 +09:00
b134431d91 fix: 이미지 대체 시 원본 사용자 아이템 정보 보존 2026-04-06 11:03:32 +09:00
c7cafb87c3 fix: 대체된 사용자 아이템 보존 및 상태 표시 2026-04-06 11:00:02 +09:00
a716ee0062 fix: 관리자 이미지 대체 흐름 보정 2026-04-06 10:50:18 +09:00
7164d32ae8 feat: 관리자 수동 이미지 대체 기능 추가 2026-04-06 10:41:05 +09:00
dddc29fd4b fix: 프로필 카드 오버플로우와 프리뷰 풀 노출 정리 2026-04-06 10:21:45 +09:00
947837fe40 fix: 모바일 오른쪽 레일 스크롤과 프리뷰 카드 배치 보정 2026-04-03 18:13:20 +09:00
5ef833fde5 조정: 모바일 오른쪽 레일 전체 화면 패널화 2026-04-03 18:05:48 +09:00
67e192b0e1 조정: 모바일 워크스페이스 배경 레이어 정리 2026-04-03 17:58:39 +09:00
8ef011bfc8 수정: 모바일 본문 시작 위치 회귀 보정 2026-04-03 17:57:14 +09:00
33 changed files with 5025 additions and 845 deletions

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)

File diff suppressed because it is too large Load Diff

View File

@@ -20,9 +20,11 @@ const {
updateTopicThumbnail,
createTopicItem,
updateTopicItemLabel,
updateTopicItemMeta,
updateTopicItemDisplayOrder,
countTierListsUsingTopicItem,
updateCustomItemLabel,
updateCustomItemMeta,
updateImageAssetLabel,
deleteTopicItem,
deleteTopic,
@@ -33,6 +35,7 @@ const {
findUnusedCustomItems,
findCustomItemsByIds,
deleteCustomItems,
markCustomItemReplaced,
listUsers,
findPrimaryAdminUser,
listAdminTierLists,
@@ -53,6 +56,10 @@ const {
listRecentImageOptimizationJobs,
clearImageOptimizationJobs,
cleanupMissingUploadReferences,
listReferencedUploadSources,
replaceUploadSourceReferences,
updateCustomItemDisplayReferences,
clearCustomItemReplacement,
} = require('../db')
const { requireAdmin } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
@@ -94,6 +101,17 @@ function buildItemLabelFromSrc(src) {
const upload = createMemoryUpload(multer, { fileSize: 20 * 1024 * 1024, maxCount: 100 })
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
function normalizeRouteTags(tags) {
const values = Array.isArray(tags) ? tags : typeof tags === 'string' ? tags.split(',') : []
return Array.from(
new Set(
values
.map((tag) => String(tag || '').trim().replace(/^#/, '').slice(0, 40))
.filter(Boolean)
)
).slice(0, 30)
}
function decorateAdminUser(user, primaryAdmin) {
if (!user) return null
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
@@ -118,10 +136,16 @@ 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]+)*$/),
name: z.string().min(1).max(60),
tags: z.array(z.string().trim().min(1).max(40)).max(30).optional().default([]),
isPublic: z.boolean().optional().default(false),
thumbnailSrc: z.string().max(255).optional().default(''),
})
@@ -131,7 +155,12 @@ router.post('/templates', requireAdmin, async (req, res) => {
if (exists) return res.status(409).json({ error: 'topic_slug_taken' })
let template
try {
template = await createTopic({ slug: parsed.data.slug, name: parsed.data.name, isPublic: parsed.data.isPublic })
template = await createTopic({
slug: parsed.data.slug,
name: parsed.data.name,
tags: normalizeRouteTags(parsed.data.tags),
isPublic: parsed.data.isPublic,
})
} catch (error) {
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
@@ -149,6 +178,7 @@ router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
const schema = z.object({
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
name: z.string().trim().min(1).max(60).optional(),
tags: z.array(z.string().trim().min(1).max(40)).max(30).optional(),
isPublic: z.boolean().optional(),
})
const parsed = schema.safeParse(req.body)
@@ -164,6 +194,7 @@ router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
? await updateTopicMeta(template.id, {
slug: parsed.data.slug || template.slug,
name: parsed.data.name || template.name,
tags: typeof parsed.data.tags !== 'undefined' ? normalizeRouteTags(parsed.data.tags) : template.tags || [],
isPublic: typeof parsed.data.isPublic === 'boolean' ? parsed.data.isPublic : template.isPublic,
})
: await findTopicById(template.id)
@@ -304,14 +335,20 @@ router.get('/templates/:templateId/items/:itemId/usage', requireAdmin, async (re
})
router.patch('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
const schema = z.object({ label: z.string().trim().min(1).max(60) })
const schema = z.object({
label: z.string().trim().min(1).max(60),
tags: z.array(z.string().trim().min(1).max(40)).max(30).optional().default([]),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const template = await findTopicById(getTemplateIdFromParams(req))
if (!template) return res.status(404).json({ error: 'not_found' })
const updated = await updateTopicItemLabel(req.params.itemId, parsed.data.label)
const updated = await updateTopicItemMeta(req.params.itemId, {
label: parsed.data.label,
tags: normalizeRouteTags(parsed.data.tags),
})
if (!updated || updated.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
res.json({ item: updated })
})
@@ -327,6 +364,7 @@ router.delete('/templates/:templateId', requireAdmin, async (req, res) => {
router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
const schema = z.object({
label: z.string().trim().min(1).max(60),
tags: z.array(z.string().trim().min(1).max(40)).max(30).optional().default([]),
sourceType: z.enum(['template', 'user', 'asset']).optional().default('user'),
})
const parsed = schema.safeParse(req.body)
@@ -340,12 +378,18 @@ router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
}
if (parsed.data.sourceType === 'template') {
const updated = await updateTopicItemLabel(itemId, parsed.data.label)
const updated = await updateTopicItemMeta(itemId, {
label: parsed.data.label,
tags: normalizeRouteTags(parsed.data.tags),
})
if (!updated) return res.status(404).json({ error: 'not_found' })
return res.json({ item: updated })
}
const updated = await updateCustomItemLabel(itemId, parsed.data.label)
const updated = await updateCustomItemMeta(itemId, {
label: parsed.data.label,
tags: normalizeRouteTags(parsed.data.tags),
})
if (!updated) return res.status(404).json({ error: 'not_found' })
return res.json({ item: updated })
})
@@ -355,8 +399,12 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
q: z.string().trim().max(120).optional().default(''),
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
collapseShared: z
.union([z.string(), z.boolean(), z.number()])
.optional()
.transform((value) => value === true || value === 1 || value === '1' || value === 'true'),
filter: z
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused-user', 'unused-admin'])
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused', 'unused-user', 'replaced-user', 'unused-admin'])
.optional()
.default('library'),
})
@@ -368,6 +416,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
page: parsed.data.page,
limit: parsed.data.limit,
filterMode: parsed.data.filter,
collapseShared: parsed.data.collapseShared,
})
res.json(result)
})
@@ -542,15 +591,77 @@ async function removeCustomItemFiles(items) {
)
}
async function removeUnreferencedCustomItemFiles(items) {
const referencedSrcs = new Set(await listReferencedUploadSources())
const removableItems = (items || []).filter((item) => item?.src && !referencedSrcs.has(item.src))
await removeCustomItemFiles(removableItems)
}
async function promoteLibraryItemToTemplateItem({ item, templateId }) {
return createTopicItem({
id: nanoid(),
topicId: templateId,
src: item.src || '',
label: item.label,
tags: item.tags || [],
})
}
async function findLibraryItemForReplacement(itemId, sourceType = '') {
const normalizedId = String(itemId || '').trim()
const normalizedSourceType = String(sourceType || '').trim()
if (!normalizedId) return null
if (normalizedId.startsWith('asset:') || normalizedSourceType === 'asset') {
const assetId = normalizedId.startsWith('asset:') ? normalizedId.slice(6) : normalizedId
const asset = await findImageAssetById(assetId)
if (!asset) return null
return {
id: `asset:${asset.id}`,
sourceType: 'asset',
src: asset.src || '',
label: asset.labelOverride || buildItemLabelFromSrc(asset.src),
tags: [],
}
}
if (normalizedSourceType === 'template') {
const item = await findTopicItemById(normalizedId)
if (!item) return null
return {
id: item.id,
sourceType: 'template',
src: item.src || '',
label: item.label || buildItemLabelFromSrc(item.src),
tags: item.tags || [],
}
}
const customItem = await findCustomItemById(normalizedId)
if (customItem) {
return {
id: customItem.id,
sourceType: 'user',
src: customItem.src || '',
label: customItem.label || buildItemLabelFromSrc(customItem.src),
tags: customItem.tags || [],
}
}
const templateItem = await findTopicItemById(normalizedId)
if (templateItem) {
return {
id: templateItem.id,
sourceType: 'template',
src: templateItem.src || '',
label: templateItem.label || buildItemLabelFromSrc(templateItem.src),
tags: templateItem.tags || [],
}
}
return null
}
async function copyUploadIntoTopicAsset(src) {
if (typeof src !== 'string') return ''
const raw = src.trim()
@@ -721,13 +832,14 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
return res.json({ ok: true, sourceType: 'template' })
}
const canDeleteReplacedUserItem = target.sourceType === 'user' && !!target.replacedAt
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
if (target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' })
if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
if (!canDeleteReplacedUserItem && target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' })
if (!canDeleteReplacedUserItem && target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
const items = await findCustomItemsByIds([target.id])
await deleteCustomItems([target.id])
await removeCustomItemFiles(items)
await removeUnreferencedCustomItemFiles(items)
res.json({ ok: true, sourceType: 'user' })
})
@@ -760,6 +872,90 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
res.json({ item })
})
router.post('/custom-items/:itemId/unlink-template', requireAdmin, async (req, res) => {
const schema = z.object({
topicId: z.string().trim().min(1),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const template = await findTopicById(parsed.data.topicId)
if (!template) return res.status(404).json({ error: 'topic_not_found' })
const sourceItem = await findLibraryItemForReplacement(req.params.itemId)
if (!sourceItem?.src) return res.status(404).json({ error: 'not_found' })
const templateItems = await listTopicItems(template.id)
const matchedItems = templateItems.filter((item) => item?.src === sourceItem.src)
if (!matchedItems.length) return res.status(404).json({ error: 'linked_template_item_not_found' })
await Promise.all(matchedItems.map((item) => deleteTopicItem(item.id)))
res.json({ ok: true, deletedCount: matchedItems.length, topicId: template.id, src: sourceItem.src })
})
router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
const schema = z.object({
targetItemId: z.string().trim().min(1),
targetSourceType: z.enum(['template', 'user', 'asset']).optional().default('user'),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const sourceItem = await findLibraryItemForReplacement(req.params.itemId)
if (!sourceItem?.src) return res.status(404).json({ error: 'source_not_found' })
if (sourceItem.sourceType !== 'user') return res.status(400).json({ error: 'user_item_required' })
const targetItem = await findLibraryItemForReplacement(parsed.data.targetItemId, parsed.data.targetSourceType)
if (!targetItem?.src) return res.status(404).json({ error: 'target_not_found' })
if (sourceItem.src === targetItem.src && (sourceItem.label || '') === (targetItem.label || '')) {
return res.status(409).json({ error: 'same_target' })
}
const result = await replaceUploadSourceReferences({
fromSrc: sourceItem.src,
toSrc: targetItem.src,
toLabel: targetItem.label || '',
updateCustomItemsBySrc: false,
})
const displayResult = await updateCustomItemDisplayReferences({
itemId: sourceItem.id,
src: targetItem.src,
label: targetItem.label || '',
})
await markCustomItemReplaced({
itemId: sourceItem.id,
replacedByItemId: targetItem.id || '',
replacedBySrc: targetItem.src || '',
replacedByLabel: targetItem.label || '',
})
res.json({
ok: true,
updatedRows: (result.updatedRows || 0) + (displayResult.updatedRows || 0),
sourceItem,
targetItem,
})
})
router.post('/custom-items/:itemId/restore', requireAdmin, async (req, res) => {
const sourceItem = await findCustomItemById(req.params.itemId)
if (!sourceItem?.id) return res.status(404).json({ error: 'not_found' })
if (!sourceItem.replacedAt) return res.status(409).json({ error: 'not_replaced' })
const restored = await updateCustomItemDisplayReferences({
itemId: sourceItem.id,
src: sourceItem.src,
label: sourceItem.label || '',
})
await clearCustomItemReplacement(sourceItem.id)
res.json({
ok: true,
restoredRows: restored.updatedRows || 0,
item: await findCustomItemById(sourceItem.id),
})
})
router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => {
const schema = z.object({
topicId: z.string().min(1),
@@ -1014,11 +1210,27 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const items = await findUnusedCustomItems({ queryText: parsed.data.q })
const ids = items.map((item) => item.id)
await deleteCustomItems(ids)
await removeCustomItemFiles(items)
res.json({ ok: true, deletedCount: ids.length })
const result = await listCustomItems({
queryText: parsed.data.q,
page: 1,
limit: 10000,
filterMode: 'unused',
})
const customItems = result.items.filter((item) => item?.sourceType === 'user')
const assetItems = result.items.filter((item) => item?.sourceType === 'asset' || item?.isAssetLibraryItem)
const customItemIds = customItems.map((item) => item.id)
const assetIds = assetItems
.map((item) => String(item.id || ''))
.filter((id) => id.startsWith('asset:'))
.map((id) => id.slice('asset:'.length))
await deleteCustomItems(customItemIds)
await removeUnreferencedCustomItemFiles(customItems)
const deletedAssets = await deleteImageAssets(assetIds)
await removeImageAssetFiles(deletedAssets)
res.json({ ok: true, deletedCount: customItemIds.length + deletedAssets.length })
})
router.get('/users', requireAdmin, async (req, res) => {

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

@@ -1,393 +1,546 @@
# 의사결정 이력
## 2026-04-03 v1.4.72
## 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`으로 승격해 이어간다.
- 따라서 기존 문서의 `v1.2.x``v0.2.x`, `v1.3.x``v0.3.x`, `v1.4.x``v1.0.x`로 읽어야 하며, 현재 최신 문서 기준 버전은 `v1.0.103`다.
## 2026-04-06 v1.0.101
- 실제 문제는 `editorCanvas` 자체보다 그 아래 `workspaceBody--localRail`의 하단 패딩까지 문서가 더 스크롤되는 구간에서 발생했다. 편집 캔버스 자체에도 하단 여백이 있으므로, 데스크톱의 `workspaceBody--localRail` 하단 패딩과 오른쪽 sticky 래퍼의 추가 보정 패딩은 제거하는 편이 더 단순하고 정확하다고 판단했다.
- 아이템 풀은 내부 스크롤이 필요하지만, 오른쪽 패널 안에서 스크롤바가 항상 보이면 작업 화면이 좁고 복잡해 보일 수 있으므로 스크롤 동작만 유지하고 스크롤바 시각 요소는 숨기는 편이 낫다고 정리했다.
## 2026-04-06 v1.0.100
- 오른쪽 아이템 패널은 sticky로 동작하지만 부모 컨테이너의 끝에 닿으면 sticky 제한 때문에 마지막 스크롤 위치에서 위쪽 여백이 무너져 보일 수 있다. 그래서 편집 캔버스 자체에 작은 하단 여유 공간을 두어 마지막 위치에서도 패널 주변 여백이 유지되도록 하는 편이 낫다고 판단했다.
## 2026-04-06 v1.0.99
- 단축키는 실제 키 위치 기준으로 기대하는 경우가 많으므로, 한글 입력 상태에서 S 자리 키가 `ㄴ`으로 들어와도 같은 “아이템 검색” 동작을 실행하고, F 자리 키가 `ㄹ`로 들어와도 같은 “전체 화면” 동작을 실행하는 편이 한국어 사용자에게 자연스럽다고 정리했다.
## 2026-04-06 v1.0.98
- 방송/편집처럼 화면을 자주 정리해야 하는 사용 흐름에서는 사이드 패널과 전체 화면 전환을 마우스로만 조작하면 반복 비용이 커진다. 그래서 `[`/`]`/`F`/`S`처럼 한 손으로 누르기 쉬운 단축키를 두되, 입력창에서는 단축키를 무시해 실제 텍스트 입력을 방해하지 않는 편이 맞다고 정리했다.
- `S`는 전역 템플릿 검색이 아니라 티어표 편집 화면의 아이템 검색으로 연결해야 하므로, 앱 셸이 편집 화면에 커스텀 이벤트를 보내고 편집 화면이 자신의 아이템 검색창에 포커스를 주는 방식으로 분리했다.
## 2026-04-06 v1.0.97
- 티어표 편집기의 오른쪽 아이템 패널은 페이지 내부 위치가 헤더, 제목, 스크롤 상태에 따라 달라지므로 `100dvh - 고정값` 방식으로는 왼쪽 레일처럼 하단이 자연스럽게 맞지 않을 수 있다. 실제 패널의 화면 내 시작 위치를 측정해 남은 높이를 계산하는 편이 더 안정적이라고 정리했다.
## 2026-04-06 v1.0.96
- 템플릿 제목을 버튼화하면 접근성은 좋아지지만, 포커스가 남은 상태의 `Space` 입력이 브라우저 스크롤과 섞이면 작업 화면을 갑자기 밀어낼 수 있다. 따라서 제목 버튼에서는 `Space` 기본 스크롤을 막고 의도한 본문 이동만 실행하는 편이 맞다고 정리했다.
## 2026-04-06 v1.0.95
- 티어표 편집 중에는 공통 헤더보다 보드와 아이템 풀이 더 중요한 작업 기준점이므로, 템플릿 제목을 본문 위치로 빠르게 이동하는 가벼운 컨트롤로 활용하는 편이 좋다고 정리했다. 별도 버튼을 추가하기보다 기존 제목 클릭 동작으로 두어 화면 복잡도를 늘리지 않는 쪽을 택했다.
## 2026-04-06 v1.0.94
- 아이템 수가 많을 때 오른쪽 풀 때문에 페이지 전체가 길어지면 왼쪽 티어표까지 함께 움직여 방송/녹화 환경에서 기준 화면이 흔들릴 수 있다. 그래서 데스크톱에서는 오른쪽 사이드의 실제 화면 시작 위치를 감안해 높이를 제한하되, 제목과 검색창은 유지하고 아이템 그리드만 스크롤되게 하는 편이 더 적절하다고 정리했다. 모바일에서는 기존처럼 문서 흐름을 유지한다.
## 2026-04-06 v1.0.93
- 티어표 편집기의 커스텀 이미지 추가 영역 아래는 아이템 수가 적을 때 비어 보이기 쉬우므로, 이 공간에는 큰 기능보다 방해되지 않는 작은 작업 팁을 두는 편이 자연스럽다고 정리했다. 특히 우클릭 복제, 미사용 아이템 처리, 브라우저 확대/축소처럼 초반 시행착오를 줄여 주는 내용이 효과적이라고 판단했다.
## 2026-04-06 v1.0.92
- 모바일 왼쪽 레일은 사용자 카드, 검색, 메뉴가 세로로 붙는 구조라 기본 `gap`이 빠지면 브라우저별 렌더링 차이에 따라 훨씬 답답하게 보일 수 있으므로, 이 영역 간격은 명시적으로 주는 편이 안전하다고 정리했다.
## 2026-04-06 v1.0.91
- 관리자 화면 모달이 많아질수록 `Esc` 동작이 일부 모달에서만 먹으면 예측 가능성이 떨어지므로, 열려 있는 공통 모달은 모두 `Esc = 취소` 규칙으로 맞추는 편이 더 자연스럽다고 정리했다.
- 왼쪽 레일 사용자 카드의 두 번째 줄은 로그인된 상태에선 이메일이라 말줄임이 맞지만, 로그인 전/확인 중 메시지는 설명 성격이 강하므로 같은 `nowrap` 규칙을 쓰면 가로 스크롤을 유발할 수 있다. 그래서 이메일과 설명 문구의 줄 처리 정책을 분리하는 쪽이 맞다고 판단했다.
## 2026-04-06 v1.0.90
- `templateSettingsCard__actions`는 카드 안에서 버튼이 줄바꿈될 수 있어야 하지만, 공통 버튼 스타일의 높이 100% 규칙까지 그대로 받으면 줄바꿈된 행이 비정상적으로 늘어날 수 있으므로 이 영역의 버튼만 높이를 자동으로 되돌리는 편이 맞다고 정리했다.
- 또 템플릿 기본 아이템 삭제는 “기존 저장 티어표에는 영향 없음”이라는 정책 설명이 중요하므로, 브라우저 기본 확인창보다 관리자 공통 모달로 통일해 같은 톤과 문구 체계 안에서 보여주는 쪽이 더 낫다고 판단했다.
## 2026-04-06 v1.0.89
- 템플릿 화면에서 이름/slug 저장과 아이템 태그 일괄 추가는 성격이 다르므로, 기존처럼 하나의 `메타` 개념으로 묶기보다 `이름/주소 저장``공통 태그 추가`를 분리해 보여주는 편이 운영자가 이해하기 쉽다고 정리했다.
-`templateSettingsCard`는 버튼 문구가 비교적 길고 썸네일/폼/토글이 함께 들어가는 카드라서, 좁은 폭에서 각 블록의 최소 너비를 풀어 주지 않으면 카드 밖으로 밀려나기 쉬우므로 입력 필드와 액션 버튼 모두 카드 내부에서 줄어들고 줄바꿈되게 하는 쪽이 맞다고 판단했다.
- 템플릿 기본 아이템 카드도 작은 썸네일 위에 버튼 두 개를 계속 노출하면 카드 높이가 불필요하게 커지고 반복 조작 밀도가 떨어지므로, 저장은 입력 후 `Enter`, 삭제는 우상단 `X`처럼 더 직접적인 마이크로 인터랙션으로 옮기는 편이 낫다고 정리했다.
## 2026-04-06 v1.0.88
- 같은 이미지를 사용자 항목, 템플릿 항목, 관리자 자산으로 각각 따로 카드에 늘어놓으면 운영자가 실제로 보고 싶은 “이미지 단위 상태”보다 내부 저장 단위가 더 크게 드러나므로, 관리자 목록과 검색은 기본적으로 같은 `src`를 하나로 묶어 보여주는 편이 더 자연스럽다고 정리했다.
- 아이템 모달의 연결 템플릿 배지는 단순히 해당 템플릿 화면으로 점프하는 것보다, 그 자리에서 `이 템플릿에서 제외`를 바로 수행하는 액션이 훨씬 직접적이므로 배지에 제거 버튼을 붙이는 쪽이 더 낫다고 판단했다.
- 한글 태그 입력은 IME 조합 중 `Enter`가 중간 문자열까지 함께 커밋될 수 있으므로, 배지형 태그 입력에서는 조합 상태를 명시적으로 감지해 완성된 문자열만 태그로 추가하는 편이 맞다고 정리했다.
## 2026-04-06 v1.0.87
- 템플릿 태그는 이름/slug 검색과 역할이 겹치고, 운영자가 실제로 원하는 것은 “템플릿 찾기”보다 “아이템 묶음 분류”에 가까웠으므로 템플릿 화면에서 직접 노출하지 않는 편이 더 맞다고 정리했다.
- 태그 입력도 카드 곳곳에 흩어져 있으면 메인 작업인 업로드와 이름 정리가 묻히기 쉬우므로, 태그는 관리자 아이템 모달에서만 배지형으로 다루고 템플릿 화면 본문은 가볍게 유지하는 쪽을 택했다.
- 기존 템플릿을 통째로 가져오는 기능만으로는 운영자가 “조건에 맞는 일부 아이템만” 빠르게 합치는 흐름이 부족하므로, 이름·파일명·태그 기반 개별 아이템 검색 후 초안 목록으로 담아두는 보조 모달을 추가하는 편이 더 실용적이라고 판단했다.
## 2026-04-06 v1.0.86
- 내부 운영용 분류는 공개 노출용 필드와 분리하는 편이 맞다고 보고, 태그는 템플릿과 아이템의 관리자 전용 메타로만 저장하고 검색에 활용하는 방향으로 정리했다.
- 기존 분기 템플릿이나 요청 아이템을 다시 재조합해 새 템플릿을 만드는 흐름은 완전히 별도 마법 기능보다, 이미 있는 `초안 목록`에 다른 출처의 아이템을 합류시키고 `제외` 버튼으로 정리하는 편이 훨씬 예측 가능하다고 판단했다.
- 사용자가 잘못 올린 이미지는 파일 업로드 자체를 되돌리는 것보다 “현재 티어표에서 이 커스텀 아이템을 제거”하는 조작이 먼저 필요하다고 보고, 저장 전 blob 이미지와 저장 후 커스텀 아이템 모두 같은 제거 UX로 다루는 쪽을 채택했다.
## 2026-04-06 v1.0.85
- 썸네일과 프로필 이미지는 이미 별도 필터로 분리돼 있고, 템플릿 아이템/사용자 아이템도 각각 구분되고 있으므로, 그 어디에도 속하지 않는 관리자 업로드 자산은 운영 의미상 `보관 자산`보다 `미사용 이미지`로 보는 편이 더 직관적이라고 정리했다.
- 또 관리자 아이템 화면에서 삭제 버튼이 필터 조건에 따라 갑자기 비활성으로만 보이면 흐름을 이해하기 어려우므로, 평소에는 `미사용 이미지 확인`, 해당 화면 안에서만 `미사용 이미지 일괄 삭제`로 바뀌는 점진적 동작이 더 낫다고 판단했다.
## 2026-04-06 v1.0.84
- 대체된 원본 아이템은 운영상 이미 교체 완료된 이력이므로, 현재 저장본에서 같은 item id 사용량이 남아 있더라도 개별 삭제를 막아 두는 쪽보다 명시적으로 정리 가능하게 두는 편이 더 맞다고 판단했다.
- 또 관리자 목록에서 보이는 `미사용` 기준과 실제 일괄 삭제 API 기준이 다르면 운영자가 버튼 상태를 신뢰하기 어려워지므로, 둘은 반드시 같은 조건으로 맞추기로 정리했다.
- 마지막으로 업로드 파일은 하나의 레코드가 없어졌다고 곧바로 지우기보다, 전체 참조를 다시 확인한 뒤 정말 고아 파일일 때만 삭제하는 방식이 더 안전하다고 판단했다.
## 2026-04-06 v1.0.83
- 대체 이력이 쌓이기 시작하면 일반 사용자 업로드 목록 안에 섞여 보이는 것보다, 운영자가 후속 검수와 정리를 위해 `대체된 항목만` 따로 모아 보는 필터가 있는 편이 더 실용적이라고 판단했다.
## 2026-04-06 v1.0.82
- 원본 A를 보존하더라도 실제 저장본이 계속 A의 item id를 쓰는 구조라면, 대체/복구는 `custom_items` 레코드 자체를 바꾸는 방식보다 “그 item id가 참조되는 보드 표시 데이터(src/label)만 바꾸는 방식”이 더 자연스럽다고 판단했다.
- 또 대체된 원본은 집계상 사용량이 남더라도 운영 의미상 이미 정리 가능한 이력이므로, `미사용 아이템 일괄 삭제`에서 제외하면 오히려 계속 쌓이게 된다. 그래서 대체 이력 아이템은 예외적으로 미사용 정리 대상으로 포함하는 쪽이 맞다고 정리했다.
## 2026-04-06 v1.0.81
- 원본 보존을 하더라도 실제 `custom_items.src``label`까지 대체 대상 값으로 덮어쓰면 관리자 입장에서는 “원본을 남겼다”기보다 “같은 새 이미지가 두 번 보이는 상태”로 읽히므로, 원본 아이템 레코드는 실제 이미지/라벨을 그대로 유지하고 참조 이동 대상에서만 제외하는 편이 맞다고 정리했다.
- 즉 대체 이력 메타(`어떤 아이템으로 대체됐는지`)와 원본 본문 데이터(`원래 A 이미지/이름`)는 분리해서 보존해야, 이후 복구나 검수 기준으로 의미가 살아난다고 판단했다.
## 2026-04-06 v1.0.80
- 이미지 대체 직후 원본 사용자 아이템을 완전히 지워버리면 관리자 입장에서는 “왜 바꿨는지”, “나중에 정리해도 되는지”를 다시 확인할 근거가 사라지므로, 참조 이동과 원본 보존을 분리하는 편이 운영 흐름에 더 맞다고 판단했다.
- 다만 대체 완료된 원본까지 별도 보호 대상으로 빼면 라이브러리 정리가 끝없이 쌓일 수 있으므로, 원본은 `대체됨` 상태로 계속 보이게 하되 이미 미사용인 이상 `미사용 아이템 일괄 삭제`와 개별 삭제로 언제든 정리할 수 있게 두는 쪽이 균형이 맞는다고 정리했다.
## 2026-04-06 v1.0.79
- 관리자 대체 모달은 열자마자 현재 라이브러리 결과를 그대로 쏟아내면 “같은 보드 안의 비슷한 항목을 고르는 화면”처럼 읽히기 쉬우므로, 검색 전에는 후보를 비워 두고 운영자가 의도적으로 찾은 뒤 고르는 방식이 더 분명하다고 판단했다.
- 또 사용자 업로드 A를 F로 대체했을 때 관리자 목록에 F가 두 장 보이면 “참조 이동”보다 “복제 생성”처럼 느껴지므로, 사용자 업로드 대체는 참조를 옮긴 뒤 원본 레코드 자체를 정리해 결과적으로 목표 이미지 한 장만 남는 쪽이 운영 기대와 더 잘 맞는다고 정리했다.
## 2026-04-06 v1.0.78
- 사용자 업로드 이미지의 “같은 캐릭터인데 파일만 다른 경우”는 자동 판별하려 들수록 오탐 위험이 커지므로, 관리자 모달에서 대상 이미지를 직접 검색·선택하는 수동 치환 흐름으로 시작하는 편이 가장 안전하다고 판단했다.
- 이때 `src`만 바꾸고 기존 라벨을 남기면 운영자가 통합한 뒤에도 표기가 제각각 남을 수 있으므로, 치환 대상의 `라벨`을 기준으로 사용자 업로드 행과 저장된 티어표/요청 스냅샷 내부 라벨까지 함께 맞춰 주는 편이 운영 목적에 더 부합한다고 정리했다.
## 2026-04-06 v1.0.77
- 작성자 프로필 화면의 공개 티어표 카드는 같은 계열의 다른 목록 뷰와 거의 동일한 마크업을 쓰고 있었지만, `overflow: hidden`과 일부 최소 너비 제약이 빠져 있어 긴 제목/메타/썸네일이 카드 라운드 경계 안에서 안정적으로 잘리지 못한다고 판단했다.
- 또 공유용 프리뷰는 “완성된 티어표 보드”를 보여주는 화면이므로, 편집 중 보조 영역인 미사용 풀까지 노출하면 실제 배치 결과보다 산만해질 수 있어 프리뷰에서는 보드에 배치된 아이템만 노출하는 쪽이 더 일관된다고 정리했다.
## 2026-04-03 v1.0.76
- 프리뷰용 `viewerSidebar__section`은 데스크톱 오른쪽 레일에서 하단 액션 카드처럼 보이게 하려고 `margin-top: auto`를 갖고 있었지만, 모바일 전체 화면 overlay에서는 이 규칙이 카드를 바닥으로 밀어 과도하게 붙은 인상을 만들 수 있다고 판단했다.
- 게다가 `localRightRailRoot`가 최소 높이 100%를 유지한 채 상위 콘텐츠 컨테이너도 flex 남은 높이를 채우면, 하단 footer 영역과 Teleport 콘텐츠의 시각적 쌓임이 어색해질 수 있으므로 모바일 overlay에서는 콘텐츠 컨테이너를 내용 높이 기준으로 풀어 footer가 자연스럽게 아래로 따라오게 정리했다.
## 2026-04-03 v1.0.75
- 데스크톱/태블릿에서는 오른쪽 레일을 폭이 정해진 서랍 패널처럼 여는 게 맞지만, 모바일에서는 같은 폭 규칙을 유지하면 오히려 “오른쪽에서 덜 열린 반쪽 패널”처럼 보여 하단 공간까지 어색해질 수 있다고 판단했다.
- 그래서 모바일 한정으로 오른쪽 레일 overlay를 전체 화면 패널로 바꾸고, 공유/복사 같은 하단 액션이 기기 하단 UI나 safe-area에 붙어 잘리지 않도록 내부 바디 패딩을 더 넉넉하게 두는 쪽으로 정리했다.
## 2026-04-03 v1.0.74
- 모바일에서는 `workspaceBody` 자체가 카드처럼 배경색을 가지는 것보다, 바깥 앱 셸 배경 위에 각 화면의 실제 카드/섹션만 떠 있는 편이 시각 구조가 더 명확하다고 판단했다.
- 특히 `workspaceBody`가 좌우 마진을 가진 상태로 별도 배경색을 칠하면 “내용과 무관한 중간 레이어 박스”처럼 보일 수 있으므로, 모바일 한정으로 공통 워크스페이스 배경을 투명 처리해 불필요한 레이어감을 줄이는 쪽으로 정리했다.
## 2026-04-03 v1.0.73
- 모바일 `appShell`은 PC처럼 좌우 3열이 아니라 위쪽 레일과 아래쪽 본문이 세로로 쌓이는 2행 구조이므로, 열 정의만 1fr로 바꾸고 행 정의를 비워두면 암묵 그리드 행이 남는 높이를 늘려 본문이 아래로 밀려 보일 수 있다고 판단했다.
- 이 문제는 각 화면 본문을 개별 조정하기보다 모바일 셸 컨테이너에서 첫 행은 `auto`, 본문 행은 `minmax(0, 1fr)`로 고정하고 전체 콘텐츠 정렬을 위쪽으로 붙이는 편이 공통 회귀를 가장 작게 되돌리는 방법이라고 정리했다.
## 2026-04-03 v1.0.72
- 모바일 상단 레일은 왼쪽 유저 카드 영역과 오른쪽 패널 토글 버튼이 같은 시각적 간격 체계를 가져야 전체 셸이 덜 비뚤어져 보이므로, 모바일에서만 `railHeader` 좌우 패딩을 본문 카드 여백보다 조금 넓은 `20px`로 맞추는 편이 낫다고 판단했다.
- 오른쪽 레일 토글 아이콘이 모바일에서 테두리 없는 아이콘만 보이면 왼쪽 네비 토글 버튼과 컴포넌트 문법이 달라 보이므로, 모바일 한정으로 같은 버튼형 배경/테두리/라운드를 적용해 조작 가능한 컨트롤처럼 통일하는 쪽으로 정리했다.
## 2026-04-03 v1.4.71
## 2026-04-03 v1.0.71
- 모바일에서 공통 본문 하단이 딱 붙어 보이는 문제는 로그인 화면 하나만 고치는 것보다 `workspaceBody` 공통 하단 여백을 safe-area까지 포함해 보강하는 편이 이후 모든 본문 화면에 일괄 적용되어 유지보수상 낫다고 판단했다.
- 모바일 왼쪽 네비게이션은 데스크톱의 폭 축소형 접기와 목적이 다르므로, 기존 `leftRailCollapsed`를 억지로 재사용하기보다 `mobileLeftNavOpen` 상태를 분리하고 유저 카드 우측 버튼으로 검색/메뉴 묶음만 접는 방식이 더 자연스럽다고 정리했다.
- 오른쪽 레일은 모바일에서 기본 자동 열림이 실제 조작 공간을 빼앗는 경우가 많으므로, 모바일 진입과 라우트 이동 시 기본 닫힘으로 두되 PC 레이아웃으로 돌아오면 다시 기본 열림을 복원하는 쪽으로 맞췄다.
- 모바일 터치에서는 짧은 탭 선택과 드래그 시작이 같은 포인터 입력에서 충돌하기 쉬우므로, Sortable에 터치 전용 지연과 threshold를 둬 탭은 선택, 길게 누르고 움직이면 드래그가 되도록 의도를 분리했다.
## 2026-04-03 v1.4.70
## 2026-04-03 v1.0.70
- 카카오톡/디스코드/X 공유 미리보기는 대개 프런트 SPA 자바스크립트를 실행하기 전에 HTML 메타를 먼저 읽으므로, 기존 `index.html` 고정 메타를 프런트 런타임에서 바꾸는 방식만으로는 티어표별 썸네일/제목/설명을 안정적으로 보여주기 어렵다고 판단했다.
- 현재 운영 구조가 프런트 Nginx 정적 서빙 + 백엔드 API 분리 형태이므로, 모든 SPA 경로를 SSR로 바꾸기보다 공유 버튼만 `/share/editor/...` 서버 렌더링 경로를 사용하게 하고, 이 경로에서 OG 메타를 만든 뒤 기존 `preview=1` 화면으로 넘기는 방식이 가장 작은 변경이라고 정리했다.
- 다만 비공개 티어표의 제목/설명/썸네일이 외부 크롤러에게 노출되면 안 되므로, 공유 메타 생성은 공개 티어표이면서 URL의 주제 식별자와 실제 티어표 소속이 일치하는 경우에만 개별 메타를 사용하고, 그 외에는 서비스 기본 메타로 떨어지게 제한했다.
## 2026-04-03 v1.4.69
## 2026-04-03 v1.0.69
- 아이템 검색 실패가 라벨 누락이나 이벤트 문제처럼 보일 수도 있지만, 코드상 필터링 조건 자체는 단순했으므로 한글 입력/저장 문자열의 유니코드 정규형 차이까지 먼저 흡수하는 편이 더 안전하다고 판단했다.
- 검색 시점에만 임시 보정하는 것보다, 검색어와 저장 라벨 비교를 같은 정규화 함수로 통일하고 커스텀 파일명 기반 기본 라벨 생성도 `NFC`로 맞춰 이후 신규 업로드 항목까지 같은 규칙을 타게 정리했다.
## 2026-04-03 v1.4.68
## 2026-04-03 v1.0.68
- 우클릭 복제 UX는 카드 영역과 썸네일 이미지 중 어디를 눌러도 같은 동작이어야 하므로, 개별 카드의 버블링 이벤트만 믿기보다 전역 캡처 단계에서 아이템 우클릭을 먼저 가로채는 방식이 더 안전하다고 판단했다.
- 편집기에서는 아이템 이미지를 브라우저 기본 이미지처럼 드래그하거나 저장 메뉴로 여는 것보다 보드 조작이 우선이므로, 썸네일 이미지의 기본 드래그도 명시적으로 꺼두는 편이 맞다고 정리했다.
## 2026-04-03 v1.4.67
## 2026-04-03 v1.0.67
- 이미지 최적화는 해시 기반 중복 재사용을 하기 때문에, 프로필 아바타로 올린 이미지가 우연히 템플릿/사용자 아이템과 같은 `src`를 공유할 수 있다. 이때 자산 카드 쪽을 무조건 숨기면 “실제로는 프로필 이미지로 쓰이는데 관리자 필터에서 안 보이는 상태”가 생기므로, 아바타/썸네일 참조가 있는 `src`는 자산 카드도 유지하는 편이 맞다고 판단했다.
## 2026-04-03 v1.4.66
## 2026-04-03 v1.0.66
- 특정 템플릿은 같은 아이템을 조건별로 여러 칸에 동시에 배치해야 하므로, 기존 아이템을 직접 공유해서 재사용하기보다 우클릭으로 새 복제본을 만들어 미사용 풀에 넣는 방식이 더 자연스럽다고 판단했다.
- 현재 티어표 저장 구조는 아이템 ID 기준으로 위치를 추적하므로, 복제본이 원본과 같은 ID를 쓰면 중복 배치가 불가능해진다. 따라서 복제 시 `dup-...` 형태의 새 ID를 발급해 원본과 복제본을 별도 인스턴스로 관리하기로 했다.
## 2026-04-03 v1.4.62
## 2026-04-03 v1.0.62
- 운영 서버를 새 DB로 다시 시작하는 절차는 “일반 업데이트 재빌드”와 “볼륨까지 삭제하는 완전 초기화”가 같은 문서 안에 섞이면 실수로 데이터를 날릴 위험이 크므로, 배포 문서에서 두 흐름을 별도 섹션으로 나누는 편이 맞다고 판단했다.
- DB만 비우고 업로드 볼륨을 남기는 방식도 가능하지만, 현재 서비스는 DB 레코드와 업로드 파일 참조가 강하게 연결되어 있으므로 이 방법 역시 “운영 데이터를 전부 버리는 전제”라는 경고를 같이 적어두는 쪽으로 정리했다.
## 2026-04-03 v1.4.61
## 2026-04-03 v1.0.61
- 운영자가 쓰는 템플릿 주소를 `topics.id` 자체로 두면 나중에 이름/URL을 다듬고 싶을 때 참조 FK와 기존 링크까지 같이 흔들릴 수 있으므로, 내부 참조용 랜덤 `id`와 공개/관리용 `slug`를 분리하는 구조가 더 안전하다고 판단했다.
- 운영 DB와 로컬 DB를 모두 새로 시작할 수 있는 상황이라면 예전 `id -> slug` 백필이나 레거시 호환 코드를 남기는 편이 오히려 유지보수 비용만 늘리므로, 이번 변경은 새 스키마 기준으로 깔끔하게 정리하고 기존 데이터 호환 마이그레이션은 두지 않기로 했다.
- 빈 DB 초기화 시 예시 템플릿 2개가 자동 생성되면 운영자가 “진짜 운영 데이터인지 샘플인지”를 매번 구분해야 하므로, 시스템 필수 `freeform`만 남기고 빈 예시 템플릿 시드는 제거하는 편이 맞다고 정리했다.
## 2026-04-03 v1.4.60
## 2026-04-03 v1.0.60
- 신규 업로드만 샤딩 저장하고 기존 평면 `assets` 파일을 그대로 두면 운영자가 파일 구조를 볼 때 두 방식이 오래 섞여 보여 정리성이 떨어지므로, 기존 평면 자산도 같은 규칙으로 옮기는 일회성 마이그레이션 스크립트를 제공하는 편이 맞다고 판단했다.
- 기존 파일을 재인코딩해서 새 자산으로 다시 만드는 방식은 해시 중복 처리와 품질/메타 차이가 다시 얽힐 수 있으므로, 이번 샤딩 정리는 실제 파일 rename과 경로 참조 치환만 수행해 이미지 내용 자체는 건드리지 않는 쪽으로 정리했다.
## 2026-04-03 v1.4.59
## 2026-04-03 v1.0.59
- 최근 최적화 이미지가 `assets` 바로 아래 평면 파일로 저장되면서 경로만으로 프로필/썸네일 역할을 구분할 수 없게 되었으므로, 관리자 아이템 분류는 폴더명 규칙 하나에만 기대지 말고 실제 DB 참조 컬럼을 역추적해 판별하는 편이 더 안전하다고 판단했다.
- 이미지가 장기적으로 많이 쌓일 수 있는 서비스라면 한 폴더에 모든 파일을 계속 몰아넣기보다 적당한 수준의 하위 폴더 분산이 낫다고 보고, 신규 파일만 ID 앞 2글자로 1단계 샤딩 저장하되 기존 평면 경로는 그대로 유지하는 점진 방식으로 정리했다.
## 2026-04-03 v1.4.58
## 2026-04-03 v1.0.58
- 작성자 프로필 화면 상단에서 닉네임과 `@accountName`을 다시 보여주면 바로 아래 프로필 카드의 동일 정보와 역할이 겹치므로, 상단은 페이지 성격을 설명하는 공통 제목으로 두고 실제 사용자 식별 정보는 프로필 카드 한 곳에만 모으는 편이 낫다고 판단했다.
- `@accountName`은 사용자가 직접 만든 핸들이 아니라 이메일 앞부분 기반 표시라서 계정명이 따로 존재하는 것처럼 오해를 만들 수 있으므로, 별도 사용자명 정책을 도입하기 전까지는 공개 프로필 UI에서 숨기는 쪽으로 정리했다.
## 2026-04-03 v1.4.57
## 2026-04-03 v1.0.57
- 아이템 관리 필터는 전체 데이터 탐색과 실제 반복 아이템 검수를 같은 셀렉트에서 오가야 하므로, `전체 이미지`를 맨 위에 두되 기본값은 여전히 `아이템(템플릿 + 사용자)`로 유지해 운영자가 처음부터 프로필/썸네일 자산에 묻히지 않게 하는 편이 맞다고 판단했다.
- `미사용 사용자 업로드`라는 표현은 계정 탈퇴 잔여물처럼 오해될 수 있으므로, 실제 의미가 “사용자 아이템 레코드는 남아 있지만 현재 저장 티어표/템플릿 참조가 없는 항목”이라는 점에 맞춰 `미사용 아이템`으로 줄이고, 계정 삭제 시 외래키로 같이 삭제되는 항목은 이 범주로 남지 않는다고 정리했다.
## 2026-04-03 v1.4.56
## 2026-04-03 v1.0.56
- 아이템 관리의 원래 목적은 “반복 사용 가능한 티어표 아이템”과 “사용자가 올린 커스텀 아이템”을 운영자가 구분해 검수하는 것이었는데, 프로필 아바타나 티어표 썸네일까지 `관리자 템플릿`으로 보이면 의미가 흐려지므로 보관 이미지 자산은 별도 출처와 배지로 분리하는 편이 맞다고 판단했다.
- 평소 운영자가 가장 먼저 봐야 하는 대상도 1회성 썸네일이 아니라 실제 아이템이므로, 아이템 관리 기본 필터는 `전체 이미지`가 아니라 `아이템만 (템플릿+사용자)`로 두고 썸네일/프로필 이미지는 필요할 때만 따로 보게 하는 쪽으로 정리했다.
## 2026-04-03 v1.4.55
## 2026-04-03 v1.0.55
- 기존 `최근 활동`은 실제 의미가 “작성한 티어표의 마지막 수정일”에 가까웠는데, 이를 마지막 접속일처럼 읽을 수 있으면 장기 미접속 계정 정리 판단이 틀어질 수 있으므로 `최근 콘텐츠 활동``마지막 접속일`을 아예 분리하는 편이 맞다고 판단했다.
- 마지막 접속일은 로그인 성공 순간만 찍으면 장기 세션 사용자를 놓칠 수 있으므로, 세션이 살아 있는 `/api/auth/me` 확인에서도 일정 간격으로 갱신해 실제 접속 흔적에 더 가깝게 유지하는 쪽으로 정리했다.
- 관리자 회원 카드에서 팔로워/즐겨찾기 수치를 보더라도 실제 공개 프로필과 작성 글 구성을 바로 확인할 진입점이 없으면 운영 판단이 끊기므로, `회원 정보 수정` 옆에 `프로필 보기` 버튼을 같이 두는 편이 자연스럽다고 판단했다.
## 2026-04-03 v1.4.54
## 2026-04-03 v1.0.54
- 추천 티어표를 수동으로만 지정할 수 있어도, 운영자가 후보 자체를 찾지 못하면 실무상 큐레이션이 막히므로, 관리자 전체 티어표 목록에 받은 즐겨찾기 수를 직접 보여주고 즐겨찾기 많은 순/최소 즐겨찾기 필터를 먼저 붙이는 편이 맞다고 판단했다.
- 누가 핵심 작성자인지 보는 기준도 작성 티어표 수 하나만으로는 부족하므로, 팔로워 수와 받은 즐겨찾기 수를 회원 관리 카드에 같이 노출하고 이 지표로 정렬할 수 있게 두는 쪽으로 정리했다.
- 이메일 인증/재설정 메일이 들어간 뒤에는 운영자가 평소 화면에서 회원 비밀번호를 직접 덮어쓰는 버튼을 계속 보는 것이 과한 권한처럼 느껴질 수 있으므로, 서버 API는 최후 보루로 남기되 관리자 회원 카드의 비밀번호 초기화 UI는 숨기는 편이 맞다고 판단했다.
## 2026-04-03 v1.4.53
## 2026-04-03 v1.0.53
- 본인 티어표 복사 기능이 타인 티어표 전용 조건으로만 남아 있었지만, 실제 사용에서는 자기 작업본을 변형용 복사본으로 다시 만들고 싶은 경우도 많으므로 저장된 본인 티어표에도 복사 버튼을 여는 편이 맞다고 판단했다.
- 편집 중 저장하지 않은 변경이 있는 상태에서 복사본을 만들 때는 마지막 저장본이 아니라 현재 화면 상태가 복사되기를 기대하기 쉬우므로, 본인 편집본 복사는 복사 직전에 현재 원본을 한 번 저장한 뒤 새 복사본을 만드는 쪽으로 정리했다.
- 팔로우 기능은 처음부터 추천 알고리즘까지 섞기보다, 작성자 프로필과 팔로우 피드라는 명확한 사용자 경로를 먼저 열어두는 편이 제품 구조상 자연스럽다고 보고 `user_follows` 기반 1차 구현을 먼저 붙였다.
- 작성자 프로필 진입점은 목록 카드 내부 작성자 클릭을 바로 분리하면 기존 카드 전체 클릭 문법과 충돌할 수 있으므로, 이번 단계에서는 티어표 편집/뷰어 우측 패널의 `작성자 프로필 보기`를 우선 진입점으로 두고 카드 내부 세부 클릭 분리는 후속 UX로 미루는 편이 안전하다고 판단했다.
## 2026-04-03 v1.4.51
## 2026-04-03 v1.0.51
- 불특정 다수가 만드는 공개 티어표를 전부 같은 선상에 두면 첫 화면 품질 편차가 너무 커질 수 있으므로, 주제별 목록 상단에 관리자 큐레이션 `추천 티어표` 섹션을 두고 아래 일반 공개 목록과 분리하는 편이 맞다고 판단했다.
- 추천 선정은 처음부터 완전 자동화보다 운영자가 직접 지정/해제할 수 있는 수동 큐레이션을 먼저 넣는 편이 안전하므로, 좋아요 수 기반 자동 후보 필터와 팔로우 피드는 후속 작업으로 미루고 이번 릴리스에서는 추천 표시 구조와 관리자 토글만 먼저 완성했다.
- 비공개 글이 추천 섹션에 올라가면 접근 정책이 꼬이므로, 추천 지정은 공개 글만 허용하고 공개글을 비공개로 바꾸면 추천 상태도 함께 해제하는 쪽으로 정리했다.
## 2026-04-03 v1.4.49
## 2026-04-03 v1.0.49
- 프로필 저장 실패를 하나의 일반 실패 메시지로만 보여주면 사용자가 “서버가 고장났나?”라고 오해하기 쉬우므로, 중복 닉네임/예약어 닉네임처럼 사용자가 직접 고칠 수 있는 입력 오류는 원인별 안내를 분리하는 편이 맞다고 판단했다.
- 비밀번호를 잊은 사용자뿐 아니라 로그인 중인 사용자도 보안상 주기적으로 비밀번호를 직접 바꿀 수 있어야 하므로, 설정 화면에 현재 비밀번호 확인 기반 변경 폼을 추가하는 쪽으로 정리했다.
- 비밀번호 재설정 링크는 로그인 세션이 남아 있어도 링크 토큰 자체의 목적이 우선이므로, `/login?resetToken=...` 진입 시에는 기존 자동 리다이렉트보다 재설정 폼 렌더링을 우선하는 편이 맞다고 판단했다.
## 2026-04-03 v1.4.45
## 2026-04-03 v1.0.45
- 실제 서비스에서는 남의 이메일 주소로 가입만 먼저 해두는 문제가 생길 수 있으므로, 일반 회원은 가입 직후 인증 메일을 거쳐야 로그인할 수 있게 하고 비밀번호 분실도 메일 토큰 기반으로 복구하는 구조가 필요하다고 판단했다.
- 다만 초기 운영자가 바로 서비스를 띄울 수 있어야 하므로, 첫 번째 가입 계정만은 기존처럼 이메일 인증 없이 바로 최고 관리자 계정으로 활성화하는 예외를 유지하는 편이 맞다고 정리했다.
- 발신 인프라는 우선 사용자가 준비한 Gmail 계정과 앱 비밀번호로 SMTP를 먼저 붙이고, 도메인 발신 주소와 SPF/DKIM/DMARC는 실제 발송 품질을 본 뒤 Cloudflare DNS에서 후속 정리하는 단계적 접근이 더 현실적이라고 판단했다.
## 2026-04-03 v1.4.44
## 2026-04-03 v1.0.44
- 공통 카피라이트 링크 색을 고정 민트색으로 두면 다크 모드에서는 잘 보이지만 라이트 모드에서 대비가 부족해질 수 있으므로, 테마 텍스트 색을 따라가게 하고 굵기로 링크 인지를 보완하는 편이 더 안정적이라고 판단했다.
## 2026-04-03 v1.4.43
## 2026-04-03 v1.0.43
- Vue Router에서 같은 컴포넌트가 유지된 채 `params/query`만 바뀌는 에디터 이동은 `onMounted()`만으로는 새 데이터를 다시 불러오지 못할 수 있으므로, 에디터 로딩을 라우트 값 watch 기반으로 옮기는 편이 맞다고 판단했다.
- 복사본에서 원본으로 이동하는 액션은 사용자가 편집 중이던 내용을 잃을 수 있으므로, 저장하지 않은 변경이 감지되는 경우에는 바로 이동하지 않고 확인 모달로 한 번 끊어주는 쪽이 안전하다고 정리했다.
## 2026-04-03 v1.4.42
## 2026-04-03 v1.0.42
- 홈 템플릿 목록은 관리자가 아직 수동 순서를 건드리지 않은 신규 템플릿까지 이름순으로 섞이면 “새로 만든 항목이 앞에 보인다”는 운영 기대와 어긋나므로, 수동 순서가 없는 항목은 최신 생성순을 우선하는 정렬이 맞다고 판단했다.
- 티어표 편집 조작은 드래그만으로도 충분하지만, 세밀한 이동이나 터치패드 환경에서는 클릭 선택 후 대상 셀 클릭 방식이 더 편할 수 있으므로 두 조작을 병행 지원하는 쪽으로 확장했다.
- 다만 드래그 직후 click 이벤트가 이어서 들어오면 의도치 않은 재선택이 생길 수 있으므로, 드래그 시작 시 선택을 비우고 드래그 종료 직후 짧은 클릭 잠금을 두는 방식으로 충돌을 줄였다.
## 2026-04-03 v1.4.41
## 2026-04-03 v1.0.41
- 관리자 기본 아이템 업로드는 운영자가 한 번에 많은 캐릭터 이미지를 정리하는 작업이 잦으므로, 서버 개별 파일 제한뿐 아니라 한 요청당 업로드 개수와 프록시 본문 크기 제한도 같이 넉넉하게 올려두는 편이 맞다고 판단했다.
- 다중 업로드가 프런트에서 한 번의 `FormData` 요청으로 묶여 나가는 구조라면, 백엔드 `multer`만 올리고 Nginx `client_max_body_size`를 그대로 두면 병목이 남을 수 있으므로 프런트 프록시 제한도 함께 상향하는 쪽으로 정리했다.
## 2026-04-03 v1.4.40
## 2026-04-03 v1.0.40
- 공유 링크 진입 화면은 사용자가 조작 가능한 편집 화면보다 `뷰어 모드`로 명확히 분리하는 편이 안전하므로, 비로그인 사용자와 타인 티어표는 기본적으로 드래그 없는 완성본 열람 상태를 보여주기로 정리했다.
- 공유 링크를 받은 비로그인 사용자도 다시 전달할 수 있어야 하므로 `공유하기`는 로그인 여부와 무관하게 뷰어 모드에서 열어두고, `내 티어표로 복사`는 로그인한 타인 열람자에게만 노출하는 쪽으로 권한을 나눴다.
- 작성자 본인도 공유 화면이 어떻게 보이는지 확인할 필요가 있으므로, 본인 티어표는 `수정 모드``뷰어 모드`를 양방향 전환할 수 있게 두는 편이 자연스럽다고 판단했다.
- 뷰어 모드 우측 레일은 빈 공간을 크게 남기기보다 다른 화면처럼 광고를 상단에 두고, 실제 행동 버튼은 하단 카드로 모아 시각 균형과 전환 흐름을 함께 맞추는 방향으로 정리했다.
## 2026-04-03 v1.4.39
## 2026-04-03 v1.0.39
- 기존 저장 티어표를 다시 열 때마다 최신 템플릿 아이템으로 전체를 덮어쓰면 과거 결과물이 깨질 수 있으므로, 저장본의 기존 그룹/풀은 보존하고 새로 추가된 템플릿 아이템만 미사용 풀에 합류시키는 병합 방식이 더 안전하다고 판단했다.
- 관리자 템플릿에서 삭제된 기본 아이템을 과거 저장본에서도 자동 삭제하면 사용자 결과물 보존성이 떨어지므로, 삭제는 이후 새 티어표 생성에만 반영하고 기존 저장본은 유지하는 정책을 계속 유지하기로 정리했다.
## 2026-04-03 v1.4.38
## 2026-04-03 v1.0.38
- 최고 관리자 보호는 서버에서만 막고 프런트 버튼은 열어두면 운영자가 계속 실패 액션을 누르게 되므로, 최고 관리자 대상 행은 일반 운영자 화면에서 버튼 자체를 비활성화하는 편이 맞다고 판단했다.
- 예약어 닉네임은 일반 가입/프로필 수정에서는 계속 막아야 하지만, 운영자가 직접 회원 계정을 정리할 때는 `공식`, `운영자` 같은 표기를 의도적으로 부여해야 할 수 있으므로 관리자 수정 API만 예외를 두는 쪽으로 정리했다.
- 공유 프리뷰는 별도 단독 페이지처럼 보이는 것보다 홈과 같은 좌측/우측 레일, 같은 중앙 헤더, 같은 메인 배경 위에 자연스럽게 얹히는 편이 서비스 유입과 탐색 전환에 더 유리하다고 판단해, 프리뷰 전용 셸을 공통 앱 셸로 흡수했다.
## 2026-04-02 v1.4.33
## 2026-04-02 v1.0.33
- 서비스 공개 전 단계에서는 가입 자체를 열어두는 것보다, 이메일/닉네임 중복과 운영자 사칭성 닉네임을 먼저 막아두는 편이 훨씬 중요하다고 판단했다.
- 닉네임 제한은 회원가입 한 곳에만 두면 이후 프로필 수정이나 관리자 수정으로 쉽게 우회되므로, auth/profile/admin 수정 흐름 전부가 같은 예약어 정책을 공유하도록 정리했다.
- 라이트 모드는 취향상 필요한 사용자가 있을 수 있으므로 완전히 제거하기보다, 기본값만 다크로 고정하고 설정 화면에서만 직접 토글하도록 두는 편이 더 균형 잡힌 선택이라고 정리했다.
## 2026-04-02 v1.4.32
## 2026-04-02 v1.0.32
- 서비스 공개 전 마감 단계에서는 사용자 노출 텍스트만이 아니라 파일명·composable 이름·관리자 CSS 클래스·백엔드 헬퍼 함수명까지 같이 정리해 두는 편이 이후 유지보수 비용을 확실히 낮춘다고 판단했다.
- 이 시점부터는 `game`이 데이터 호환층도 아닌 단순 내부 이름으로 남아 있는 것조차 혼란을 만들 수 있으므로, 실제 기능을 바꾸지 않는 선에서 이름층까지 끝까지 정리해 코드 검색 결과 자체를 깨끗하게 만드는 방향으로 마감했다.
## 2026-04-02 v1.4.31
## 2026-04-02 v1.0.31
- 서비스가 아직 외부 공개 전이고 예전 북마크/예전 데이터베이스를 이어갈 필요가 없다는 전제가 확인되었으므로, 남겨둔 호환층을 유지하는 것보다 지금 마감 시점에 완전히 제거해 구조를 단순화하는 편이 맞다고 판단했다.
- 이 단계에서는 “기존 것도 읽어준다”보다 “현재 구조만 남긴다”가 더 중요한 목표가 되었으므로, redirect·legacy migration·`origin: 'game'` 허용까지 함께 정리해 실제 코드 검색에서 `game` 흔적을 0건으로 맞추는 방향으로 마감했다.
## 2026-04-02 v1.4.30
## 2026-04-02 v1.0.30
- 로컬 MariaDB는 테스트용으로 새로 밀어도 된다는 전제가 확인되었으므로, 개발 환경에서는 기존 데이터를 끌고 가는 것보다 현재 스키마가 “빈 DB에서 바로 정상 부팅되는지”를 먼저 검증하는 편이 더 가치 있다고 판단했다.
- `origin: 'game'` 호환층은 즉시 제거하기보다, `ensureData()`에서 저장 데이터와 요청 스냅샷을 자동 정규화하게 만들어 두고 새 DB에서도 legacy 값이 생기지 않는 상태를 먼저 확인한 뒤 다음 제거 판단으로 넘기는 편이 가장 안전하다고 정리했다.
## 2026-04-02 v1.4.29
## 2026-04-02 v1.0.29
- `origin: 'game'`는 이미 저장된 티어표 데이터와 직접 맞물리므로, 이 단계에서는 새 데이터 기본값만 `template`로 옮기고 예전 값도 계속 받아주는 점진 호환이 가장 안전하다고 판단했다.
- 아이템 라이브러리의 `linkedGames`는 실제 의미가 템플릿 연결 정보이므로, 이 응답 키까지 `linkedTemplates`로 바꿔두는 편이 이후 관리자 유지보수에서 훨씬 덜 헷갈린다고 정리했다.
## 2026-04-02 v1.4.28
## 2026-04-02 v1.0.28
- 이 시점 이후 코드 검색에 남는 `game`는 대부분 레거시 데이터 마이그레이션, 옛 주소 redirect, 저장 데이터의 `origin` 호환처럼 의도된 층이므로, 무리하게 전부 0으로 만들기보다 기능을 깨뜨리지 않는 선에서 의미 있는 이름층만 더 줄이는 편이 맞다고 판단했다.
- 관리자 화면 내부 상태명(`selectedTemplate.game`, `isGameLoading`, `gameVisibilitySaving`)은 실제 기능 의미와 어긋나므로, QA 전에 한 번 더 `template` 기준으로 옮겨두는 편이 이후 유지보수에 더 유리하다고 정리했다.
## 2026-04-02 v1.4.27
## 2026-04-02 v1.0.27
- 공개/관리자 API 표면까지 `topic/template`로 정리된 뒤에는, 관리자 내부 상태 이름과 DB export alias에 남은 `game` 흔적도 계속 유지할 이유가 작아졌으므로 이 단계에서 함께 걷어내는 편이 맞다고 판단했다.
- 다만 외부에서 직접 참조할 수 있는 공개 북마크와 달리, `adminGames`, `game-admin`, `favoriteGame` 같은 이름은 내부 구현 용어라서 이번 단계에서 정리해도 위험이 낮다고 정리했다.
## 2026-04-02 v1.4.26
## 2026-04-02 v1.0.26
- `topic/template` 표면을 거의 마감한 시점에서는 관리자 API와 관리자 화면 경로까지 계속 `/games` alias를 유지하는 편보다, 실제 사용하는 `templates` 경로만 남기고 예전 관리자 주소는 redirect로만 정리하는 편이 더 일관되고 안전하다고 판단했다.
- 공개 사용자 북마크는 여전히 `/games -> /topics` redirect가 필요하지만, 백엔드 API의 `/api/games`까지 계속 유지할 이유는 작아졌으므로 이 단계에서 `/api/topics`만 남기는 편이 맞다고 정리했다.
## 2026-04-02 v1.4.25
## 2026-04-02 v1.0.25
- 이제 프런트와 백엔드 소비층이 `topic/template`를 기본으로 읽을 준비가 되었으므로, 응답과 payload에 `gameId / gameName` 호환 키를 오래 남기는 것보다 실제 표면을 먼저 정리하는 편이 더 낫다고 판단했다.
- 다만 오래된 외부 링크까지 한 번에 끊는 건 위험하므로, 이번 단계에서는 데이터/응답/프런트 소비는 `topic`으로 마감하되 `/games/:gameId`와 관리자 route alias 같은 레거시 주소만 마지막 호환 레이어로 남기는 점진 종료가 가장 안전하다고 정리했다.
## 2026-04-02 v1.4.24
## 2026-04-02 v1.0.24
- `topic/template` 소비층이 이미 정리된 상태라면, 공개 주제 API와 관리자 템플릿 API 응답도 이제는 `game` 키를 기본으로 유지할 이유가 크지 않으므로 새 의미 키만 기본으로 내보내는 편이 맞다고 판단했다.
- 다만 관리자 화면 내부 상태 구조를 한 번에 뒤집는 건 위험하므로, 응답은 줄이되 `selectedTemplate.game`처럼 화면 구조에 깊게 퍼진 부분은 프런트에서 한 번 정규화해 받는 점진 방식이 가장 안전하다고 정리했다.
## 2026-04-02 v1.4.23
## 2026-04-02 v1.0.23
- 프런트가 이미 `topic/template` 메서드만 실제로 쓰고 있다면, `api.js` 안에 남은 레거시 `game` 별칭까지 계속 유지하는 건 오히려 정리 상태를 흐리므로 이 단계에서 정리하는 편이 맞다고 판단했다.
- 티어표 저장과 템플릿 요청처럼 핵심 생성 흐름은 백엔드 내부 payload도 먼저 `topicId` 기준으로 맞춰 두는 편이, 이후 응답 호환 키를 걷어낼 때 충격을 더 줄인다고 정리했다.
## 2026-04-02 v1.4.22
## 2026-04-02 v1.0.22
- 내부 함수명과 export를 정리한 뒤에도 라우트 파일명이 계속 `games.js`로 남아 있으면 마지막까지 개념 충돌을 남기게 되므로, 공개 주제 라우트 파일명도 실제 의미에 맞게 `topics.js`로 옮기는 편이 맞다고 판단했다.
- `/api/games` 호환 경로는 유지하더라도, 서버 내부 구현만큼은 `topic` 기준 param 이름과 파일 이름으로 정리해 두는 편이 이후 레거시 제거를 훨씬 더 쉽게 만든다고 정리했다.
## 2026-04-02 v1.4.21
## 2026-04-02 v1.0.21
- 백엔드에서 `topic/template` 응답을 내보내더라도 프런트가 계속 `game` 키만 읽으면 호환 레이어가 끝나지 않으므로, 이번 단계부터는 실제 사용자 화면과 관리자 저장 흐름도 새 키를 우선 읽게 맞추는 편이 맞다고 판단했다.
- 이 구간은 외부 API를 끊는 작업이 아니라 “프런트가 새 의미를 먼저 받아들이는 단계”이므로, 기존 `game` 키는 fallback으로만 남겨 두고 단계적으로 걷어내는 편이 가장 안전하다고 정리했다.
## 2026-04-02 v1.4.20
## 2026-04-02 v1.0.20
- 스키마만 `topic`으로 옮기고 함수명/라우트 내부가 계속 `game`으로 남아 있으면 이후 유지보수에서 계속 의미 충돌이 생기므로, 이번 단계부터는 백엔드 export와 주요 라우트 내부 이름도 `topic/template`를 기본으로 읽히게 정리하는 편이 맞다고 판단했다.
- 다만 외부 API와 프런트 호환을 한 번에 끊는 건 위험하므로, 실제 구현은 새 `topic` 이름을 기본으로 쓰되 기존 `game` 이름은 alias와 호환 응답으로 잠시 유지하는 점진 전환이 가장 안전하다고 정리했다.
## 2026-04-02 v1.4.19
## 2026-04-02 v1.0.19
- 템플릿 기본 아이템 삭제가 과거에 저장된 티어표까지 바꿔 버리면 운영자 편집 의도보다 사용자 결과물 보존이 더 크게 흔들리므로, 이 삭제는 “앞으로의 템플릿 구성만 바꾸고 기존 저장본은 보존”하는 편이 맞다고 판단했다.
- 다만 이미 여러 티어표에서 쓰인 아이템인지 모른 채 지우게 두는 것도 위험하므로, 삭제 자체를 막기보다는 저장된 티어표 사용 개수와 공개/비공개 범위를 먼저 보여주고 운영자가 맥락을 알고 결정하게 하는 쪽이 더 현실적이라고 정리했다.
## 2026-04-02 v1.4.18
## 2026-04-02 v1.0.18
- 새 창 열기처럼 브라우저 기본 앵커가 충분한 동작은 템플릿 안에서 `window.open`을 직접 부르기보다, 기본 링크 동작에 맡기는 편이 더 단순하고 안전하다고 판단했다.
## 2026-04-02 v1.4.17
## 2026-04-02 v1.0.17
- `editor` 주소는 이전과 현재가 같은 URL 형태를 공유하므로, 여기까지 redirect를 두면 호환성이 아니라 자기 자신으로의 재해석만 반복하게 된다. 이 구간은 별도 레거시 레코드를 두지 않고 현재 라우트 하나로 수용하는 편이 맞다고 판단했다.
## 2026-04-02 v1.4.16
## 2026-04-02 v1.0.16
- 백엔드/DB 장애 상황을 단순 연결 실패처럼 보여주면 사용자가 원인을 잘못 이해하게 되므로, 네트워크 단절과 서버 점검/초기화 실패를 전역 UI에서 분리해서 안내하는 편이 맞다고 판단했다.
- 이런 장애 안내는 각 화면별 에러 문구를 따로 손보는 것보다 `api` 공통 계층에서 상태를 감지하고 `App` 셸이 한 번에 전환하는 구조가 재사용성과 유지보수 측면에서 더 안전하다고 정리했다.
## 2026-04-02 v1.4.15
## 2026-04-02 v1.0.15
- 실제 운영 DB에서 마지막 500 원인을 먼저 재현해본 결과, 스키마 설계보다 MariaDB의 `SHOW ... LIKE ?` 플레이스홀더 비호환과 부분 마이그레이션 상태 재진입 이슈가 핵심이었으므로, 이 단계에선 구조 변경보다 기동 안정성을 먼저 회복하는 편이 맞다고 판단했다.
- 마이그레이션 로직은 “처음 실행”뿐 아니라 “반쯤 적용된 상태에서 다시 실행”도 견뎌야 하므로, 컬럼 존재 확인과 조건 분기를 모두 공용 `information_schema` 검사로 모으는 편이 더 안전하다고 정리했다.
## 2026-04-02 v1.4.14
## 2026-04-02 v1.0.14
- 기존 `/games` 주소 호환은 alias보다 redirect가 더 맞다고 판단했다. 이번 단계에선 주소는 유지하되 라우트 파라미터 의미는 항상 `topicId`로 정규화해 Vue Router 경고와 내부 분기를 함께 줄였다.
- 운영 DB에 직접 `RENAME TABLE`과 컬럼 `CHANGE`를 거는 방식은 실제 환경에서 실패 여지가 커서, 마지막 스키마 전환도 새 topic 스키마를 먼저 만들고 기존 game 데이터를 복사하는 비파괴 마이그레이션이 더 안전하다고 정리했다.
## 2026-04-02 v1.4.13
## 2026-04-02 v1.0.13
- 사용자 표면과 API 이름층까지 `topic/template`로 옮긴 뒤에는, DB 스키마도 실제로 따라오게 해야 이후 유지보수 비용이 덜 쌓이므로 `games` 계열 실명을 `topics` 계열로 마이그레이션하는 편이 맞다고 판단했다.
- 다만 한 번에 응답 키까지 완전히 끊으면 프런트와 관리자 흐름이 너무 크게 흔들릴 수 있으므로, 이번 단계에서는 실제 저장 스키마는 `topic`으로 옮기고 응답의 `gameId / gameName`은 호환 키로 잠시 함께 유지하는 점진 마감이 가장 안전하다고 정리했다.
## 2026-04-02 v1.4.12
## 2026-04-02 v1.0.12
- 프런트 이름만 바꾸는 단계가 끝난 뒤에는, 백엔드도 새 `/api/topics`, `/api/admin/templates` 경로를 열고 기존 `/games`는 호환용으로 남기는 점진 전환이 가장 안전하다고 판단했다.
## 2026-04-02 v1.4.11
## 2026-04-02 v1.0.11
- 백엔드 `/api/games` 경로를 바로 바꾸기보다, 프런트 API 객체에서 먼저 `topic/template` 의미 이름을 제공하고 호출부를 옮기는 편이 위험이 훨씬 낮다고 판단했다.
## 2026-04-02 v1.4.10
## 2026-04-02 v1.0.10
- 사용자 주소는 이미 `/topics`로 옮기기 시작했으므로, 라우트 이름과 기본 파라미터도 `topicHub / topicId` 기준으로 맞추고 기존 `gameId`는 호환 fallback으로만 남기는 편이 더 자연스럽다고 판단했다.
## 2026-04-02 v1.4.9
## 2026-04-02 v1.0.9
- 경로 전환은 화면마다 문자열을 직접 고치는 방식보다, 공용 경로 헬퍼를 먼저 세워 주제·에디터·로그인 리다이렉트 흐름을 한 기준으로 묶는 편이 이후 리네이밍 비용을 훨씬 줄인다고 판단했다.
## 2026-04-02 v1.4.8
## 2026-04-02 v1.0.8
- 주제 상세 화면 제목은 내부 ID를 잠깐 보여주는 것보다, 로딩 문구를 거쳐 실제 이름으로 자연스럽게 바뀌는 편이 사용자 체감상 더 안정적이라고 판단했다.
- 주요 목록 화면은 `pageHead` 문법을 계속 통일해 두는 편이, 이후 검색/필터 툴바를 더 붙이더라도 구조를 예측하기 쉽다고 판단했다.
## 2026-04-02 v1.4.7
## 2026-04-02 v1.0.7
- 주제 상세 컬렉션 화면도 즐겨찾기·나의 티어표와 같은 `pageHead` 문법으로 맞춰야, 네비게이션으로 이동하는 주요 화면들의 리듬이 더 자연스럽다고 판단했다.
- 라우트 전환은 한 번에 `/games`를 없애기보다, 먼저 `/topics`를 기본 진입 경로로 세우고 기존 `/games`는 alias로 유지하는 점진 전환이 더 안전하다고 정리했다.
## 2026-04-02 v1.4.6
## 2026-04-02 v1.0.6
- 내부 리네이밍 2단계는 관리자 화면처럼 상태와 액션이 많은 영역부터 정리해 두는 편이, 이후 `/games` 라우트와 API 계층을 손볼 때 위험을 줄이는 데 더 유리하다고 판단했다.
## 2026-04-02 v1.4.5
## 2026-04-02 v1.0.5
- 내부 리네이밍은 한 번에 API와 DB까지 건드리기보다, 홈·주제 화면·에디터처럼 영향 범위가 비교적 명확한 프런트 핵심 흐름부터 `game` 의존 이름을 줄여 나가는 편이 더 안전하다고 판단했다.
## 2026-04-02 v1.4.4
## 2026-04-02 v1.0.4
- 용어 정리 마무리 단계에서는 눈에 잘 띄는 영어 헤더를 그대로 두기보다, 홈과 관리자처럼 진입 빈도가 높은 화면의 상단 라벨까지 한국어로 맞춰야 전체 제품 인상이 더 자연스럽다고 판단했다.
## 2026-04-02 v1.4.3
## 2026-04-02 v1.0.3
- 용어 전환은 메뉴 타이틀만 바꾸는 것으로 끝나지 않고, 관리자 작업 중 반복해서 보게 되는 토스트와 확인창까지 맞춰야 실제 체감 일관성이 살아난다고 판단했다.
## 2026-04-02 v1.4.2
## 2026-04-02 v1.0.2
- 용어 정리를 시작한 뒤에는 일부 화면만 바꾸는 것보다, 관리자 모달과 확인 메시지처럼 실제 운영 중 많이 보는 문구도 함께 맞춰 주는 편이 체감 일관성이 더 높다고 판단했다.
## 2026-04-02 v1.4.1
## 2026-04-02 v1.0.1
- 좌측 메뉴와 화면 타이틀의 명칭이 서로 다르면 사용자가 현재 위치를 직관적으로 매칭하기 어렵기 때문에, 메뉴 이름과 진입 타이틀을 같은 문구로 맞추는 편이 맞다고 판단했다.
## 2026-04-02 v1.4.0
## 2026-04-02 v1.0.0
- 서비스가 게임 외 주제 전반을 다룰 수 있는 단계에 온 만큼, 내부 모델명은 유지하더라도 사용자에게 보이는 주요 용어는 `주제 / 템플릿` 기준으로 먼저 정리하는 편이 맞다고 판단했다.
- 대규모 내부 리네이밍은 API와 DB까지 손대야 하므로, 이번 단계에서는 사용자 화면 문구만 우선 바꾸고 내부 `game` 모델은 그대로 두는 점진적 전환이 더 안전하다고 정리했다.
## 2026-04-02 v1.3.93
## 2026-04-02 v0.3.93
- 목록 카드 썸네일은 드래그 대상이 아니라 클릭 대상에 가깝기 때문에, 브라우저 기본 이미지 드래그 프리뷰는 전부 막아 두는 편이 UX 측면에서 맞다고 판단했다.
## 2026-04-02 v1.3.92
## 2026-04-02 v0.3.92
- 왼쪽 레일 활성 메뉴도 로그인 토글과 같은 이동형 배경 문법을 쓰는 편이 앱 전체 인터랙션 언어를 더 일관되게 만든다고 판단했다.
## 2026-04-02 v1.3.91
## 2026-04-02 v0.3.91
- 로그인/회원가입 탭은 즉시 배경 교체보다, 선택 배경이 실제로 이동하는 토글 문법이 더 직관적이고 상태 전환이 잘 읽힌다고 판단했다.
## 2026-04-02 v1.3.90
## 2026-04-02 v0.3.90
- 경고 수준의 CSS 진단이라도 실제 의미 없는 속성이나 벤더 전용 속성 누락이라면 바로 정리해 두는 편이 이후 유지보수 피로를 줄인다고 판단했다.
## 2026-04-02 v1.3.89
## 2026-04-02 v0.3.89
- 더 이상 참조되지 않는 Vite 기본 자산과 레거시 public 아이콘 묶음은 남겨둘수록 혼동만 커지므로, 실제 사용 파일만 남기고 정리하는 편이 맞다고 판단했다.
- 공유용 썸네일은 코드 수정과 별개로 시각 자산 손질이 자주 일어날 수 있으므로, 이번처럼 워크트리에 이미 반영된 최신 이미지 수정본은 함께 릴리스에 포함하는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.88
## 2026-04-02 v0.3.88
- 헤더의 `by zenn`은 이미 공통 카피라이트 링크가 생긴 뒤 역할이 겹치므로, 브랜드 영역은 서비스명 중심으로 정리하는 편이 맞다고 판단했다.
- 외부 공유 미리보기는 메타 태그만 넣는 것보다 실제 전용 썸네일 자산을 함께 두는 편이 메신저/소셜/모바일 홈 화면까지 더 안정적으로 동작한다고 정리했다.
- 파비콘은 인라인 data URL 하나에 의존하기보다 `svg + png + apple-touch-icon` 조합으로 두는 편이 브라우저와 기기 호환성 측면에서 안전하다고 판단했다.
## 2026-04-02 v1.3.86
## 2026-04-02 v0.3.86
- 아이콘 크기는 이미지 다운로드 결과에만 반영되고 저장본에는 남지 않으면 사용자가 체감상 “저장되지 않는 설정”으로 느끼게 되므로, 티어표 본문 설정으로 저장하는 편이 맞다고 정리했다.
- 저장 경로를 고친 뒤에도 프리뷰 화면이 기본값으로 보인다면, 데이터보다 프런트 렌더링 루트에 동일 CSS 변수가 전달되는지 먼저 확인하는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.83
## 2026-04-02 v0.3.83
- 모바일에서 열 헤더가 칸과 시각적으로 분리되는 문제는 전체 레이아웃을 다시 갈아엎기보다, 각 칸 안에 열 이름 배지를 같이 보여주는 편이 가장 적은 변경으로 효과를 낸다고 정리했다.
- 배지를 쓰는 반응형 구간에서는 기존 상단 열 헤더까지 남겨두면 중복 정보가 되므로, 같은 브레이크포인트에서 헤더는 숨기고 칸 배지 하나만 남기는 편이 맞다고 정리했다.
- 반응형 보정은 한 미디어 구간 안에서 서로 다른 규칙이 다시 덮어쓰지 않게 정리해야 하므로, 모바일용 `1fr` 레이아웃을 선언한 뒤 예전 `140px/150px` 규칙은 제거하는 편이 맞다고 판단했다.
## 2026-04-02 v1.3.82
## 2026-04-02 v0.3.82
- 프리뷰 완성본도 결국 공유/열람용 결과물이므로, 이미지 다운로드 결과와 같은 작성자/저장 시각 메타를 같이 보여주는 편이 자연스럽다고 정리했다.
- 관리자 템플릿 요청 카드는 “요청 티어표 보기”가 실제로 새창 이동용이라면 하단 버튼과 썸네일 클릭을 둘 다 유지하기보다, 썸네일 클릭 하나로 통합하는 편이 더 단순하고 직관적이라고 판단했다.
## 2026-04-02 v1.3.81
## 2026-04-02 v0.3.81
- 저장된 티어표 공유는 별도 새 페이지를 만들기보다, 이미 완성본 열람에 쓰고 있는 `preview=1` 주소를 그대로 공유 링크로 재사용하는 편이 가장 단순하고 일관적이라고 정리했다.
- 공유 액션은 저장/삭제처럼 저장본 전제의 보조 기능이므로, 메인 저장 버튼 영역보다 하단 유틸리티 링크 영역에 두는 편이 더 자연스럽다고 판단했다.
## 2026-04-02 v1.3.79
## 2026-04-02 v0.3.79
- 카피라이트처럼 앱 전체 브랜딩 성격의 footer는 관리자 텔레포트 안에 두기보다, `App.vue`의 공통 오른쪽 레일 footer로 두는 편이 위치도 안정적이고 화면 간 일관성도 높다고 정리했다.
## 2026-04-02 v1.3.78
## 2026-04-02 v0.3.78
- 축소 상태에서는 텍스트가 사라지므로 같은 `티어표 만들기` 계열 액션이라도 커스텀 제작과 템플릿 기반 제작을 아이콘으로 구분해 주는 편이 맞다고 정리했다.
- 관리자 우측 카피라이트처럼 “사이드바 하단”에 붙어야 하는 정보는 텔레포트 루트의 형제 노드로 두기보다, 실제 사이드바 컨테이너 내부의 마지막 행으로 두는 편이 레이아웃상 안전하다고 판단했다.
## 2026-04-02 v1.3.77
## 2026-04-02 v0.3.77
- 왼쪽 레일을 접었을 때 하단 액션을 완전히 숨기면 `새 티어표 만들기` 진입점이 사라지므로, 펼친 상태의 하단 위치는 유지하되 축소 상태에서는 같은 위치의 아이콘 전용 버튼으로 바꿔 남겨두는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.76
## 2026-04-02 v0.3.76
- 왼쪽 사이드 레일을 접었을 때는 텍스트가 사라진 뒤에도 행 높이가 제각각이면 아이콘 전용 탐색기로 읽히지 않으므로, 아바타/검색/내비 항목의 높이를 같은 규격으로 통일하는 편이 맞다고 정리했다.
- 왼쪽 레일 검색은 화면에 따라 티어표 검색으로 바뀌면 사용자가 사이드 검색과 메인 검색 역할을 구분하기 어려우므로, 사이드는 게임 검색으로 고정하고 티어표 검색은 메인 화면 문맥에 맡기는 편이 더 자연스럽다고 판단했다.
## 2026-04-02 v1.3.75
## 2026-04-02 v0.3.75
- 관리자 공용 모달은 기본 카드 여백을 계속 쓰되, 내부에 자체 셸을 가진 대형 상세 모달까지 같은 패딩을 강제로 받으면 오히려 레이아웃이 무너지므로 예외 클래스로 분리하는 편이 맞다고 정리했다.
- 관리자 표기 링크는 텍스트만 두기보다, 추후 주소 변경이 쉬운 한 곳짜리 상수와 새 창 링크로 관리하는 편이 운영 측면에서 더 낫다고 판단했다.
- 왼쪽 사이드 레일 접힘 상태는 요소를 좁히는 것만으로는 높이와 정렬 문제가 계속 남으므로, 메타 텍스트는 실제로 숨기고 아이콘 중심 문법으로 따로 정리하는 편이 맞다고 판단했다.
## 2026-04-02 v1.3.74
## 2026-04-02 v0.3.74
- 관리자 공용 게임 선택 모달은 단순 검색만 제공하기보다, 현재 문맥에서 이미 선택 불가능한 대상을 `이미 추가됨`으로 명시하고 막아 주는 편이 운영 실수를 줄이는 데 더 효과적이라고 정리했다.
- 프로젝트 표기는 관리자 헤더 상단보다 사이드바 최하단의 작은 카피라이트 문구로 빼는 편이 정보 밀도를 덜 방해한다고 판단했다.
## 2026-04-02 v1.3.73
## 2026-04-02 v0.3.73
- 게임 선택이 여러 관리자 화면에 퍼지기 시작한 시점에서는 일부 화면만 셀렉트나 내부 리스트를 유지하기보다, 공용 검색 모달 하나로 통일하는 편이 장기적으로 더 일관되고 확장에 강하다고 정리했다.
- 검색 입력과 실행 버튼은 세로로 같은 문법으로 쌓기보다, 입력은 입력끼리 실행은 액션으로 읽히게 한 줄 배치로 적당히 구분해주는 편이 운영 화면에서 덜 답답하다고 판단했다.
## 2026-04-02 v1.3.72
## 2026-04-02 v0.3.72
- 라우트 복원용 watcher가 composable 반환값 초기화보다 먼저 돌 수 있는 구간에서는 직접 함수를 즉시 호출하기보다, 초기화 완료 뒤 실행되도록 한 템포 미루는 편이 안전하다고 정리했다.
## 2026-04-02 v1.3.71
## 2026-04-02 v0.3.71
- 관리자에서 게임 선택 지점이 늘어나는 구조라면 각 화면마다 셀렉트/긴 리스트를 따로 두기보다, 공용 검색 모달 하나로 통일하는 편이 이후 100개 이상 게임이 쌓여도 더 안정적이라고 정리했다.
- 아이템 모달은 참조 정보 정리 뒤에도 왼쪽 선택 요약 카드가 여전히 과하다고 판단해, 예전처럼 게임 선택 자체에 더 집중한 구조로 한 단계 더 되돌리는 편이 맞다고 판단했다.
## 2026-04-02 v1.3.70
## 2026-04-02 v0.3.70
- 관리자 티어표 목록은 페이지 단위 열람만으로는 운영 개입이 부족하므로, 게임별 필터와 카드 단위 관리 액션을 함께 붙여 실제 검수 도구로 쓰는 편이 맞다고 정리했다.
- 공개 여부는 문장 속 메타보다 배지로 따로 떼어 보여주는 편이 다른 관리자 카드들과 문법이 맞고, 공개/비공개 전환도 같은 관리 모달 안에서 바로 처리하는 쪽이 운영 흐름상 자연스럽다고 판단했다.
## 2026-04-02 v1.3.69
## 2026-04-02 v0.3.69
- 관리자 아이템 라이브러리의 참조 수는 저장 구조 설명에는 도움이 되지만 실제 운영에서는 대부분 의미가 약하므로, 카드와 모달에서 계속 전면에 두기보다 다시 숨기고 필요한 경우 내부 데이터로만 남기는 편이 맞다고 정리했다.
- 관리자 상단 요약 수치는 `활성/대기` 같은 상태 문구보다 실제 운영 판단에 바로 쓰이는 `공개/비공개 개수`가 더 중요하므로, 게임 관리와 티어표 관리 모두 공개 상태 집계를 먼저 보여주는 편이 낫다고 판단했다.
- 공개/비공개 수치는 현재 페이지 일부를 세면 오해가 생기기 쉬우므로, 검색/게임 기준 전체 결과를 집계하는 별도 관리자 API로 계산하는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.68
## 2026-04-02 v0.3.68
- 관리자 아이템 상세는 새 모달을 겹쳐 올리는 방식보다 기존 모달 안에서 `왼쪽 선택 대상 / 오른쪽 작업과 참조 정보` 역할만 분명히 나누는 편이 더 안정적이라고 정리했다.
- 같은 이미지를 두 위치에서 반복 노출하면 “모달이 두 개 겹친 것처럼” 느껴질 수 있으므로, 선택 썸네일은 한 곳에만 두고 양쪽 패널은 각자 스크롤되는 구조로 정리하는 편이 맞다고 판단했다.
## 2026-04-02 v1.3.67
## 2026-04-02 v0.3.67
- 같은 이미지 공유 구조는 저장 효율에는 유리하지만 운영자가 관계를 읽기 어렵기 때문에, 카드 단계에서는 참조 수를 바로 보여주고 상세 모달에서는 같은 `src`를 가리키는 기록들을 함께 펼쳐 보여주는 편이 맞다고 정리했다.
- 삭제 제한을 과하게 두기보다, 삭제 전 영향 범위를 문구와 개수로 먼저 보여주는 쪽이 운영 측면에서 더 현실적이라고 판단했다.
## 2026-04-02 v1.3.66
## 2026-04-02 v0.3.66
- 누락 참조 정리 도구는 커스텀 아이템 누락이 없어도 티어표/요청 썸네일 누락을 항상 따로 정리해야 하므로, 썸네일 정리를 커스텀 아이템 분기에 묶어두면 안 된다고 정리했다.
## 2026-04-02 v1.3.65
## 2026-04-02 v0.3.65
- 누락 파일 수치가 계속 쌓이는 상태에서는 원인 분석만으로는 운영 부담이 줄지 않으므로, 실제 파일이 없는 참조만 골라 썸네일/아바타/게임 아이템/커스텀 아이템 참조를 정리하는 관리자 액션을 제공하는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.64
## 2026-04-02 v0.3.64
- 관리자 이미지 최적화 기록은 “재사용” 자체보다 이번 업로드와 재사용 자산의 관계가 더 중요하므로, 용량 숫자와 설명을 함께 보여주는 편이 운영자가 오해하지 않기 쉽다고 정리했다.
- 관리자 아이템 라이브러리는 `사용자 업로드 미사용만` 같은 단일 체크박스보다 출처와 사용 상태를 함께 고를 수 있는 필터 모드가 더 실용적이므로, 사용자·템플릿·보관 자산·미사용 상태를 분리해 보는 구조가 맞다고 판단했다.
- 게임 목록이 커질수록 선택 게임 설정을 사이드바 하단에 두는 구조는 스크롤 부담이 커지므로, 공개 상태와 썸네일 관리 액션은 선택된 게임 본문 상단 카드로 올리는 편이 더 안정적이라고 정리했다.
## 2026-04-02 v1.3.63
## 2026-04-02 v0.3.63
- 이미지 최적화 기록은 내부 라우트 카테고리를 그대로 보여주면 운영자가 실제 의미를 해석해야 하므로, 관리자 화면에는 기능 기준의 한국어 라벨과 재사용 여부를 함께 보여주는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.62
## 2026-04-02 v0.3.62
- 커스텀 이미지가 많은 상태에서 저장할 때 사용자 체감 순서가 흔들리는 것은 업로드 성공보다 더 직접적인 UX 문제이므로, 내부 객체 키 순서가 아니라 현재 화면 배치 순서를 저장 기준으로 삼는 편이 맞다고 정리했다.
- 템플릿 요청이 저장본에서만 가능하다면 삭제도 같은 기준을 따르는 편이 흐름상 자연스러우므로, 저장되지 않은 초안에는 삭제 액션을 노출하지 않는 쪽으로 판단했다.
## 2026-04-02 v1.3.61
## 2026-04-02 v0.3.61
- 업로드 드롭존은 기능만 같고 생김새가 다르면 운영자와 사용자 모두 맥락 전환 비용이 생기므로, 관리자와 에디터에서 같은 아이콘·점선 보더·버튼 문법으로 읽히게 맞추는 편이 낫다고 정리했다.
- 썸네일 교체 영역은 일반 입력 필드처럼 보이면 클릭 가능성이 떨어지므로, 이미지 미리보기 위에서도 업로드 박스라는 인상이 유지되게 밝은 배경과 아이콘을 함께 쓰는 쪽으로 판단했다.
## 2026-04-02 v1.3.60
## 2026-04-02 v0.3.60
- 관리자 접근 차단은 유지하되, 이미 로그인된 관리자가 새로고침할 때 홈으로 튕기는 체감은 권한 제어보다 더 큰 문제이므로 인증 결과가 나올 때까지 같은 세션 확인 요청을 기다리는 편이 맞다고 정리했다.
- 관리자 썸네일 드롭존과 에디터 보드 드롭존은 기능은 같아도 현재 상태가 문구와 형태로 바로 드러나야 하므로, 빈 상태와 교체 상태를 텍스트로 구분하고 점선 박스 형태를 더 적극적으로 드러내는 쪽으로 판단했다.
## 2026-04-02 v1.3.59
## 2026-04-02 v0.3.59
- 템플릿 요청 아이템 반영은 관리자 주의에만 맡기기보다, 서버에서 같은 게임 안의 동일 `src` 중복 생성을 막고 프런트에서도 이미 반영된 요청 아이템을 다시 드래프트에 올리지 않는 편이 운영 안정성에 더 낫다고 정리했다.
- 신규 템플릿 요청은 “완전한 임시 게임” 구조로 바로 바꾸기보다, 우선 한 번 만든 게임을 요청과 연결해 다시 `확인하기`를 눌러도 같은 게임을 재사용하게 만드는 편이 리스크 대비 효과가 크다고 판단했다.
- 신규 템플릿 요청 카드는 생성 여부가 관리자의 머릿속 상태가 아니라 UI 메타로 드러나야 하므로, `연결된 게임 있음/없음``이미 반영 n개`를 카드와 작업 패널 양쪽에서 함께 보여주는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.55
## 2026-04-02 v0.3.55
- 관리자 요청/업로드 배지는 문구만 다르면 빠르게 구분하기 어려우므로, 같은 `pill` 구조를 유지하되 색으로도 역할을 나누는 편이 운영 판단에 더 적합하다고 정리했다.
- 신규 템플릿 요청으로 새 게임을 만들 때는 아이템만 가져오고 썸네일이 비어 있으면 식별성이 떨어지므로, 요청 썸네일도 기본값으로 함께 승계하는 편이 맞다고 판단했다.
## 2026-04-02 v1.3.54
## 2026-04-02 v0.3.54
- 관리자 요청 카드는 운영자가 이미 흐름을 알고 있다는 전제에서, 설명형 힌트보다 즉시 판단에 필요한 메타와 액션만 남기는 편이 더 적합하다고 정리했다.
- 요청 종류 표시는 중복 텍스트보다 오른쪽 상단의 짧은 상태 배지 하나로 고정하고, 하단 액션 줄은 `보조 링크는 왼쪽 / 실제 처리 버튼은 오른쪽` 구조가 더 읽기 쉽다고 판단했다.
## 2026-04-02 v1.3.53
## 2026-04-02 v0.3.53
- 관리자 후속 리팩터링은 남은 큰 액션 묶음인 `상단 고정 게임 정렬``커스텀 아이템 검수`부터 composable로 분리하는 편이 `AdminView.vue` 체감 복잡도를 가장 빨리 낮춘다고 판단했다.
- 이 단계에서도 레이아웃이나 문구보다 로직 책임 경계를 먼저 옮기고, 실제 스타일 파일 분리는 그 다음 단계로 이어가는 편이 안전하다고 정리했다.
## 2026-04-02 v1.3.52
## 2026-04-02 v0.3.52
- 관리자 화면은 본문을 컴포넌트로 나눈 뒤에도 같은 시각 문법을 유지해야 하므로, `scoped`를 유지한 채 각 섹션에 스타일을 복붙하기보다 관리자 범위 공통 스타일로 다시 묶는 편이 더 안전하다고 정리했다.
- `템플릿 요청 관리 / 전체 티어표 관리` 내부 모드 값은 URL과 버튼 상태가 어긋나지 않도록 `all` 하나로 통일하는 편이 맞다고 판단했다.
- 릴리스 기록은 문서 버전만 올라가고 태그가 빠지면 추적이 끊기므로, 뒤늦게라도 누락 태그를 다시 맞춰 버전 흐름을 복구하는 편이 낫다고 정리했다.
## 2026-04-02 v1.3.51
## 2026-04-02 v0.3.51
- 관리자 리팩터링은 본문 분리 다음 단계에서 `회원 관리`처럼 모달과 부수 액션이 많은 영역을 composable로 떼어내는 편이 효과가 크다고 판단했다.
- 이 단계에서는 UI 문구나 사용자가 이미 손본 CSS를 다시 건드리기보다, 현재 동작을 유지한 채 책임 경계만 옮기는 쪽이 더 안전하다고 정리했다.
## 2026-04-02 v1.3.50
## 2026-04-02 v0.3.50
- 템플릿 요청 카드는 게임 이름/ID만 남기기보다 대표 썸네일까지 유지하는 편이 운영자가 요청 대상을 훨씬 빨리 구분할 수 있다고 정리했다.
- 템플릿 요청의 `확인하기`는 단순히 해당 게임을 선택하는 동작이 아니라, 게임 관리 화면에서 요청 아이템 후보가 실제 작업 상태로 복원되어야 한다고 판단했다.
- 관리자 화면은 기능이 많아진 만큼 단일 `/admin` 상태보다 섹션별 경로를 갖는 편이 뒤로가기와 직접 진입 모두에서 더 안정적이라고 정리했다.
@@ -397,144 +550,144 @@
- 관리자 리팩터링은 한 번에 로직까지 갈아엎기보다, 먼저 각 관리 본문을 섹션 컴포넌트로 분리해 `AdminView.vue`의 책임을 줄이는 단계형 접근이 더 안전하다고 정리했다.
- 본문 템플릿 분리 다음 단계에서는 `게임 관리``템플릿 요청`처럼 상태가 무거운 영역부터 composable로 옮겨, 뷰 파일과 업무 로직 파일의 경계를 먼저 세우는 편이 맞다고 판단했다.
## 2026-04-01 v1.3.49
## 2026-04-01 v0.3.49
- 템플릿 요청은 임시 편집 상태에 기대기보다, 먼저 저장된 티어표를 기준으로 요청 스냅샷을 만드는 편이 훨씬 안정적이라고 정리했다. 그래서 요청 버튼은 저장본이 있을 때만 노출하고, 제목이 비어 있으면 사람이 쓰는 기본 문구 대신 고유한 랜덤 제목으로 먼저 저장본을 만든 뒤 요청을 이어가도록 했다.
- 관리자 템플릿 요청 미리보기는 별도 요청 전용 보드 레이아웃을 유지하기보다, 일반 티어표 완성본과 같은 행·열·남은 아이템 문법을 그대로 재사용하는 편이 검수 체감이 가장 자연스럽다고 판단했다.
## 2026-04-01 v1.3.48
## 2026-04-01 v0.3.48
- 관리자 탭 데이터는 첫 진입 로딩만 믿기보다, 인증 완료와 탭 전환 시점에 필요한 목록을 다시 채워 넣는 편이 실제 운영 화면에서 더 안정적이라고 정리했다.
- 템플릿 요청 미리보기는 일반 티어표 보기와 완전히 같은 구현을 억지로 분기하기보다, 같은 내부 프레임 문법과 정보 밀도를 먼저 맞춰 체감 차이를 줄이는 쪽이 현실적이라고 판단했다.
## 2026-04-01 v1.3.47
## 2026-04-01 v0.3.47
- 관리자 `사용자 템플릿 요청`도 결국 검수용 카드이므로, 요청 전용 카드 문법을 따로 두기보다 `전체 티어표 관리`와 같은 카드 구조를 재사용하는 편이 더 직관적이라고 정리했다.
- 새 템플릿 생성 요청의 기본 게임 ID는 사람이 읽기 어려운 난수보다 요청 단위에서 유일한 임시값을 먼저 채워두고, 승인 전에 관리자가 수정하는 흐름이 더 현실적이라고 판단했다.
## 2026-04-01 v1.3.46
## 2026-04-01 v0.3.46
- 관리자 전체 티어표 카드에서는 좌측 영역 전체를 버튼처럼 만드는 것보다, 실제 썸네일 이미지만 미리보기 진입점으로 읽히게 두는 편이 카드 정보 구조가 덜 흔들린다고 정리했다.
- 템플릿 요청 미리보기는 일반 티어표 보기와 다른 요약 레이아웃을 새로 두기보다, 같은 내부 프레임 문법 안에서 보드 자체를 먼저 보여주는 편이 사용자가 더 자연스럽게 이해한다고 판단했다.
## 2026-04-01 v1.3.45
## 2026-04-01 v0.3.45
- 템플릿 요청에서 `내 티어리스트에도 저장`은 별도 부가 기능이 아니라 실제 저장본 생성 경로를 타므로, 새 저장본 ID는 호출자에 기대지 말고 저장 함수 내부에서 항상 보장하는 편이 더 안전하다고 정리했다.
- 개발 단계의 내부 조치 문구인 `백엔드 재시작` 같은 표현은 사용자 토스트에 직접 노출하지 않고, 운영형 재시도 안내로 낮추는 편이 맞다고 판단했다.
## 2026-04-01 v1.3.44
## 2026-04-01 v0.3.44
- 관리자 티어표 목록에서는 `보기` 버튼을 없애더라도 완성본 확인 기능 자체는 유지해야 하므로, 별도 액션 버튼보다 카드 썸네일 클릭을 미리보기 진입점으로 쓰는 편이 더 자연스럽다고 정리했다.
- 템플릿 요청 미리보기도 별도 요약 카드보다 실제 보드 구조를 우선 보여주는 쪽이 관리자 검수 흐름에 더 맞으므로, 일반 티어표 미리보기와 가까운 방향으로 통일하기로 했다.
## 2026-03-30 v1.2.25
## 2026-03-30 v0.2.25
- 홈 게임 카드는 메인 썸네일까지 없애는 것보다, 큰 썸네일은 유지하고 ID 옆의 작은 보조 표시만 제거하는 편이 원래 의도와 맞다고 정리했다.
- 좌우 하단 액션 여백은 `margin`으로 푸터 전체를 밀기보다, 푸터 내부 `padding-bottom`으로 확보해야 버튼 자체는 항상 보이고 여백만 남는다고 판단했다.
## 2026-03-30 v1.2.24
## 2026-03-30 v0.2.24
- `내 티어표` 헤더의 저장 개수 stat은 정보 가치보다 시각 잡음이 더 크다고 보고, 제목/설명 중심 헤더로 단순화하는 편이 낫다고 정리했다.
- 게임 선택 카드는 티어표 카드와 달리 템플릿 선택 진입점이므로, 썸네일까지 반복하기보다 제목과 ID만 간결하게 보여주는 편이 더 적합하다고 판단했다.
- 좌우 하단 액션 버튼은 푸터 블록 안에 있더라도 화면 바닥에 너무 붙으면 무거워 보이므로, 추가 하단 여백을 두어 숨을 쉬게 하는 편이 낫다고 정리했다.
## 2026-03-30 v1.2.23
## 2026-03-30 v0.2.23
- 홈 화면의 게임 카드도 다른 목록 카드와 같은 밀도를 따라가야 하므로, 메인 라이브러리 역시 데스크톱 기본 4열을 기준으로 두는 편이 더 일관되다고 정리했다.
- 게임 허브에서 새 티어표 만들기 버튼이 본문과 우측 패널에 동시에 있으면 역할이 겹치므로, 생성 CTA는 우측 사이드 하나만 남기는 편이 맞다고 판단했다.
- 좌우 레일 액션 버튼은 스크롤되는 본문 안보다 독립된 하단 `56px` 푸터 영역에 놓는 편이 위치 인지가 더 안정적이라고 정리했다.
## 2026-03-30 v1.2.22
## 2026-03-30 v0.2.22
- 왼쪽 레일은 홈/목록/에디터 어디서든 “사라지는 패널”보다 “축소된 내비 레일”로 읽히는 편이 구조적으로 더 일관되므로, 완전 숨김 대신 아이콘 중심 축소 상태를 유지하기로 했다.
- 좌우 패널 토글은 상태마다 다른 아이콘이 바뀌기보다 방향만 고정하는 편이 덜 혼란스러우므로, 우측은 `dock_to_left`, 좌측은 `dock_to_right` 하나로 통일하기로 정리했다.
- 좌측 검색도 임시 선형 SVG보다 실제 에셋을 쓰는 편이 전체 레일 완성도가 높으므로, 사용자가 추가한 `search.svg`를 우선 적용하기로 했다.
## 2026-03-30 v1.2.21
## 2026-03-30 v0.2.21
- 티어표 목록 카드는 페이지마다 다른 메타 구성을 두기보다, `제목+좋아요 / 작성자+최종 수정일` 두 줄 문법으로 통일하는 편이 시안과 사용성 모두에 더 맞다고 정리했다.
- `내 즐겨찾기` 화면에서 “즐겨찾기한 날짜”는 컬렉션 내부 정보일 뿐 카드 핵심 정보는 아니므로, 정렬은 유지하되 카드에는 마지막 수정일만 보여주는 편이 더 읽기 쉽다고 판단했다.
- 좌측 `Favorites`는 메인 내비보다 보조 영역이어야 하므로, 같은 공간 안에서도 더 작은 썸네일·더 작은 텍스트·더 약한 대비로 눌러두는 편이 맞다고 정리했다.
## 2026-03-30 v1.2.20
## 2026-03-30 v0.2.20
- 전역 검색 입력이 이미 좌측 레일에 고정되어 있으므로, 검색 결과 화면 안에 검색 폼을 또 두는 것은 중복이라고 판단해 `/search` 화면은 결과 표시 자체에만 집중시키기로 했다.
- 중앙 워크스페이스는 셸 여백 위에 다시 큰 카드 테두리와 배경을 씌우면 시안보다 한 겹 더 두꺼워 보이므로, `workspaceBody`는 외곽 카드 없이 단일 여백 레이어만 유지하는 편이 더 맞다고 정리했다.
- 우측 패널 토글은 같은 위치에 서로 다른 상태의 버튼이 번갈아 보여야 인지가 쉬우므로, 패널이 닫혀 있을 때는 중앙 헤더에 열기 버튼을 두고 열려 있을 때는 우측 헤더에 닫기 버튼을 두는 방식으로 정리했다.
## 2026-03-30 v1.2.19
## 2026-03-30 v0.2.19
- 사용자 카드에서 프로필/로그아웃 팝업을 또 띄우는 구조는 좌측 `Settings` 메뉴와 역할이 겹치므로, 설정 진입은 메뉴 하나로만 통일하고 로그아웃은 설정 화면 안쪽에서 마무리하는 편이 더 명확하다고 정리했다.
- 좌측 `Favorites`는 단순 링크보다 “내가 최근 좋아요한 실제 티어표 바로가기”를 보여주는 쪽이 시안과 사용성 모두에 더 가깝다고 판단해, 최근 10개만 노출하고 나머지는 `즐겨찾기 더 보기`로 보내기로 했다.
- 좌측 검색은 페이지 내부 국소 검색보다 서비스 전체 공개 티어표 검색 진입점으로 쓰는 편이 더 자연스럽다고 판단해, 별도 `/search` 결과 화면을 두는 방향으로 정리했다.
## 2026-03-30 v1.2.18
## 2026-03-30 v0.2.18
- 피그마 기준 상단 구조는 페이지마다 다르게 보이면 안 되므로, 좌/중앙/우 컬럼 모두 `56px` 헤더를 고정으로 두고 내용이 없을 때도 빈 헤더 공간을 유지하는 편이 맞다고 정리했다.
- 사이트 브랜드는 좌측 레일 안쪽 카드가 아니라 중앙 워크스페이스 상단의 고정 헤더에 두는 쪽이 시안과 더 가깝고, 페이지 이동 시에도 더 일관되게 읽힌다고 판단했다.
- 에디터 화면 안에서 `.layout`이 다시 좌우 컬럼을 만들면 공통 3단 셸과 충돌하므로, 에디터 본문은 셸이 제공한 중앙 컬럼 안에서만 레이아웃을 잡아야 한다고 정리했다.
## 2026-03-30 v1.2.17
## 2026-03-30 v0.2.17
- 공통 오른쪽 레일을 쓰는 화면에서는 로컬 패널이 다시 외곽 래퍼 카드로 감싸지면 “오른쪽 레일 안의 또 다른 사이드”처럼 읽히므로, 에디터 우측 패널은 섹션들만 공통 레일 루트에 직접 배치하는 쪽이 더 일관적이라고 정리했다.
- 에디터/관리자 공통 오른쪽 컬럼은 컨테이너를 화면별로 따로 꾸미기보다, 셸의 `localRightRailRoot`가 기본 스택 문법을 제공하고 각 화면은 내부 section만 채우는 방식으로 맞추기로 했다.
## 2026-03-30 v1.2.16
## 2026-03-30 v0.2.16
- 홈 화면은 이동 경로가 이미 좌측/우측 사이드에 충분히 있으므로, 중앙 바디 상단에 상태 카드와 중복 버튼을 다시 두기보다 본문은 게임 카드에만 집중시키는 편이 더 낫다고 정리했다.
- 오른쪽 사이드도 정보가 막막하다고 해서 임시 카드를 많이 넣기보다, 우선 핵심 CTA 하나만 남기고 나중에 필요한 항목만 추가하는 편이 시안과 운영 흐름 모두에 더 적합하다고 판단했다.
## 2026-03-30 v1.2.15
## 2026-03-30 v0.2.15
- 리디자인 기준 구조는 화면마다 달라지면 안 되므로, 홈에서 보이는 `좌측 레일 / 중앙 / 우측 레일` 3단 셸을 일반 페이지 공통 뼈대로 고정하고 안쪽 콘텐츠만 바꾸는 방식으로 정리하기로 했다.
- 에디터와 관리자의 우측 패널도 예외적인 바디 내부 aside가 아니라 공통 셸의 세 번째 컬럼을 공유해야 전체 제품 구조가 일관된다고 판단했다.
## 2026-03-30 v1.2.14
## 2026-03-30 v0.2.14
- 에디터 우측 패널은 본문 내부 그리드의 일부가 아니라 공통 셸의 세 번째 컬럼이어야 메인 화면과 같은 구조로 읽히므로, Teleport로 셸 aside에 직접 붙이는 편이 맞다고 정리했다.
- 로컬 우측 패널 화면에서 “메인 안쪽 2단 레이아웃”과 “셸 3단 레이아웃”을 섞으면 계속 혼선이 생기므로, 에디터는 셸 레벨 3단 구조를 우선 기준으로 삼기로 결정했다.
## 2026-03-30 v1.2.13
## 2026-03-30 v0.2.13
- 공통 상태를 로컬 우측 패널에 연결할 때는 템플릿의 ref 자동 언래핑을 고려해야 하므로, 템플릿에서는 `.value` 없이 직접 참조하는 편이 안전하다고 다시 정리했다.
- 이번 회귀처럼 편집 화면이 통째로 무너질 수 있는 연결점은 작은 레이아웃 수정이어도 바로 복구 릴리스로 끊는 편이 낫다고 판단했다.
## 2026-03-30 v1.2.12
## 2026-03-30 v0.2.12
- 공통 상단의 패널 토글은 로컬 우측 패널 화면에서도 같은 의미로 동작해야 하므로, 에디터의 `editorSidebar`도 같은 상태를 공유해 접고 펴는 편이 일관된다고 판단했다.
- 로컬 우측 패널 화면에 공통 `rightClosed` 그리드 계산이 다시 들어오면 컬럼 수가 꼬일 수 있으므로, 에디터/관리자 화면은 셸 차원에서 별도 예외 컬럼 규칙을 유지하기로 결정했다.
## 2026-03-30 v1.2.11
## 2026-03-30 v0.2.11
- 에디터와 관리자처럼 자체 우측 패널이 있는 화면은 공통 `workspaceBody` 카드 배경 안에 다시 넣기보다, 셸 레벨에서 중앙 본문을 투명하게 풀어주는 편이 우측 사이드바 독립성이 더 잘 살아난다고 판단했다.
- 로컬 우측 패널의 핵심은 “본문 안쪽 보조 박스”가 아니라 “진짜 오른쪽 컬럼”처럼 읽히는 것이므로, 에디터에서는 본문 카드보다 패널 분리감을 먼저 확보하기로 결정했다.
## 2026-03-30 v1.2.10
## 2026-03-30 v0.2.10
- 목록 화면도 결국 같은 제품의 라이브러리 레이어이므로, 상단 통계 카드와 버튼의 높이·반경·배경을 공통 셸과 같은 문법으로 맞추는 편이 일관성이 높다고 정리했다.
- 홈 화면의 빠른 액션은 중복 의미 버튼보다 `즐겨찾기 / 내 리스트 / 커스텀 시작`처럼 실제 이동 동선이 분명한 버튼 구성이 더 적합하다고 판단했다.
- 카드 hover 반응은 화면마다 조금씩 다르게 두기보다, 모두 얕은 위로 이동과 배경 변화로 통일하는 편이 대시보드 감도를 유지하기 쉽다고 결정했다.
## 2026-03-30 v1.2.9
## 2026-03-30 v0.2.9
- 관리자 화면은 기능보다 먼저 정보 계층이 읽혀야 하므로, 현재 탭에 맞는 요약 통계를 헤더에서 먼저 보여주는 편이 운영 판단에 더 유리하다고 정리했다.
- 게임/아이템/티어표/회원 카드는 기능이 다른 대신 같은 제품 안에 있으므로, 배경층·반경·패딩은 하나의 대시보드 문법으로 맞춰 시안 톤을 더 강하게 유지하기로 결정했다.
- 우측 운영 패널은 단순 필터 모음보다 “현재 상태를 짧게 읽고 바로 액션하는 패널”에 가까워야 하므로, 입력과 통계 카드를 더 단단한 카드형 레이어로 정리하는 편이 맞다고 판단했다.
## 2026-03-30 v1.2.8
## 2026-03-30 v0.2.8
- 에디터는 “보드 편집”과 “옵션 편집”의 역할이 다르므로, 보드 옆에는 드래그용 아이템 풀을 두고 제목/설명/썸네일/저장 같은 설정은 최우측 사이드바에만 남기는 편이 맞다고 판단했다.
- 커스텀 아이템 이름 정리는 배치 중에 계속 보는 정보보다 저장 전 정리용 정보에 가까우므로, 아이템 풀 아래보다 우측 편집 패널 내부가 더 적합하다고 정리했다.
- 실제 SVG 에셋이 들어오기 시작한 만큼, 공통 셸은 새 아이콘을 우선 적용하고 나머지는 점진적으로 교체하는 방식이 안전하다고 판단했다.
## 2026-03-30 v1.2.7
## 2026-03-30 v0.2.7
- 피그마 감도는 개별 화면보다 공통 셸의 밀도와 아이콘 체계가 먼저 맞아야 하므로, 좌측/우측 레일을 먼저 아이콘형 카드 문법으로 정리하기로 했다.
- 실제 머티리얼 SVG 자산을 받기 전까지는 간단한 선형 SVG 아이콘으로 정보 구조를 먼저 맞추고, 이후 에셋 교체만으로 다듬을 수 있게 하는 편이 안전하다고 판단했다.
- 에디터는 기능은 이미 많은 상태이므로 구조를 더 바꾸기보다 보드, 툴바, 우측 편집 패널의 카드 톤을 공통 셸과 맞추는 방식으로 단계적으로 다듬기로 했다.
## 2026-03-30 v1.2.6
## 2026-03-30 v0.2.6
- 홈, 게임 허브, 내 티어표, 즐겨찾기처럼 카드 중심 화면은 한 번에 같은 카드 문법으로 맞춰야 전체 앱이 하나의 제품처럼 보이므로, 목록 화면을 우선 통일하기로 했다.
- 홈 화면은 단순 게임 버튼 모음보다 상태 카드와 CTA가 있는 라이브러리 대시보드 쪽이 피그마 톤에 더 가깝다고 판단했다.
- 게임 허브와 개인 목록도 썸네일/작성자/메타의 비중이 비슷하므로, 화면마다 다른 카드 구조를 유지하기보다 동일한 정보 계층을 반복하는 편이 더 읽기 쉽다고 정리했다.
## 2026-03-30 v1.2.5
## 2026-03-30 v0.2.5
- 관리자 화면도 에디터와 마찬가지로 공통 우측 패널보다 전용 로컬 운영 패널이 더 중요하므로, `/admin` 역시 화면 내부 `320px` 패널을 사용하는 포커스 화면으로 정리하기로 결정했다.
- 관리자 기능은 탭, 검색, 필터, 빠른 액션이 본문에 섞이면 밀도가 너무 높아지므로, 우측 패널에는 제어 요소를 모으고 중앙에는 실제 관리 대상 목록과 상세만 남기는 편이 낫다고 판단했다.
- 새 셸 단계에서는 기능을 줄이기보다 위치를 재배치하는 것이 안전하므로, 기존 게임/아이템/티어표/회원 관리 로직은 유지한 채 정보 구조만 피그마 방향으로 옮기기로 했다.
## 2026-03-30 v1.2.4
## 2026-03-30 v0.2.4
- 로그인 유도는 좌측 하단의 단일 버튼이면 충분하므로, 비로그인 상태에서 사이드 상단에 별도 안내 카드를 또 보여주는 구조는 제거하는 편이 더 깔끔하다고 판단했다.
- 티어표 편집 화면은 공통 우측 패널의 generic 문맥 카드보다 실제 편집 필드가 우측에 있는 편이 훨씬 중요하므로, 이 화면은 전용 로컬 우측 패널을 두는 쪽으로 정리했다.
- 좌측 내비가 이미 라우팅 역할을 하므로, 에디터 우측 패널에서는 “게임 목록으로” 같은 중복 이동 CTA보다 저장과 편집 자체에 집중하는 것이 맞다고 판단했다.
## 2026-03-30 v1.2.2
## 2026-03-30 v0.2.2
- 우측 패널은 본문 내부 보조 박스가 아니라 별도 컬럼으로 보이는 것이 핵심이므로, 폭을 `320px`로 고정하고 접힘/펼침도 레이아웃 레벨에서 처리하는 편이 맞다고 판단했다.
- 좌측 패널도 시안 기준 인지 폭이 중요하므로 `248px`로 고정하고, 중앙 콘텐츠는 나머지 공간을 유동적으로 쓰게 하는 구조로 정리했다.
- 우측 패널 토글은 라우트별 개별 구현보다 공통 셸의 상단 컨트롤로 두는 편이 모든 화면에서 일관된 사용성을 제공한다고 판단했다.
## 2026-03-30 v1.2.1
## 2026-03-30 v0.2.1
- 공통 셸을 먼저 올린 직후에는 에디터와 관리자처럼 자체 패널이 많은 화면이 가장 크게 깨지므로, 이 화면들은 우선 공통 우측 패널을 숨기고 중앙 폭을 회복시키는 편이 안정적이라고 판단했다.
- 목록형 카드 화면은 셸 안쪽 폭이 줄어든 상태에서 이전보다 더 많은 컬럼을 유지하면 즉시 사용성이 무너지므로, 기본 컬럼 수를 줄여 먼저 읽히는 상태를 만드는 쪽을 우선하기로 했다.
- 리디자인 초기 단계에서는 “완벽한 시안 재현”보다 먼저 실제 조작 가능한 상태를 되찾는 것이 중요하므로, 이번 단계는 안정화 릴리스로 짧게 끊어 가기로 정리했다.
## 2026-03-30 v1.2.0
## 2026-03-30 v0.2.0
- 피그마 시안은 단순 컴포넌트 교체보다 앱 전체의 정보 구조를 바꾸는 성격이 강하므로, 우선 공통 앱 셸부터 `좌측 내비 / 중앙 워크스페이스 / 우측 컨텍스트 패널`로 올리는 단계적 리디자인이 더 안전하다고 판단했다.
- 홈, 게임 허브, 내 티어표, 즐겨찾기처럼 카드 목록 중심 화면은 시안 톤을 먼저 맞추고, 에디터와 관리자처럼 상호작용이 무거운 화면은 같은 셸 안에서 후속 이관하는 방식이 리스크가 적다고 정리했다.
- 이번 리디자인은 사용자 체감 변화가 큰 편이므로, 버전도 기존 `0.1.x`가 아니라 `v1.2.0`으로 점프해 기록하는 편이 더 자연스럽다고 판단했다.
- 이번 리디자인은 사용자 체감 변화가 큰 편이므로, 버전도 기존 `0.1.x`가 아니라 `v0.2.0`으로 점프해 기록하는 편이 더 자연스럽다고 판단했다.
## 2026-03-27 v0.1.52
- 관리자 확인용 완성본은 사이트 전체가 아니라 보드만 보여주는 preview 전용 모드가 더 적합하다고 판단했다.
@@ -675,15 +828,15 @@
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.
## 2026-04-03 v1.4.65
## 2026-04-03 v1.0.65
- 운영 환경에서 루트 정적 favicon 요청이 계속 `403`으로 떨어지는 상황에서는 원인을 프록시/권한 계층에서 끝까지 추적하기보다, 브라우저 탭용 파비콘을 인라인 데이터 URL로 제공해 해당 요청 자체를 없애는 편이 더 단순하고 안정적이라고 판단했다.
- 다만 iOS 홈 화면용 `apple-touch-icon.png`와 외부 공유용 `og-card.png`는 실제 파일이 필요하므로, 일반 브라우저 탭 favicon만 인라인 처리하는 선으로 범위를 제한했다.
## 2026-04-03 v1.4.64
## 2026-04-03 v1.0.64
- 운영/로컬 DB를 새로 미는 흐름을 공식화한 만큼, 더 이상 “기존 DB에서만 우연히 남아 있던 컬럼”에 기대지 않고 `ensureSchema()`의 신규 생성 정의만으로 관리자 화면 전체가 떠야 한다고 다시 정리했다.
- `template_requests`는 요청 목록 카드뿐 아니라 요청 미리보기와 이미지 참조 추적에도 쓰이므로, 저장 스냅샷 컬럼(`groups_json`, `board_items_json`, `show_character_names_snapshot`)을 초기 스키마에 반드시 포함하기로 결정했다.
## 2026-04-03 v1.4.63
## 2026-04-03 v1.0.63
- 관리자/에디터 화면의 우측 패널은 Teleport로 공통 셸의 레일 DOM에 끼워 넣는 구조이므로, 라우트 변경 시 Teleport 대상 노드 자체를 조건부로 없애면 Vue 언마운트/패치 순서에 따라 DOM 기준점이 깨질 수 있다고 판단했다.
- 따라서 `#local-right-rail-root`는 항상 렌더링해두고, 일반 화면에서는 숨김 클래스만 적용하는 방식으로 유지해 라우트 전환 안정성을 우선 확보하기로 결정했다.
@@ -745,19 +898,19 @@
- 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다.
- 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다.
## 2026-04-03 v1.4.37
## 2026-04-03 v1.0.37
- 드롭 또는 클릭 안내를 보여주는 업로드 박스는 실제 클릭 동작까지 연결돼 있어야 UX가 자연스러우므로, 이후 같은 패턴에서는 박스 자체가 트리거 역할을 하도록 맞추기로 했다.
## 2026-04-03 v1.4.36
## 2026-04-03 v1.0.36
- 복사 기능은 “타인 티어표 가져오기”에만 묶기보다, 본인 작업도 파생본을 빠르게 만드는 용도로 열어두는 편이 실제 제작 흐름에 더 맞는다고 정리했다.
- 공유 프리뷰도 서비스와 완전히 단절된 단일 화면보다, 광고 레일과 카피라이트를 포함한 가벼운 사이트 문맥을 유지하는 편이 자연스럽다고 판단했다.
## 2026-04-03 v1.4.35
## 2026-04-03 v1.0.35
- 실제 사용 테스트에서 아이템 수가 80개 안팎으로 늘어나면 “기능은 있는데 찾을 수 없는 상태”가 되기 쉬워, 편집기 풀에는 가벼운 이름 검색이 필수라고 정리했다.
- 공유 링크는 완성본만 보여주는 데서 끝내지 말고 서비스 메인으로 돌아오는 손잡이를 함께 두는 편이 자연스럽다고 판단했다.
- 공개 프리뷰의 작성 시각은 분 단위까지 노출할 필요가 낮아, 신뢰에 필요한 최소 정보만 남기는 쪽으로 줄이기로 했다.
## 2026-04-02 v1.4.34
## 2026-04-02 v1.0.34
- 라이트모드는 단순 토글 존재만으로 충분하지 않고, 셸/카드/버튼/오버레이가 같은 색 문법을 공유해야 품질이 안정된다고 판단해 공통 토큰을 다시 정리했다.
- 홈 카드 즐겨찾기 버튼처럼 다크 전용 하드코딩이 남아 있으면 전체 인상이 쉽게 무너지므로, 이후 테마 보정은 공통 변수 우선 원칙으로 계속 가져가기로 했다.

View File

@@ -2,8 +2,13 @@
## `/`
- 화면 파일: `frontend/src/views/HomeView.vue`
- 역할: 데스크톱 기본 4열 주제 카드 라이브러리 대시보드, 상단 메인 썸네일과 `주제명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 주제 카드 클릭 이동, `직접 티어표 만들기` 진입
- 연동 API: `GET /api/topics`
- 역할: 공개 티어표 홈 피드, 상단 `추천 티어표`와 아래 `최신 공개 티어표` 목록을 같은 카드 문법으로 표시, 검색어(`q`)가 있으면 공개 티어표 제목/작성자 기준으로 필터링, 카드 클릭 시 해당 티어표 화면으로 이동
- 연동 API: `GET /api/tierlists/public?q=...`
## `/templates`
- 화면 파일: `frontend/src/views/TemplatesView.vue`
- 역할: 공개 템플릿 전용 목록, 관리자 수동 순서와 즐겨찾기 여부를 반영한 주제 템플릿 카드 목록 표시, 템플릿 즐겨찾기 토글, 검색어(`q`)가 있으면 템플릿 이름/slug 기준으로 즉시 필터링
- 연동 API: `GET /api/topics`, `POST /api/topics/:topicId/favorite`, `DELETE /api/topics/:topicId/favorite`
## `/topics/:topicId`
- 화면 파일: `frontend/src/views/TopicHubView.vue`
@@ -12,8 +17,13 @@
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
- 화면 파일: `frontend/src/views/TierEditorView.vue`
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰와 하단 댓글 카드를 렌더링하며, 우측 뷰어 카드(`공유 티어표 보기`)는 스폰서 카드 바로 아래에서 유지
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `GET /api/tierlists/:id/comments`, `POST /api/tierlists/:id/comments`, `DELETE /api/tierlists/:id/comments/:commentId`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
## `/comments`
- 화면 파일: `frontend/src/views/CommentInboxView.vue`
- 역할: 내 티어표에 달린 댓글과 내 댓글에 달린 답글을 시간순 카드로 확인, 안 읽은 댓글만 보기 필터, 모두 읽음 처리, 카드별 red dot 표시, 티어표 썸네일과 원댓글/새 댓글 비교 블록 제공, 카드 클릭 시 해당 티어표의 특정 댓글 위치로 이동
- 연동 API: `GET /api/comments/inbox`, `GET /api/comments/inbox/unread-count`, `POST /api/comments/inbox/read`
## `/login`
- 화면 파일: `frontend/src/views/LoginView.vue`
@@ -48,7 +58,7 @@
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 템플릿 이름/slug 수정, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기·최근 콘텐츠 활동·마지막 접속일 확인과 회원 정보·권한 수정 및 공개 프로필 보기, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
- 연동 API: `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/tierlists/stats`, `PATCH /api/admin/tierlists/:tierListId/featured`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId`
- 연동 API: `GET /api/admin/templates`, `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/tierlists/stats`, `PATCH /api/admin/tierlists/:tierListId/featured`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId`
## `/profile`
- 화면 파일: `frontend/src/views/ProfileView.vue`
@@ -57,12 +67,13 @@
## 공통 레이아웃
- 앱 셸 파일: `frontend/src/App.vue`
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링, 관리자/에디터 화면이 Teleport로 사용하는 `#local-right-rail-root` 대상 DOM을 상시 유지해 라우트 전환 중 우측 레일 언마운트 순서를 안정화
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `홈 / 템플릿 / 댓글 관리` 네비게이션과 화면별 검색 placeholder 전환, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 댓글 알림 unread dot, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링, 관리자/에디터 화면이 Teleport로 사용하는 `#local-right-rail-root` 대상 DOM을 상시 유지해 라우트 전환 중 우측 레일 언마운트 순서를 안정화
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
## 백엔드 진입점
- 서버 엔트리: `backend/index.js`
- 데이터 초기화: `backend/src/db.js`
- 세부: 댓글/알림 관련 테이블(`tierlist_comments`, `comment_notifications`)은 여기서 생성되고, 기존 DB에 누락된 컬럼이 있으면 서버 시작 시 자동 보강한다.
- 로컬 DB 실행 설정: `docker-compose.yml`
- 로컬 MariaDB 가이드: `docs/local-mariadb.md`
- 인증 라우트: `backend/src/routes/auth.js`

View File

@@ -33,12 +33,24 @@
- 상단 토글 버튼은 항상 고정되어 있고, 패널을 축소하면 텍스트를 숨기고 아이콘 중심 레일로 전환한다.
- `Settings`는 별도 메뉴 항목으로만 진입하며, 사용자 카드 자체는 정보 표시 용도로만 사용한다.
- 사용자 아바타는 원형 보더 스타일을 유지하고, `Favorites` 영역은 최근 즐겨찾기 티어표 최대 10개를 메인 메뉴보다 작은 밀도의 바로가기 목록으로 보여준 뒤 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결한다.
- 사용자 아바타가 없을 때 표시하는 fallback 이니셜은 계정명보다 닉네임을 우선한다.
- 중앙 워크스페이스
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
- 공통 `workspaceBody`는 별도 외곽 카드 테두리 없이 셸 여백만 제공하고, 실제 카드/패널 레이어는 각 화면 내부에서만 구성한다.
- 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색 결과 화면은 같은 카드 문법(상단 16:9 썸네일, `제목+좋아요` 1행, `작성자+최종 수정일` 1행)을 공유하며, 데스크톱 기준 기본 4열 카드 그리드를 사용한다.
- 단, 홈 게임 선택 카드는 템플릿 선택용이므로 상단 메인 썸네일은 유지하되, 하단 메타는 `게임명 + 작은 ID`만 간결하게 표시한다.
- `/` 홈은 템플릿 선택 화면이 아니라 `공개 티어표 피드`이며, 추천 티어표와 최신 공개 티어표를 같은 보드 카드 문법으로 보여준다.
- `/templates`는 공개 템플릿 전용 화면이며, 템플릿 카드는 상단 메인 썸네일과 `주제명 + 작은 slug/id` 메타를 가진다.
- 목록 계열 화면의 상단 도구 영역은 통계 카드와 액션 버튼을 공통 높이/반경으로 맞춰, 같은 라이브러리 대시보드로 읽히도록 정리한다.
## 주요 라우트/데이터 규칙
- 일반 `GET /api/topics`는 로그인한 관리자라도 공개 템플릿만 반환한다.
- 관리자 전용 템플릿 목록은 `GET /api/admin/templates`를 사용하며, 비공개 템플릿까지 포함한다.
- 홈 피드(`/`)는 `GET /api/tierlists/public?q=...`를 사용한다.
- `featuredTierLists`: 상단 추천 티어표
- `tierLists`: 추천 제외 최신 공개 티어표
- 저장된 티어표에는 댓글 스레드가 붙을 수 있다. 작성자 본인 편집 화면에서는 `작업 팁` 아래, 작성자가 아닌 사용자의 보기 전용 화면에서는 `preview` 보드 아래에서 같은 댓글 카드를 사용한다.
- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다.
- 댓글 정렬은 루트 댓글 최신순, 각 루트 내부의 답글은 오래된순을 기본 규칙으로 유지한다.
- 우측 패널
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
@@ -139,6 +151,26 @@
- `userId`: string
- `topicId`: string
- `createdAt`: number
- `tierListComments`
- `id`: string
- `tierListId`: string
- `authorId`: string
- `parentCommentId`: string
- `content`: string
- `createdAt`: number
- `updatedAt`: number
- 답글은 1단계까지만 허용한다.
- `commentNotifications`
- `id`: string
- `userId`: string
- `tierListId`: string
- `commentId`: string
- `actorUserId`: string
- `notificationType`: `tierlist_comment | comment_reply`
- `isRead`: boolean
- `readAt`: number
- `createdAt`: number
- 기존 운영 DB에 예전 형태 테이블이 남아 있어도 서버 시작 시 스키마 보정으로 누락 컬럼을 자동 추가한다.
- `templateRequests`
- `id`: string
- `type`: string
@@ -191,6 +223,9 @@
- `GET /api/tierlists/me`
- `GET /api/tierlists/favorites/me`
- `GET /api/tierlists/:id`
- `GET /api/tierlists/:id/comments`
- `POST /api/tierlists/:id/comments`
- `DELETE /api/tierlists/:id/comments/:commentId`
- `POST /api/tierlists/:id/template-request`
- `POST /api/tierlists/:id/favorite`
- `DELETE /api/tierlists/:id/favorite`
@@ -207,6 +242,11 @@
- 해당 작성자의 공개 티어표 목록을 반환한다.
- `POST /api/users/:userId/follow`
- `DELETE /api/users/:userId/follow`
- 댓글 알림
- `GET /api/comments/inbox`
- 알림 카드 렌더링을 위해 티어표 썸네일과 부모 댓글 내용도 함께 반환한다.
- `GET /api/comments/inbox/unread-count`
- `POST /api/comments/inbox/read`
- 관리자
- `POST /api/admin/templates`
- 요청 본문은 `slug`, `name`, `isPublic`, `thumbnailSrc`를 사용하고, 내부 `topics.id`는 서버가 자동 생성한다.

View File

@@ -1,5 +1,32 @@
# 할 일 및 이슈
## 단기 확인
- `v1.1.6` 이후 루트 댓글이 최신순으로, 답글은 오래된순으로 정확히 보이는지 실제 댓글 데이터를 여러 개 넣어 확인한다.
- 뷰어 모드에서 댓글이 길어져도 우측 `공유 티어표 보기` 카드가 스폰서 카드 바로 아래에서 유지되고, 더 이상 하단으로 밀려 보이지 않는지 확인한다.
- `v1.1.5` 이후 댓글 카드/댓글 관리 카드에서 보더가 과해 보이지 않고, surface/shadow 중심 레이어가 다크/라이트 모드 모두에서 자연스러운지 확인한다.
- 댓글 등록/답글 등록 버튼이 실제 저장 CTA 톤으로 보이고 hover/disabled 상태도 다른 저장 버튼들과 이질감이 없는지 확인한다.
- `v1.1.4` 이후 댓글 관리 카드에서 티어표 썸네일, 원댓글/새 댓글 비교 블록이 데스크톱과 모바일에서 모두 자연스럽게 보이는지 확인한다.
- 댓글 스레드 카드 리디자인 후 답글 연결선, 배지, 본문 말풍선 배경이 라이트/다크 모드 모두에서 과하지 않게 보이는지 확인한다.
- `v1.1.3` 이후 답글 작성 시 입력창이 열리자마자 포커스를 받고, 포커스 전에도 카드/입력 경계가 분명하게 보이는지 다크/라이트 모드 모두에서 확인한다.
- `v1.1.2` 반영 후 실제 운영/로컬 DB에서 서버를 다시 띄워 `comment_notifications.is_read` 컬럼이 자동 보강되는지, `댓글 관리` 메뉴 unread dot과 `/api/comments/inbox/unread-count`가 더 이상 SQL 오류 없이 동작하는지 확인한다.
- `v1.1.1` 댓글 복구 이후 다음 흐름을 우선 QA한다: 공개 티어표 프리뷰 하단 댓글 노출, 댓글 작성/답글 작성/본인 댓글 삭제, 댓글 관리 메뉴 red dot, 댓글 관리 화면에서 `안 읽은 댓글만 보기``모두 읽음 처리`, 카드 클릭 후 해당 댓글 위치 스크롤.
- 작성자 본인 티어표 편집 화면과 타인 티어표 프리뷰 화면에서 같은 댓글 카드가 모두 자연스럽게 보이는지, 새로고침 후에도 기존 에디터 회귀 없이 댓글 카드만 안정적으로 붙는지 확인한다.
- 댓글 알림 unread count는 현재 접속 시와 라우트 이동 시 갱신되므로, 다른 탭에서 새 댓글이 생겼을 때 실시간 반영이 필요하면 이후 polling 또는 SSE 도입 여부를 검토한다.
- `v1.1.0`에서 홈을 공개 티어표 피드로, 템플릿을 `/templates`로 분리했으므로 왼쪽 사이드 `홈 / 템플릿 / 나의 티어표 / 설정` 흐름과 검색 placeholder가 각 화면에서 자연스럽게 바뀌는지 확인한다.
- 관리자 계정으로 일반 템플릿 목록(`/templates`)에 들어가도 비공개 템플릿이 보이지 않고, 관리자 화면에서는 여전히 비공개 템플릿이 관리 가능한지 확인한다.
- 홈 피드의 추천 티어표와 최신 공개 티어표 카드가 데스크톱/태블릿/모바일에서 overflow 없이 안정적으로 보이는지 확인한다.
- 아바타 fallback 이니셜이 썸네일 미등록 상태에서 계정명이 아니라 닉네임 첫 글자로 보이는지 홈/주제 허브/나의 티어표/즐겨찾기/팔로우 피드/검색 결과/사용자 프로필에서 각각 확인한다.
## 다음 작업자 인수인계
- 현재 기준선은 `v1.0.104`다. 홈 피드와 댓글 기능은 이 버전 위에서 다시 구현해야 하며, 편집 화면 로딩/새로고침 안정성이 먼저다.
- 홈 피드는 기존 템플릿 메인 화면과 분리된 별도 `/` 화면으로 두되, 데이터 원천은 `공개 티어표 목록` API로만 시작한다. 첫 단계에서는 `최근 공개 티어표` 목록과 `관리자 추천 티어표` 상단 섹션만 붙이고, 템플릿 화면 카드 문법을 최대한 재사용한다.
- 홈 피드 카드는 새 컴포넌트를 급히 만들기보다, 이미 안정적이던 템플릿/목록 카드 문법을 공통 컴포넌트로 먼저 분리한 뒤 사용한다. 썸네일 비율, 아바타 fallback, 제목/메타 overflow를 카드마다 따로 다시 구현하지 않는다.
- 댓글 기능은 티어표 편집기 본문 안으로 깊게 섞기보다, 저장된 티어표 하단의 독립 카드 섹션으로 붙인다. 편집 모드와 preview 모드 모두 같은 댓글 카드 컴포넌트를 재사용하는 구조가 안전하다.
- 댓글 로딩은 에디터 메인 `loadEditorState()`와 분리해, 티어표 본문이 먼저 안정적으로 그려진 뒤 별도 비동기로 붙인다. 댓글 로딩/스크롤/읽음 처리 타이머는 라우트 변경 시 즉시 취소 가능해야 한다.
- `commentId` 이동, 댓글 관리함, unread dot 같은 부가 기능은 댓글 목록 조회/등록이 안정화된 뒤 2단계로 구현한다. 처음부터 알림, 자동 스크롤, 읽음 처리까지 한 번에 붙이지 않는다.
- 글로벌 로딩 화면이나 initial loading state는 에디터 본문 전체를 조건부로 갈아끼우기보다, 앱 셸 수준의 고정 오버레이 또는 최소한의 skeleton으로 처리한다. Teleport 우측 레일, RouterView, 편집기 내부 DOM을 동시에 조건부 마운트/언마운트하는 방식은 피한다.
- 티어표 편집 화면에서는 새로고침 직후 다음 세 가지 QA가 항상 선행되어야 한다: `오른쪽 아이템 풀 복구`, `왼쪽 메뉴 이동 가능`, `콘솔 Vue patch/unmount 오류 없음`.
## 단기 확인
- `v1.4.68`에서 아이템 우클릭 처리를 `window` 캡처 단계로 보강했으므로, 보드에 배치된 아이템/미사용 풀 아이템/아이템 썸네일 이미지 위에서 각각 우클릭했을 때 브라우저 기본 메뉴 대신 `아이템 복제` 메뉴가 바로 뜨는지 QA한다.
- `v1.4.67`에서 같은 `src`가 프로필 아바타와 템플릿/사용자 아이템으로 동시에 쓰여도 자산 카드를 유지하도록 바꿨으므로, 운영 관리자 화면의 `전체 이미지``프로필 이미지` 필터에서 실제 아바타가 보이고 상세 모달의 공유 참조 목록도 자연스럽게 읽히는지 QA한다.

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,9 @@
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { editorNewPath, favoritesPath, followingFeedPath, homePath, loginPath, mePath } from './lib/paths'
import { commentsPath, editorNewPath, favoritesPath, followingFeedPath, homePath, loginPath, mePath, templatesPath } from './lib/paths'
import { displayInitialFrom } from './lib/display'
import { api } from './lib/api'
import { toApiUrl } from './lib/runtime'
import { useToast } from './composables/useToast'
import iconDockToLeft from './assets/icons/dock_to_left.svg'
@@ -22,7 +24,7 @@ import SvgIcon from './components/SvgIcon.vue'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const { toasts, dismissToast } = useToast()
const { toasts, dismissToast, error: showErrorToast } = useToast()
const RIGHT_RAIL_COPYRIGHT_URL = 'https://x.com/zennbox'
const currentTopicId = computed(() => route.params.topicId || '')
@@ -30,7 +32,7 @@ const leftRailCollapsed = ref(false)
const mobileLeftNavOpen = ref(false)
const rightRailOpen = ref(true)
const searchQuery = ref('')
const leftRailSearchPlaceholder = '주제 템플릿 검색'
const leftRailSearchPlaceholder = computed(() => (route.name === 'templates' ? '주제 템플릿 검색' : '공개 티어표 검색'))
const isCollapsedSearchOpen = ref(false)
const isGuideModalOpen = ref(false)
const themeMode = ref('dark')
@@ -38,6 +40,8 @@ const guideStepIndex = ref(0)
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
const backendState = ref('online')
const backendMessage = ref('')
const isFullscreenActive = ref(false)
const unreadCommentCount = ref(0)
provide('rightRailOpen', rightRailOpen)
provide('localRightRailTarget', '#local-right-rail-root')
@@ -62,16 +66,19 @@ const accountEmail = computed(() => {
if (!authReady.value) return '계정 상태를 확인하고 있어요.'
return (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.'
})
const isAccountEmailHint = computed(() => !auth.user)
const shellStyle = computed(() => ({
'--left-rail-width': leftRailCollapsed.value ? '76px' : '248px',
'--right-rail-width': !isRightRailOverlay.value && rightRailOpen.value ? '325px' : '0px',
}))
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))
@@ -136,6 +143,13 @@ const guideSteps = [
description:
'주제 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
},
{
id: 'keyboard-shortcuts',
title: '단축키로 빠른 조작',
summary: '사이드 패널과 전체 화면을 키보드로 빠르게 전환합니다.',
description:
'[ 키는 왼쪽 사이드를 열고 닫고, ] 키는 오른쪽 사이드를 열고 닫습니다. F 키는 전체 화면 보기 토글, S 키는 티어표 편집 화면의 아이템 검색창으로 바로 이동할 때 사용할 수 있어요. 한글 입력 상태에서는 F 자리의 ㄹ, S 자리의 ㄴ 키도 같은 단축키로 처리됩니다. 각종 모달은 Esc 키로 닫을 수 있습니다. 단, 검색창이나 입력칸에 글을 쓰는 중에는 단축키가 동작하지 않도록 처리되어 있어요.',
},
]
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
const isGuidePrevDisabled = computed(() => guideStepIndex.value <= 0)
@@ -146,11 +160,15 @@ const showSettingsThemePanel = computed(() => route.name === 'profile')
const showTopicViewToggle = computed(() => route.name === 'topicHub')
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
const shouldLockRightRailBodyScroll = computed(() => isRightRailOverlay.value && rightRailOpen.value && !showBackendFallback.value)
const leftBottomPrimaryAction = computed(() => {
if (!authReady.value) return null
if (route.name === 'home' && auth.user) {
return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
}
if (route.name === 'templates' && auth.user) {
return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
}
if (route.name === 'topicHub') {
const target = editorNewPath(currentTopicId.value)
return { label: '새 티어표 만들기', to: auth.user ? target : loginPath(target), iconSrc: iconAddNotes }
@@ -161,10 +179,22 @@ const leftBottomPrimaryAction = computed(() => {
const routeMeta = computed(() => {
if (route.name === 'home') {
return {
title: '주제 선택',
subtitle: '주제 템플릿 선택과 커스텀 보드 시작',
title: '',
subtitle: '공개 티어표 피드',
contextTitle: '빠른 시작',
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 주제를 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
contextText: auth.user ? '추천 티어표와 최신 공개 티어표를 둘러보고 바로 새 작업을 시작할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
actionLabel: '템플릿 보기',
action: () => {
router.push(templatesPath())
},
}
}
if (route.name === 'templates') {
return {
title: '템플릿',
subtitle: '주제 템플릿 선택',
contextTitle: '빠른 시작',
contextText: auth.user ? '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
action: () => {
router.push(auth.user ? editorNewPath('freeform') : loginPath())
@@ -234,6 +264,16 @@ const routeMeta = computed(() => {
action: () => router.push(favoritesPath()),
}
}
if (route.name === 'comments') {
return {
title: '댓글 관리',
subtitle: '댓글과 답글 확인',
contextTitle: '알림',
contextText: '내 티어표에 달린 댓글과 내 댓글에 달린 답글을 확인하고 바로 해당 위치로 이동할 수 있어요.',
actionLabel: '나의 티어표 보기',
action: () => router.push(mePath()),
}
}
if (route.name === 'userProfile') {
return {
title: '작성자 프로필',
@@ -286,6 +326,28 @@ 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)
}
function applyTheme(mode) {
themeMode.value = mode === 'light' ? 'light' : 'dark'
if (typeof document !== 'undefined') document.documentElement.dataset.theme = themeMode.value
@@ -296,6 +358,11 @@ function toggleTheme() {
applyTheme(isLightTheme.value ? 'dark' : 'light')
}
function syncRightRailBodyScrollLock(shouldLock) {
if (typeof document === 'undefined') return
document.body.style.overflow = shouldLock ? 'hidden' : ''
}
onMounted(async () => {
if (typeof window !== 'undefined') {
const savedTheme = window.localStorage.getItem('tier-maker:theme')
@@ -305,9 +372,13 @@ onMounted(async () => {
await auth.refresh()
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)
document.addEventListener('webkitfullscreenchange', syncFullscreenState)
const leftSaved = window.localStorage.getItem('tier-maker:left-rail-collapsed')
if (leftSaved === '1') leftRailCollapsed.value = true
const saved = window.localStorage.getItem('tier-maker:right-rail-open')
@@ -320,6 +391,7 @@ onMounted(async () => {
rightRailOpen.value = true
}
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
await refreshUnreadCommentCount()
})
function handleGlobalKeydown(event) {
@@ -329,15 +401,67 @@ function handleGlobalKeydown(event) {
}
if (event.key === 'Escape' && isCollapsedSearchOpen.value) {
closeCollapsedSearch()
return
}
if (isGuideModalOpen.value || isCollapsedSearchOpen.value) return
if (shouldIgnoreGlobalShortcut(event)) return
if (event.key === '[') {
event.preventDefault()
toggleLeftRail()
return
}
if (event.key === ']') {
event.preventDefault()
toggleRightRail()
return
}
if (['f', 'ㄹ'].includes(String(event.key || '').toLowerCase())) {
event.preventDefault()
toggleFullscreen()
return
}
if (['s', 'ㄴ'].includes(String(event.key || '').toLowerCase()) && ['editEditor', 'newEditor'].includes(String(route.name || ''))) {
event.preventDefault()
window.dispatchEvent(new CustomEvent('tier-maker:focus-editor-item-search'))
}
}
function shouldIgnoreGlobalShortcut(event) {
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return true
const target = event.target
if (!target || !(target instanceof HTMLElement)) return false
const tagName = target.tagName.toLowerCase()
return target.isContentEditable || ['input', 'textarea', 'select'].includes(tagName)
}
async function toggleFullscreen() {
if (typeof document === 'undefined') return
try {
const fullscreenElement = document.fullscreenElement || document.webkitFullscreenElement
if (fullscreenElement) {
const exitFullscreen = document.exitFullscreen || document.webkitExitFullscreen
if (exitFullscreen) await exitFullscreen.call(document)
return
}
const target = document.documentElement
const requestFullscreen = target.requestFullscreen || target.webkitRequestFullscreen
if (requestFullscreen) await requestFullscreen.call(target)
} catch (error) {
showErrorToast('전체 화면 전환을 실행하지 못했어요.')
}
}
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)
document.removeEventListener('webkitfullscreenchange', syncFullscreenState)
}
syncRightRailBodyScrollLock(false)
})
watch(
@@ -350,6 +474,14 @@ watch(
mobileLeftNavOpen.value = false
rightRailOpen.value = false
}
refreshUnreadCommentCount()
}
)
watch(
() => auth.user?.id,
() => {
refreshUnreadCommentCount()
}
)
@@ -383,6 +515,14 @@ watch(
{ immediate: true }
)
watch(
shouldLockRightRailBodyScroll,
(shouldLock) => {
syncRightRailBodyScrollLock(shouldLock)
},
{ immediate: true }
)
function isRouteActive(path) {
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
@@ -457,7 +597,7 @@ function handleLeftRailSearch() {
function submitGlobalSearch() {
const query = (searchQuery.value || '').trim()
isCollapsedSearchOpen.value = false
router.push(homePath(query))
router.push(route.name === 'templates' ? templatesPath(query) : homePath(query))
}
function reloadApp() {
@@ -511,10 +651,10 @@ 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">{{ accountEmail }}</div>
<div class="appUserCard__email" :class="{ 'appUserCard__email--hint': isAccountEmailHint }">{{ accountEmail }}</div>
</div>
<button
v-if="isMobileLayout"
@@ -557,6 +697,7 @@ function reloadApp() {
<span class="leftNav__glyph">
<SvgIcon v-if="item.iconSrc" :src="item.iconSrc" :size="24" />
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
<span v-if="item.showDot" class="leftNav__dot" aria-hidden="true"></span>
</span>
<span class="leftNav__label">{{ item.label }}</span>
</RouterLink>
@@ -967,6 +1108,7 @@ function reloadApp() {
.appUserCard__button,
.appUserCard__guest {
width: 100%;
min-width: 0;
display: flex;
gap: 12px;
align-items: center;
@@ -1007,6 +1149,7 @@ function reloadApp() {
.leftRail__mobileMenu {
display: grid;
min-width: 0;
}
.appUserCard__navToggle {
@@ -1027,18 +1170,30 @@ function reloadApp() {
.appUserCard__name {
font-size: 14px;
font-weight: 800;
min-width: 0;
overflow-wrap: anywhere;
}
.appUserCard__email {
font-size: 12px;
color: var(--theme-text-muted);
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.appUserCard__email--hint {
white-space: normal;
overflow: visible;
text-overflow: unset;
overflow-wrap: anywhere;
word-break: keep-all;
}
.searchStub {
width: 100%;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
@@ -1147,12 +1302,24 @@ function reloadApp() {
/* width: 28px; */
/* height: 28px; */
/* border-radius: 10px; */
position: relative;
display: grid;
place-items: center;
/* background: rgba(255, 255, 255, 0.06); */
flex: 0 0 auto;
}
.leftNav__dot {
position: absolute;
top: -2px;
right: -3px;
width: 9px;
height: 9px;
border-radius: 999px;
background: #ff4d67;
box-shadow: 0 0 0 2px var(--theme-surface);
}
.appShell--leftCollapsed .leftRail__top {
justify-content: center;
}
@@ -1366,7 +1533,7 @@ function reloadApp() {
.workspaceBody--localRail {
min-height: 0;
padding: 18px 18px 32px;
padding: 18px 18px 0;
border: 0;
border-radius: 0;
background: var(--theme-workspace-bg);
@@ -1837,6 +2004,8 @@ function reloadApp() {
min-height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: 14px;
}
@@ -2031,6 +2200,8 @@ function reloadApp() {
@media (max-width: 860px) {
.appShell {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
align-content: start;
min-height: 100dvh;
}
@@ -2081,6 +2252,16 @@ function reloadApp() {
color: var(--theme-text-soft);
}
.rightRail--overlay {
inset: 0;
width: 100vw;
min-width: 0;
height: 100dvh;
min-height: 100dvh;
border-left: 0;
box-shadow: none;
}
.appMain {
min-height: auto;
border-left: 0;
@@ -2147,12 +2328,14 @@ function reloadApp() {
.workspaceBody {
padding: 0 0 calc(28px + env(safe-area-inset-bottom));
border-radius: 0;
background: transparent;
margin: 14px 14px 0;
}
.workspaceBody--localRail {
padding: 0 0 calc(28px + env(safe-area-inset-bottom));
border-radius: 0;
background: transparent;
margin: 14px 14px 0;
}
@@ -2169,7 +2352,16 @@ function reloadApp() {
}
.rightRail--overlay .rightRail__body {
padding-bottom: calc(14px + env(safe-area-inset-bottom));
padding: 14px 20px calc(32px + env(safe-area-inset-bottom));
}
.rightRail--overlay .rightRail__content {
flex: 0 0 auto;
overflow: visible;
}
.rightRail--overlay .localRightRailRoot {
min-height: auto;
}
.collapsedSearchModal {

View File

@@ -0,0 +1,158 @@
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
placeholder: { type: String, default: '태그 입력 후 Enter' },
disabled: { type: Boolean, default: false },
maxTags: { type: Number, default: 30 },
maxTagLength: { type: Number, default: 40 },
})
const emit = defineEmits(['update:modelValue'])
const draft = ref('')
const isComposing = ref(false)
const normalizedTags = computed(() =>
Array.from(
new Set(
(Array.isArray(props.modelValue) ? props.modelValue : [])
.map((tag) => String(tag || '').trim().replace(/^#/, '').slice(0, props.maxTagLength))
.filter(Boolean)
)
).slice(0, props.maxTags)
)
function commitTags(nextTags) {
emit(
'update:modelValue',
Array.from(
new Set(
(nextTags || [])
.map((tag) => String(tag || '').trim().replace(/^#/, '').slice(0, props.maxTagLength))
.filter(Boolean)
)
).slice(0, props.maxTags)
)
}
function addDraftTag() {
if (props.disabled) return
const nextTag = String(draft.value || '').trim().replace(/^#/, '').slice(0, props.maxTagLength)
if (!nextTag) return
commitTags([...normalizedTags.value, nextTag])
draft.value = ''
}
function removeTag(tag) {
if (props.disabled) return
commitTags(normalizedTags.value.filter((entry) => entry !== tag))
}
function handleKeydown(event) {
if (event.isComposing || isComposing.value) return
if (event.key === 'Enter') {
event.preventDefault()
addDraftTag()
return
}
if (event.key === 'Backspace' && !draft.value && normalizedTags.value.length) {
removeTag(normalizedTags.value[normalizedTags.value.length - 1])
}
}
function handleBlur() {
if (isComposing.value) return
addDraftTag()
}
function handleCompositionStart() {
isComposing.value = true
}
function handleCompositionEnd() {
isComposing.value = false
}
</script>
<template>
<div class="tagBadgeInput" :class="{ 'tagBadgeInput--disabled': disabled }">
<div v-if="normalizedTags.length" class="tagBadgeInput__list">
<span v-for="tag in normalizedTags" :key="tag" class="tagBadgeInput__badge">
<span>#{{ tag }}</span>
<button class="tagBadgeInput__remove" type="button" :disabled="disabled" @click="removeTag(tag)">X</button>
</span>
</div>
<input
v-model="draft"
class="tagBadgeInput__input"
type="text"
:placeholder="placeholder"
:disabled="disabled || normalizedTags.length >= maxTags"
:maxlength="maxTagLength"
@keydown="handleKeydown"
@blur="handleBlur"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
/>
</div>
</template>
<style scoped>
.tagBadgeInput {
display: grid;
gap: 10px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid color-mix(in srgb, var(--theme-border-strong) 72%, rgba(255, 255, 255, 0.08));
background: color-mix(in srgb, var(--theme-surface-soft) 82%, rgba(255, 255, 255, 0.02));
}
.tagBadgeInput--disabled {
opacity: 0.72;
}
.tagBadgeInput__list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tagBadgeInput__badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 11px;
border-radius: 999px;
background: color-mix(in srgb, var(--theme-accent-soft) 55%, rgba(255, 255, 255, 0.06));
color: var(--theme-text);
font-size: 12px;
font-weight: 700;
}
.tagBadgeInput__remove {
border: none;
background: transparent;
color: inherit;
font-size: 11px;
font-weight: 800;
cursor: pointer;
padding: 0;
}
.tagBadgeInput__input {
width: 100%;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--theme-text);
font: inherit;
}
.tagBadgeInput__input::placeholder {
color: var(--theme-text-soft);
}
</style>

View File

@@ -0,0 +1,631 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { api } from '../lib/api'
import { displayInitialFrom } from '../lib/display'
import { loginPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
const props = defineProps({
tierListId: {
type: String,
default: '',
},
canWrite: {
type: Boolean,
default: false,
},
currentUserId: {
type: String,
default: '',
},
title: {
type: String,
default: '댓글',
},
description: {
type: String,
default: '티어표에 대한 의견을 남기고 답글로 대화를 이어갈 수 있어요.',
},
})
const route = useRoute()
const comments = ref([])
const isLoading = ref(false)
const error = ref('')
const commentDraft = ref('')
const replyDrafts = ref({})
const openedReplyComposerId = ref('')
const submittingTargetId = ref('')
const deletingCommentId = ref('')
const replyInputRefs = ref({})
let activeCommentRetryTimer = 0
const totalCommentCount = computed(() =>
comments.value.reduce((count, comment) => count + 1 + (comment.replies?.length || 0), 0)
)
const loginTarget = computed(() => loginPath(route.fullPath))
const highlightedCommentId = computed(() =>
typeof route.query.commentId === 'string' ? route.query.commentId.trim() : ''
)
function avatarUrlOf(user) {
return user?.authorAvatarSrc ? toApiUrl(user.authorAvatarSrc) : ''
}
function avatarFallbackOf(user) {
return displayInitialFrom(user?.authorName, user?.authorAccountName, '?')
}
function displayNameOf(user) {
return user?.authorName || '알 수 없음'
}
function formatDate(ts) {
return new Date(Number(ts || 0)).toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function isOwnComment(comment) {
return !!props.currentUserId && props.currentUserId === comment?.authorId
}
function isHighlighted(commentId) {
return !!commentId && highlightedCommentId.value === commentId
}
function clearActiveCommentRetry() {
if (typeof window === 'undefined' || !activeCommentRetryTimer) return
window.clearTimeout(activeCommentRetryTimer)
activeCommentRetryTimer = 0
}
async function scrollToHighlightedComment(attempt = 0) {
clearActiveCommentRetry()
if (!highlightedCommentId.value || typeof document === 'undefined') return
await nextTick()
const target = document.querySelector(`[data-comment-id="${highlightedCommentId.value}"]`)
if (target) {
target.scrollIntoView({ behavior: attempt === 0 ? 'auto' : 'smooth', block: 'center' })
return
}
if (attempt >= 6 || typeof window === 'undefined') return
activeCommentRetryTimer = window.setTimeout(() => {
scrollToHighlightedComment(attempt + 1)
}, 180)
}
async function loadComments() {
if (!props.tierListId) {
comments.value = []
return
}
isLoading.value = true
error.value = ''
try {
const data = await api.listTierListComments(props.tierListId)
comments.value = Array.isArray(data.comments) ? data.comments : []
scrollToHighlightedComment()
} catch (loadError) {
error.value = '댓글을 불러오지 못했어요.'
} finally {
isLoading.value = false
}
}
async function submitComment(parentCommentId = '') {
if (!props.canWrite || !props.tierListId) return
const isReply = !!parentCommentId
const draftValue = isReply ? replyDrafts.value[parentCommentId] || '' : commentDraft.value
const content = String(draftValue || '').trim()
if (!content) return
submittingTargetId.value = parentCommentId || 'root'
error.value = ''
try {
const data = await api.createTierListComment(props.tierListId, {
content,
parentCommentId,
})
comments.value = Array.isArray(data.comments) ? data.comments : []
if (isReply) {
replyDrafts.value = { ...replyDrafts.value, [parentCommentId]: '' }
openedReplyComposerId.value = ''
} else {
commentDraft.value = ''
}
await nextTick()
if (data.createdCommentId && typeof window !== 'undefined') {
const target = document.querySelector(`[data-comment-id="${data.createdCommentId}"]`)
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
} catch (submitError) {
const code = submitError?.data?.error
if (code === 'comment_reply_depth_invalid') {
error.value = '답글에는 다시 답글을 달 수 없어요.'
} else {
error.value = '댓글을 저장하지 못했어요.'
}
} finally {
submittingTargetId.value = ''
}
}
async function deleteComment(commentId) {
if (!commentId || !props.tierListId) return
deletingCommentId.value = commentId
error.value = ''
try {
const data = await api.deleteTierListComment(props.tierListId, commentId)
comments.value = Array.isArray(data.comments) ? data.comments : []
} catch (deleteError) {
error.value = '댓글을 삭제하지 못했어요.'
} finally {
deletingCommentId.value = ''
}
}
function registerReplyInput(commentId, element) {
if (!commentId) return
if (element) {
replyInputRefs.value[commentId] = element
return
}
delete replyInputRefs.value[commentId]
}
async function focusReplyInput(commentId) {
if (!commentId) return
await nextTick()
const target = replyInputRefs.value[commentId]
if (target && typeof target.focus === 'function') {
target.focus()
const value = target.value || ''
if (typeof target.setSelectionRange === 'function') {
target.setSelectionRange(value.length, value.length)
}
}
}
async function toggleReplyComposer(commentId) {
const nextId = openedReplyComposerId.value === commentId ? '' : commentId
openedReplyComposerId.value = nextId
if (!nextId) return
await focusReplyInput(nextId)
}
watch(() => props.tierListId, loadComments, { immediate: true })
watch(highlightedCommentId, () => {
scrollToHighlightedComment()
})
onBeforeUnmount(() => {
clearActiveCommentRetry()
})
</script>
<template>
<section class="commentsCard">
<header class="commentsCard__head">
<div class="commentsCard__headline">
<div class="commentsCard__eyebrow">Comments</div>
<h3 class="commentsCard__title">{{ title }}</h3>
<p class="commentsCard__desc">{{ description }}</p>
</div>
<div class="commentsCard__count">{{ totalCommentCount }}</div>
</header>
<div v-if="error" class="commentsCard__error">{{ error }}</div>
<div v-if="props.canWrite" class="commentsComposer">
<textarea
v-model="commentDraft"
class="commentsComposer__input"
maxlength="2000"
rows="3"
placeholder="이 티어표에 대한 의견을 남겨보세요."
/>
<div class="commentsComposer__footer">
<span class="commentsComposer__hint">{{ commentDraft.length }}/2000</span>
<button class="btn btn--save commentsComposer__submit" type="button" :disabled="!commentDraft.trim() || submittingTargetId === 'root'" @click="submitComment()">
댓글 등록
</button>
</div>
</div>
<div v-else class="commentsLoginCta">
<div class="commentsLoginCta__text">로그인하면 댓글과 답글을 남길 있어요.</div>
<RouterLink class="btn btn--ghost commentsComposer__submit" :to="loginTarget">로그인</RouterLink>
</div>
<div v-if="isLoading" class="commentsCard__empty">댓글을 불러오는 중이에요.</div>
<div v-else-if="comments.length === 0" class="commentsCard__empty">아직 댓글이 없어요. 댓글을 남겨보세요.</div>
<div v-else class="commentsThread">
<article
v-for="comment in comments"
:key="comment.id"
class="commentItem"
:class="{ 'commentItem--highlighted': isHighlighted(comment.id) }"
:data-comment-id="comment.id"
>
<div class="commentItem__head">
<div class="commentItem__author">
<img v-if="avatarUrlOf(comment)" class="commentItem__avatar" :src="avatarUrlOf(comment)" :alt="displayNameOf(comment)" draggable="false" />
<div v-else class="commentItem__avatar commentItem__avatar--fallback">{{ avatarFallbackOf(comment) }}</div>
<div class="commentItem__meta">
<div class="commentItem__name">{{ displayNameOf(comment) }}</div>
<div class="commentItem__date">{{ formatDate(comment.createdAt) }}</div>
</div>
</div>
<div class="commentItem__actions">
<button v-if="props.canWrite" class="commentItem__action" type="button" @click="toggleReplyComposer(comment.id)">답글</button>
<button
v-if="isOwnComment(comment)"
class="commentItem__action commentItem__action--danger"
type="button"
:disabled="deletingCommentId === comment.id"
@click="deleteComment(comment.id)"
>
삭제
</button>
</div>
</div>
<div class="commentItem__body">{{ comment.content }}</div>
<div v-if="openedReplyComposerId === comment.id && props.canWrite" class="replyComposer">
<textarea
v-model="replyDrafts[comment.id]"
:ref="(element) => registerReplyInput(comment.id, element)"
class="commentsComposer__input commentsComposer__input--reply"
maxlength="2000"
rows="2"
placeholder="답글을 입력하세요."
/>
<div class="commentsComposer__footer">
<span class="commentsComposer__hint">{{ (replyDrafts[comment.id] || '').length }}/2000</span>
<button class="btn btn--save commentsComposer__submit" type="button" :disabled="!(replyDrafts[comment.id] || '').trim() || submittingTargetId === comment.id" @click="submitComment(comment.id)">
답글 등록
</button>
</div>
</div>
<div v-if="comment.replies?.length" class="replyList">
<article
v-for="reply in comment.replies"
:key="reply.id"
class="commentItem commentItem--reply"
:class="{ 'commentItem--highlighted': isHighlighted(reply.id) }"
:data-comment-id="reply.id"
>
<div class="commentItem__head">
<div class="commentItem__author">
<img v-if="avatarUrlOf(reply)" class="commentItem__avatar" :src="avatarUrlOf(reply)" :alt="displayNameOf(reply)" draggable="false" />
<div v-else class="commentItem__avatar commentItem__avatar--fallback">{{ avatarFallbackOf(reply) }}</div>
<div class="commentItem__meta">
<div class="commentItem__name">{{ displayNameOf(reply) }}</div>
<div class="commentItem__date">{{ formatDate(reply.createdAt) }}</div>
</div>
</div>
<div class="commentItem__actions">
<button
v-if="isOwnComment(reply)"
class="commentItem__action commentItem__action--danger"
type="button"
:disabled="deletingCommentId === reply.id"
@click="deleteComment(reply.id)"
>
삭제
</button>
</div>
</div>
<div class="commentItem__body">{{ reply.content }}</div>
</article>
</div>
</article>
</div>
</section>
</template>
<style scoped>
.commentsCard {
margin-top: 24px;
padding: 18px;
border-radius: 22px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
}
.commentsCard__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.commentsCard__headline {
min-width: 0;
}
.commentsCard__eyebrow {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--theme-text-faint);
}
.commentsCard__title {
margin: 6px 0 8px;
font-size: 22px;
font-weight: 900;
letter-spacing: -0.04em;
}
.commentsCard__desc {
margin: 0;
color: var(--theme-text-muted);
font-size: 12px;
line-height: 1.55;
}
.commentsCard__count {
flex: 0 0 auto;
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text-muted);
font-size: 13px;
font-weight: 800;
}
.commentsCard__error {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
color: var(--theme-text);
}
.commentsComposer,
.commentsLoginCta {
margin-bottom: 18px;
padding: 14px;
border-radius: 18px;
background: var(--theme-surface-soft);
}
.commentsComposer__input {
width: 100%;
min-height: 92px;
resize: vertical;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--theme-border);
background: var(--theme-input-bg);
color: var(--theme-text);
box-sizing: border-box;
}
.commentsComposer__input--reply {
min-height: 72px;
background: var(--theme-input-bg);
}
.commentsComposer__input:focus {
outline: none;
border-color: color-mix(in srgb, var(--theme-accent) 60%, var(--theme-field-border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-accent) 16%, transparent);
}
.commentsComposer__footer,
.commentsLoginCta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.commentsComposer__footer {
margin-top: 12px;
}
.commentsComposer__hint,
.commentsLoginCta__text {
color: var(--theme-text-muted);
font-size: 13px;
}
.commentsCard__empty {
padding: 20px 0;
color: var(--theme-text-muted);
}
.commentsThread {
display: grid;
gap: 12px;
}
.commentItem {
position: relative;
padding: 14px 0 0;
}
.commentItem--reply {
margin-top: 10px;
margin-left: 22px;
padding-top: 10px;
}
.commentItem--reply::before {
content: '';
position: absolute;
top: 0;
left: -12px;
width: 1px;
bottom: 0;
background: color-mix(in srgb, var(--theme-border) 82%, transparent);
}
.commentItem--highlighted {
border-radius: 18px;
background: color-mix(in srgb, var(--theme-accent) 10%, transparent);
}
.commentItem__head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.commentItem__author {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.commentItem__avatar {
width: 38px;
height: 38px;
border-radius: 999px;
object-fit: cover;
border: 1px solid var(--theme-avatar-border);
background: var(--theme-border);
flex: 0 0 auto;
}
.commentItem__avatar--fallback {
display: grid;
place-items: center;
font-size: 14px;
font-weight: 900;
}
.commentItem__meta {
min-width: 0;
}
.commentItem__name {
font-size: 14px;
font-weight: 800;
}
.commentItem__date {
margin-top: 4px;
font-size: 12px;
color: var(--theme-text-faint);
}
.commentItem__actions {
display: flex;
align-items: flex-start;
gap: 8px;
}
.commentItem__action {
border: 0;
background: transparent;
color: var(--theme-text-muted);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.commentItem__action--danger {
color: var(--theme-danger-text, #ff8f8f);
}
.commentItem__body {
padding: 14px 15px;
border-radius: 18px;
border: 1px solid var(--theme-border);
background: var(--theme-surface);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
}
.replyComposer {
margin-top: 14px;
padding: 14px;
border-radius: 18px;
background: var(--theme-surface-soft);
}
.commentsComposer__submit {
min-width: 112px;
min-height: 44px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 12px 18px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
cursor: pointer;
font-size: 15px;
font-weight: 700;
transition: background 160ms ease;
}
.btn:hover {
background: var(--theme-surface-soft-3);
}
.btn:disabled {
opacity: 0.58;
cursor: default;
}
.btn--save {
min-width: 112px;
font-weight: 900;
background: rgba(96, 165, 250, 0.22);
border-color: rgba(96, 165, 250, 0.36);
}
.btn--save:hover {
background: rgba(96, 165, 250, 0.3);
}
.btn--ghost {
background: color-mix(in srgb, var(--theme-surface-soft) 86%, transparent);
}
@media (max-width: 860px) {
.commentsCard {
padding: 18px 16px;
}
.commentsCard__head,
.commentsComposer__footer,
.commentsLoginCta,
.commentItem__head {
flex-direction: column;
align-items: stretch;
}
.commentItem--reply {
margin-left: 14px;
}
.commentItem--reply::before {
left: -8px;
}
}
</style>

View File

@@ -25,6 +25,7 @@ const props = defineProps({
>
{{ item.sourceLabel }}
</span>
<span v-if="item.replacedAt" class="customItemCard__badge customItemCard__badge--replaced">대체됨</span>
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
</button>

View File

@@ -9,6 +9,9 @@ const props = defineProps({
stagedRequestDraftCount: { type: Number, required: true },
appliedRequestItemCount: { type: Number, required: true },
openTemplateCreateModal: { type: Function, required: true },
openTemplateSourceImportModal: { type: Function, required: true },
openTemplateLibraryItemModal: { type: Function, required: true },
openTemplateBulkTagModal: { type: Function, required: true },
isTemplateLoading: { type: Boolean, required: true },
hasSelectedTemplate: { type: Boolean, required: true },
selectedTemplate: { type: Object, default: null },
@@ -18,6 +21,7 @@ const props = defineProps({
templateMetaSaving: { type: Boolean, required: true },
canSaveTemplateMeta: { type: Boolean, required: true },
saveTemplateMeta: { type: Function, required: true },
canBulkTagTemplateItems: { type: Boolean, required: true },
canApplyThumbnail: { type: Boolean, required: true },
templateVisibilitySaving: { type: Boolean, required: true },
thumbFileInputRef: { type: Function, required: true },
@@ -170,8 +174,11 @@ function setThumbFileElement(el) {
</label>
<div class="templateSettingsCard__actions">
<button class="btn btn--ghost" :disabled="!props.canSaveTemplateMeta || props.templateMetaSaving" @click="props.saveTemplateMeta">
{{ props.templateMetaSaving ? '저장중...' : '이름/slug 저장' }}
{{ props.templateMetaSaving ? '저장중...' : '이름/주소 저장' }}
</button>
<button class="btn btn--ghost" :disabled="!props.canBulkTagTemplateItems" @click="props.openTemplateBulkTagModal">기본 아이템 공통 태그</button>
<button class="btn btn--ghost" @click="props.openTemplateLibraryItemModal">개별 아이템 검색</button>
<button class="btn btn--ghost" @click="props.openTemplateSourceImportModal">기존 템플릿 가져오기</button>
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
<button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button>
</div>
@@ -221,7 +228,7 @@ function setThumbFileElement(el) {
<div class="hint hint--tight">{{ draft.sourceName }}</div>
<div class="itemDraftRow__meta">
<span class="pill" :class="draft.kind === 'request' ? 'pill--requestItem' : 'pill--directFile'">
{{ draft.kind === 'request' ? '요청 아이템' : '직접 추가 파일' }}
{{ draft.kind === 'request' ? '요청 아이템' : draft.kind === 'library' ? '기존 템플릿' : '직접 추가 파일' }}
</span>
<button class="btn btn--danger btn--small" type="button" @click="props.removeUploadDraft(draft)">제외</button>
</div>
@@ -246,21 +253,19 @@ function setThumbFileElement(el) {
<button class="btn btn--primary btn--small" :disabled="!props.hasTemplateItemOrderChanges" @click="props.saveTemplateItemOrder">순서 저장</button>
</div>
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
<div v-else :ref="setTemplateItemListElement" class="thumbGrid">
<div v-else :ref="setTemplateItemListElement" class="thumbGrid">
<div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-template-item-id="item.id">
<img class="thumb thumb--template" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
<div class="thumbCard__actions">
<button
class="btn btn--ghost btn--small"
data-no-drag
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
@click="props.saveTemplateItemLabel(item)"
>
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
</button>
<button class="btn btn--danger btn--small" data-no-drag @click="props.removeTemplateItem(item.id)">아이템 삭제</button>
<div class="thumbCard__media">
<img class="thumb thumb--template" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
<button class="thumbCard__deleteBtn" type="button" data-no-drag @click="props.removeTemplateItem(item)">X</button>
</div>
<input
v-model="item.draftLabel"
class="input input--labelEdit"
placeholder="아이템 이름"
data-no-drag
@keydown.enter.prevent="props.saveTemplateItemLabel(item)"
/>
</div>
</div>
</div>

View File

@@ -1,5 +1,3 @@
import { nextTick } from 'vue'
export function useAdminCustomItems({
api,
toast,
@@ -14,14 +12,18 @@ export function useAdminCustomItems({
customItemModalHistoryActive,
modalTargetCustomItem,
customItemModalDraftLabel,
customItemModalDraftTags,
customItemModalLabelSaving,
customItemModalTargetTemplateId,
customItemReplacementQuery,
customItemReplacementItems,
customItemReplacementLoading,
customItemReplacementTargetId,
customItemReplacementBusy,
templates,
selectedTemplateId,
refreshCustomItems,
loadTemplate,
setTab,
selectAdminTemplate,
resetMessages,
success,
error,
@@ -56,10 +58,40 @@ export function useAdminCustomItems({
customItemModalHistoryActive.value = true
}
async function refreshReplacementCandidates() {
const currentItemId = modalTargetCustomItem.value?.id || ''
if (!currentItemId) {
customItemReplacementItems.value = []
return
}
try {
customItemReplacementLoading.value = true
const data = await api.listAdminCustomItems({
q: customItemReplacementQuery.value,
page: 1,
limit: 50,
filter: 'all',
collapseShared: true,
})
customItemReplacementItems.value = (data.items || []).filter((item) => item?.id && item.id !== currentItemId)
} catch (e) {
error.value = '대체할 이미지 목록을 불러오지 못했어요.'
customItemReplacementItems.value = []
} finally {
customItemReplacementLoading.value = false
}
}
function openCustomItemModal(item) {
modalTargetCustomItem.value = item || null
customItemModalDraftLabel.value = item?.label || ''
customItemModalDraftTags.value = Array.isArray(item?.tags) ? [...item.tags] : []
customItemModalTargetTemplateId.value = ''
customItemReplacementQuery.value = ''
customItemReplacementItems.value = []
customItemReplacementTargetId.value = ''
customItemReplacementBusy.value = false
customItemModalOpen.value = true
pushCustomItemModalHistoryState()
}
@@ -69,8 +101,14 @@ export function useAdminCustomItems({
customItemDeleteModalOpen.value = false
modalTargetCustomItem.value = null
customItemModalDraftLabel.value = ''
customItemModalDraftTags.value = []
customItemModalLabelSaving.value = false
customItemModalTargetTemplateId.value = ''
customItemReplacementQuery.value = ''
customItemReplacementItems.value = []
customItemReplacementTargetId.value = ''
customItemReplacementLoading.value = false
customItemReplacementBusy.value = false
if (fromPopState) {
customItemModalHistoryActive.value = false
@@ -85,7 +123,7 @@ export function useAdminCustomItems({
function openCustomItemDeleteModal(item) {
if (!item) return
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
if (item.sourceType === 'user' && !item.replacedAt && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
@@ -97,19 +135,10 @@ export function useAdminCustomItems({
customItemDeleteModalOpen.value = false
}
function jumpToTemplateAdmin(templateId) {
if (!templateId) return
closeCustomItemModal()
setTab('template-admin')
nextTick(() => {
selectAdminTemplate(templateId)
})
}
async function removeCustomItem(item = modalTargetCustomItem.value) {
resetMessages()
if (!item) return
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
if (item.sourceType === 'user' && !item.replacedAt && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
@@ -137,32 +166,56 @@ export function useAdminCustomItems({
async function removeUnusedCustomItems() {
resetMessages()
const ok = window.confirm('현재 검색 조건에 맞는 미사용 커스텀 이미지를 모두 삭제할까요?')
const ok = window.confirm('현재 검색 조건에 맞는 미사용 이미지를 모두 삭제할까요?')
if (!ok) return
try {
const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value })
await refreshCustomItems()
success.value = `${data.deletedCount || 0}개의 미사용 사용자 업로드 이미지를 삭제했어요.`
success.value = `${data.deletedCount || 0}개의 미사용 이미지를 삭제했어요.`
} catch (e) {
error.value = '미사용 커스텀 이미지 일괄 삭제에 실패했어요.'
error.value = '미사용 이미지 일괄 삭제에 실패했어요.'
}
}
function showUnusedCustomItems() {
if (customItemFilter.value === 'unused') return
resetMessages()
customItemFilter.value = 'unused'
customItemPage.value = 1
refreshCustomItems()
}
async function saveCustomItemModalLabel() {
const item = modalTargetCustomItem.value
const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60)
if (!item || !nextLabel || nextLabel === item.label || customItemModalLabelSaving.value) return
const nextTags = Array.from(
new Set(
(Array.isArray(customItemModalDraftTags.value) ? customItemModalDraftTags.value : [])
.map((tag) => tag.trim().replace(/^#/, '').slice(0, 40))
.filter(Boolean)
)
).slice(0, 30)
if (
!item ||
!nextLabel ||
(nextLabel === item.label && JSON.stringify(nextTags) === JSON.stringify(Array.isArray(item.tags) ? item.tags : [])) ||
customItemModalLabelSaving.value
) {
return
}
try {
customItemModalLabelSaving.value = true
const data = await api.updateAdminCustomItemLabel(item.id, { label: nextLabel, sourceType: item.sourceType })
const data = await api.updateAdminCustomItemLabel(item.id, { label: nextLabel, tags: nextTags, sourceType: item.sourceType })
item.label = data.item?.label || nextLabel
item.tags = Array.isArray(data.item?.tags) ? data.item.tags : nextTags
customItemModalDraftLabel.value = item.label
customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label } : entry))
toast.success('아이템 이름을 변경했어요.')
customItemModalDraftTags.value = [...item.tags]
customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label, tags: item.tags } : entry))
toast.success('아이템 메타를 변경했어요.')
} catch (e) {
error.value = '아이템 이름 변경에 실패했어요.'
error.value = '아이템 메타 변경에 실패했어요.'
} finally {
customItemModalLabelSaving.value = false
}
@@ -190,6 +243,80 @@ export function useAdminCustomItems({
}
}
async function unlinkCustomItemTemplate(item = modalTargetCustomItem.value, template) {
resetMessages()
if (!item?.id || !template?.id) {
error.value = '제외할 템플릿 정보를 찾지 못했어요.'
return
}
const ok = window.confirm(`"${template.name}" 템플릿에서 이 이미지를 제외할까요?`)
if (!ok) return
try {
await api.unlinkAdminCustomItemTemplate(item.id, { topicId: template.id })
if (selectedTemplateId.value === template.id) await loadTemplate()
await refreshCustomItems()
modalTargetCustomItem.value = {
...item,
linkedTemplates: (item.linkedTemplates || []).filter((entry) => entry.id !== template.id),
}
success.value = `"${template.name}" 템플릿에서 이미지를 제외했어요.`
} catch (e) {
error.value = '템플릿 연결 해제에 실패했어요.'
}
}
async function replaceCustomItem(item = modalTargetCustomItem.value) {
resetMessages()
const targetItem = customItemReplacementItems.value.find((entry) => entry.id === customItemReplacementTargetId.value)
if (!item?.id) {
error.value = '대체할 원본 아이템을 찾지 못했어요.'
return
}
if (!targetItem?.id) {
error.value = '대체할 대상 이미지를 먼저 선택해주세요.'
return
}
try {
customItemReplacementBusy.value = true
const data = await api.replaceAdminCustomItem(item.id, {
targetItemId: targetItem.id,
targetSourceType: targetItem.sourceType || 'user',
})
if (selectedTemplateId.value) await loadTemplate()
await refreshCustomItems()
closeCustomItemModal()
success.value = `"${item.label}" 이미지를 "${data.targetItem?.label || targetItem.label}" 기준으로 대체했어요.`
} catch (e) {
error.value = e?.status === 409 ? '같은 이미지/이름으로는 대체할 수 없어요.' : '이미지 대체에 실패했어요.'
} finally {
customItemReplacementBusy.value = false
}
}
async function restoreCustomItem(item = modalTargetCustomItem.value) {
resetMessages()
if (!item?.id || !item.replacedAt) {
error.value = '복구할 대체 이력이 없어요.'
return
}
try {
customItemReplacementBusy.value = true
await api.restoreAdminCustomItem(item.id)
if (selectedTemplateId.value) await loadTemplate()
await refreshCustomItems()
closeCustomItemModal()
success.value = `"${item.label}" 아이템을 원래 이미지로 복구했어요.`
} catch (e) {
error.value = '원래 이미지로 복구하지 못했어요.'
} finally {
customItemReplacementBusy.value = false
}
}
return {
submitCustomItemSearch,
changeCustomItemFilter,
@@ -200,10 +327,14 @@ export function useAdminCustomItems({
closeCustomItemModal,
openCustomItemDeleteModal,
closeCustomItemDeleteModal,
jumpToTemplateAdmin,
removeCustomItem,
removeUnusedCustomItems,
showUnusedCustomItems,
saveCustomItemModalLabel,
promoteCustomItem,
unlinkCustomItemTemplate,
refreshReplacementCandidates,
replaceCustomItem,
restoreCustomItem,
}
}

View File

@@ -31,6 +31,17 @@ export function useAdminTemplateManager({
success,
error,
}) {
function parseTagsText(value) {
return Array.from(
new Set(
String(value || '')
.split(',')
.map((tag) => tag.trim().replace(/^#/, '').slice(0, 40))
.filter(Boolean)
)
).slice(0, 30)
}
function normalizeDraftSrc(src) {
if (typeof src !== 'string') return ''
const raw = src.trim()
@@ -108,6 +119,32 @@ export function useAdminTemplateManager({
}
}
function mergeLibraryItemsIntoDrafts(items, sourceLabel = '') {
const existingTemplateSrcs = new Set((selectedTemplate.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean))
const existingDraftSrcs = new Set(uploadItemDrafts.value.map((draft) => normalizeDraftSrc(draft?.src)).filter(Boolean))
const existingKeys = new Set(uploadItemDrafts.value.map((draft) => `${draft.kind}:${draft.itemSourceType || ''}:${draft.itemId || draft.file?.name || ''}`))
const nextDrafts = (items || [])
.filter((item) => item?.id && item?.src)
.map((item) => ({
kind: 'library',
itemId: item.id,
itemSourceType: item.sourceType || 'template',
previewUrl: toApiUrl(item.src),
label: item.label || '',
tagsText: Array.isArray(item.tags) ? item.tags.join(', ') : '',
sourceName: sourceLabel ? `${sourceLabel} · ${item.label || item.id}` : (item.label || item.id),
src: item.src,
}))
.filter((draft) => !existingTemplateSrcs.has(normalizeDraftSrc(draft.src)))
.filter((draft) => !existingDraftSrcs.has(normalizeDraftSrc(draft.src)))
.filter((draft) => !existingKeys.has(`${draft.kind}:${draft.itemSourceType}:${draft.itemId}`))
if (nextDrafts.length) {
uploadItemDrafts.value = [...uploadItemDrafts.value, ...nextDrafts]
}
return nextDrafts.length
}
function removeUploadDraft(targetDraft) {
const targetKey = `${targetDraft.kind}:${targetDraft.requestId || ''}:${targetDraft.itemId || targetDraft.file?.name || ''}:${targetDraft.previewUrl || ''}`
uploadItemDrafts.value = uploadItemDrafts.value.filter((draft) => {
@@ -139,6 +176,7 @@ export function useAdminTemplateManager({
items: (data.items || []).map((item) => ({
...item,
draftLabel: item.label,
draftTags: Array.isArray(item.tags) ? item.tags.join(', ') : '',
})),
}
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
@@ -293,6 +331,7 @@ export function useAdminTemplateManager({
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
const libraryDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'library')
const totalUploadBytes = fileDrafts.reduce((sum, entry) => sum + Number(entry.file?.size || 0), 0)
let uploadCount = 0
@@ -352,6 +391,28 @@ export function useAdminTemplateManager({
}
}
if (libraryDrafts.length) {
for (const draft of libraryDrafts) {
const promoted = await api.promoteAdminTemplateItem(draft.itemId, {
topicId: selectedTemplateId.value,
})
const createdItem = promoted?.item || null
if (createdItem?.id) {
const nextTags = parseTagsText(draft.tagsText)
const needsMetaUpdate =
(draft.label || '').trim() !== (createdItem.label || '').trim() ||
JSON.stringify(nextTags) !== JSON.stringify(Array.isArray(createdItem.tags) ? createdItem.tags : [])
if (needsMetaUpdate) {
await api.updateAdminTemplateItem(selectedTemplateId.value, createdItem.id, {
label: (draft.label || createdItem.label || '').trim(),
tags: nextTags,
})
}
uploadCount += 1
}
}
}
resetUploadState()
await loadTemplate()
success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
@@ -397,6 +458,7 @@ export function useAdminTemplateManager({
items: (data.items || []).map((item) => ({
...item,
draftLabel: item.label,
draftTags: Array.isArray(item.tags) ? item.tags.join(', ') : '',
})),
}
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
@@ -412,6 +474,7 @@ export function useAdminTemplateManager({
destroyTemplateItemSortable,
syncTemplateItemSortable,
mergeRequestItemsIntoDrafts,
mergeLibraryItemsIntoDrafts,
removeUploadDraft,
loadTemplate,
createTemplate,

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' }),
@@ -77,9 +78,9 @@ export const api = {
request(`/api/admin/templates/${encodeURIComponent(templateId)}`, { method: 'PATCH', body: payload }),
updateAdminTemplateItem: (templateId, itemId, payload) =>
request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) =>
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all', collapseShared = false } = {}) =>
request(
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}&collapseShared=${encodeURIComponent(collapseShared ? '1' : '0')}`
),
listAdminTierLists: ({ q = '', topicId = '', sort = 'recent', minFavorites = 0, page = 1, limit = 50 } = {}) =>
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&sort=${encodeURIComponent(sort)}&minFavorites=${encodeURIComponent(minFavorites)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
@@ -104,6 +105,12 @@ export const api = {
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
promoteAdminTemplateItem: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
unlinkAdminCustomItemTemplate: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/unlink-template`, { method: 'POST', body: payload }),
replaceAdminCustomItem: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/replace`, { method: 'POST', body: payload }),
restoreAdminCustomItem: (itemId) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/restore`, { method: 'POST', body: {} }),
updateAdminCustomItemLabel: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
promoteAdminTierListItems: (tierListId, payload) =>
@@ -163,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 },

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,441 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../lib/api'
import { displayInitialFrom } from '../lib/display'
import { editorPath, loginPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast'
const router = useRouter()
const toast = useToast()
const notifications = ref([])
const isLoading = ref(false)
const unreadOnly = ref(false)
const isMarkingAllRead = ref(false)
const unreadCount = computed(() => notifications.value.filter((item) => !item.isRead).length)
function avatarUrlOf(notification) {
return notification.actorAvatarSrc ? toApiUrl(notification.actorAvatarSrc) : ''
}
function tierListThumbnailUrl(notification) {
return notification.tierListThumbnailSrc ? toApiUrl(notification.tierListThumbnailSrc) : ''
}
function avatarFallbackOf(notification) {
return displayInitialFrom(notification.actorName, notification.actorAccountName, '?')
}
function formatDate(ts) {
return new Date(Number(ts || 0)).toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function notificationTitle(notification) {
return notification.notificationType === 'comment_reply' ? '내 댓글에 답글이 달렸어요.' : '내 티어표에 새 댓글이 달렸어요.'
}
function notificationLead(notification) {
return notification.notificationType === 'comment_reply' ? '원래 댓글과 새 답글을 함께 확인해보세요.' : '내 티어표에 새로 남겨진 댓글입니다.'
}
function emitUnreadCount(unread) {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent('tier-maker:comment-inbox-updated', { detail: { unreadCount: unread } }))
}
async function loadInbox() {
isLoading.value = true
try {
const data = await api.listCommentInbox({ unreadOnly: unreadOnly.value })
notifications.value = Array.isArray(data.notifications) ? data.notifications : []
emitUnreadCount(unreadCount.value)
} catch (error) {
toast.error('댓글 알림을 불러오지 못했어요.')
} finally {
isLoading.value = false
}
}
async function markOneAsRead(notificationId) {
const target = notifications.value.find((item) => item.id === notificationId)
if (!target || target.isRead) return
target.isRead = true
emitUnreadCount(unreadCount.value)
try {
await api.markCommentInboxRead({ notificationIds: [notificationId] })
} catch (error) {
target.isRead = false
emitUnreadCount(unreadCount.value)
}
}
async function markAllAsRead() {
if (!unreadCount.value) return
isMarkingAllRead.value = true
const original = notifications.value.map((item) => ({ ...item }))
notifications.value = notifications.value.map((item) => ({ ...item, isRead: true }))
emitUnreadCount(0)
try {
await api.markCommentInboxRead({ all: true })
if (unreadOnly.value) {
notifications.value = []
}
} catch (error) {
notifications.value = original
emitUnreadCount(unreadCount.value)
toast.error('읽음 처리를 완료하지 못했어요.')
} finally {
isMarkingAllRead.value = false
}
}
async function openNotification(notification) {
await markOneAsRead(notification.id)
router.push({
path: editorPath(notification.topicSlug || notification.topicId, notification.tierListId),
query: { commentId: notification.commentId },
})
}
onMounted(async () => {
try {
await api.me()
} catch (error) {
toast.error('로그인이 필요해요.')
router.push(loginPath('/comments'))
return
}
loadInbox()
})
watch(unreadOnly, loadInbox)
</script>
<template>
<section class="pageWrap">
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Inbox</div>
<h1 class="pageHead__title">댓글 관리</h1>
<p class="pageHead__desc"> 티어표에 달린 댓글과, 댓글에 달린 답글을 한곳에서 확인하고 바로 이동할 있어요.</p>
</div>
</section>
<section class="commentInboxToolbar">
<label class="commentInboxToolbar__toggle">
<input v-model="unreadOnly" type="checkbox" />
<span> 읽은 댓글만 보기</span>
</label>
<button class="btn btn--ghost btn--small" type="button" :disabled="!unreadCount || isMarkingAllRead" @click="markAllAsRead">
모두 읽음 처리
</button>
</section>
<section class="commentInboxPanel">
<div v-if="isLoading" class="commentInboxEmpty">댓글 알림을 불러오는 중이에요.</div>
<div v-else-if="notifications.length === 0" class="commentInboxEmpty">
{{ unreadOnly ? ' 읽은 댓글 알림이 없어요.' : '아직 도착한 댓글 알림이 없어요.' }}
</div>
<div v-else class="commentInboxList">
<article
v-for="notification in notifications"
:key="notification.id"
class="commentInboxCard"
:class="{ 'commentInboxCard--unread': !notification.isRead }"
>
<button class="commentInboxCard__body" type="button" @click="openNotification(notification)">
<div class="commentInboxCard__thumbWrap">
<img
v-if="tierListThumbnailUrl(notification)"
class="commentInboxCard__thumb"
:src="tierListThumbnailUrl(notification)"
:alt="notification.tierListTitle || '티어표 썸네일'"
draggable="false"
/>
<div v-else class="commentInboxCard__thumbFallback">티어표</div>
</div>
<div class="commentInboxCard__main">
<div class="commentInboxCard__titleRow">
<div>
<div class="commentInboxCard__title">{{ notificationTitle(notification) }}</div>
<div class="commentInboxCard__lead">{{ notificationLead(notification) }}</div>
</div>
<div class="commentInboxCard__status">
<span v-if="!notification.isRead" class="commentInboxCard__dot" aria-label="안 읽음"></span>
<span class="commentInboxCard__badge">{{ notification.notificationType === 'comment_reply' ? '답글' : '댓글' }}</span>
</div>
</div>
<div class="commentInboxCard__meta">
<img
v-if="avatarUrlOf(notification)"
class="commentInboxCard__avatar"
:src="avatarUrlOf(notification)"
:alt="notification.actorName || '작성자'"
draggable="false"
/>
<div v-else class="commentInboxCard__avatar commentInboxCard__avatar--fallback">{{ avatarFallbackOf(notification) }}</div>
<span class="commentInboxCard__actor">{{ notification.actorName }}</span>
<span class="commentInboxCard__separator">·</span>
<span class="commentInboxCard__date">{{ formatDate(notification.createdAt) }}</span>
</div>
<div class="commentInboxCard__target">
{{ notification.tierListTitle || '제목 없는 티어표' }}
<span class="commentInboxCard__targetMeta">/ {{ notification.topicName || notification.topicSlug || notification.topicId }}</span>
</div>
<div class="commentInboxCard__thread">
<div v-if="notification.parentCommentContent" class="commentInboxCard__threadBlock">
<div class="commentInboxCard__threadLabel">원래 댓글</div>
<div class="commentInboxCard__threadText">{{ notification.parentCommentContent }}</div>
</div>
<div class="commentInboxCard__threadBlock commentInboxCard__threadBlock--accent">
<div class="commentInboxCard__threadLabel">{{ notification.notificationType === 'comment_reply' ? '새 답글' : '새 댓글' }}</div>
<div class="commentInboxCard__threadText">{{ notification.commentContent }}</div>
</div>
</div>
</div>
</button>
</article>
</div>
</section>
</section>
</template>
<style scoped>
.commentInboxToolbar {
margin-bottom: 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.commentInboxToolbar__toggle {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--theme-text-muted);
font-size: 14px;
font-weight: 700;
}
.commentInboxPanel {
border-radius: 28px;
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
padding: 24px;
}
.commentInboxEmpty {
color: var(--theme-text-muted);
}
.commentInboxList {
display: grid;
gap: 14px;
}
.commentInboxCard {
border-radius: 22px;
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-surface) 92%, var(--theme-surface-soft)) 0%, var(--theme-surface) 100%);
overflow: hidden;
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 5%, transparent),
0 14px 28px rgba(0, 0, 0, 0.06);
}
.commentInboxCard--unread {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--theme-accent) 38%, transparent),
0 14px 28px rgba(0, 0, 0, 0.08);
}
.commentInboxCard__body {
width: 100%;
padding: 18px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
gap: 18px;
align-items: stretch;
}
.commentInboxCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 18px;
overflow: hidden;
background: var(--theme-thumb-fallback-bg);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--theme-card-border) 26%, transparent);
}
.commentInboxCard__thumb,
.commentInboxCard__thumbFallback {
width: 100%;
height: 100%;
}
.commentInboxCard__thumb {
object-fit: cover;
display: block;
}
.commentInboxCard__thumbFallback {
display: grid;
place-items: center;
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 800;
}
.commentInboxCard__titleRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.commentInboxCard__title {
font-size: 17px;
font-weight: 900;
}
.commentInboxCard__lead {
margin-top: 6px;
color: var(--theme-text-muted);
font-size: 13px;
}
.commentInboxCard__status {
display: inline-flex;
align-items: center;
gap: 10px;
}
.commentInboxCard__dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: #ff4d67;
flex: 0 0 auto;
}
.commentInboxCard__badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--theme-accent) 18%, var(--theme-surface-soft));
color: var(--theme-text);
font-size: 12px;
font-weight: 800;
}
.commentInboxCard__meta {
margin-top: 10px;
display: flex;
align-items: center;
gap: 8px;
color: var(--theme-text-muted);
font-size: 13px;
}
.commentInboxCard__avatar {
width: 24px;
height: 24px;
border-radius: 999px;
object-fit: cover;
border: 1px solid var(--theme-avatar-border);
background: var(--theme-border);
flex: 0 0 auto;
}
.commentInboxCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 11px;
font-weight: 900;
}
.commentInboxCard__target {
margin-top: 12px;
font-size: 15px;
font-weight: 800;
}
.commentInboxCard__targetMeta {
color: var(--theme-text-faint);
font-weight: 700;
}
.commentInboxCard__content {
margin-top: 10px;
}
.commentInboxCard__thread {
margin-top: 14px;
display: grid;
gap: 10px;
}
.commentInboxCard__threadBlock {
padding: 14px 15px;
border-radius: 18px;
background: color-mix(in srgb, var(--theme-surface) 88%, var(--theme-surface-soft));
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--theme-card-border) 20%, transparent),
0 8px 18px rgba(0, 0, 0, 0.04);
}
.commentInboxCard__threadBlock--accent {
background: color-mix(in srgb, var(--theme-accent) 10%, var(--theme-surface));
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--theme-accent) 24%, transparent),
0 10px 20px rgba(59, 130, 246, 0.08);
}
.commentInboxCard__threadLabel {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--theme-text-faint);
}
.commentInboxCard__threadText {
margin-top: 8px;
color: var(--theme-text);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 860px) {
.commentInboxToolbar {
flex-direction: column;
align-items: stretch;
}
.commentInboxPanel {
padding: 20px;
}
.commentInboxCard__body {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -5,6 +5,7 @@ import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast'
import { editorPath, loginPath } from '../lib/paths'
import { displayInitialFrom } from '../lib/display'
const router = useRouter()
const toast = useToast()
@@ -30,7 +31,7 @@ function avatarSrcOf(tierList) {
}
function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
}
function tierListThumbnailUrl(tierList) {

View File

@@ -5,6 +5,7 @@ import { api } from '../lib/api'
import { editorPath, loginPath, userProfilePath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast'
import { displayInitialFrom } from '../lib/display'
const router = useRouter()
const toast = useToast()
@@ -38,7 +39,7 @@ function avatarSrcOf(tierList) {
}
function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
}
function tierListThumbnailUrl(tierList) {

View File

@@ -2,123 +2,166 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import SvgIcon from '../components/SvgIcon.vue'
import kidStarIcon from '../assets/icons/kid_star.svg'
import { editorPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import { loginPath, topicPath } from '../lib/paths'
import { useAuthStore } from '../stores/auth'
import { displayInitialFrom } from '../lib/display'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const templateRecords = ref([])
const featuredTierLists = ref([])
const tierLists = ref([])
const error = ref('')
const loadingFavoriteId = ref('')
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
const templates = computed(() => {
const filtered = templateRecords.value
.filter((item) => item.id !== 'freeform')
.filter((item) => {
if (!query.value) return true
const haystack = `${item.name || ''} ${item.slug || ''}`.toLowerCase()
return haystack.includes(query.value)
})
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim() : ''))
const brokenThumbnailIds = ref({})
return filtered.slice().sort((a, b) => {
if (!!a.isFavorited !== !!b.isFavorited) return a.isFavorited ? -1 : 1
const rankA = a.displayRank == null ? Number.MAX_SAFE_INTEGER : a.displayRank
const rankB = b.displayRank == null ? Number.MAX_SAFE_INTEGER : b.displayRank
if (rankA !== rankB) return rankA - rankB
if (Number(a.createdAt || 0) !== Number(b.createdAt || 0)) {
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
}
return (a.name || '').localeCompare(b.name || '', 'ko')
function fmt(ts) {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
})
}
async function loadTemplates() {
function displayNameOf(tierList) {
return tierList.authorName || '알 수 없음'
}
function avatarSrcOf(tierList) {
return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : ''
}
function avatarFallbackOf(tierList) {
return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
}
function tierListThumbnailUrl(tierList) {
if (!tierList?.id || brokenThumbnailIds.value[tierList.id]) return ''
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
}
function handleThumbnailError(tierListId) {
if (!tierListId || brokenThumbnailIds.value[tierListId]) return
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
}
async function loadHomeFeed() {
try {
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">
<article v-for="tierList in featuredTierLists" :key="`featured-${tierList.id}`" class="boardCard boardCard--featured">
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
<div class="boardCard__thumbWrap">
<img
v-if="tierListThumbnailUrl(tierList)"
class="boardCard__thumb"
:src="tierListThumbnailUrl(tierList)"
:alt="tierList.title"
draggable="false"
@error="handleThumbnailError(tierList.id)"
/>
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ tierList.title }}</div>
<div class="favoriteStat">{{ tierList.isFavorited ? '♥' : '♡' }} {{ tierList.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__topic">{{ tierList.topicName || tierList.topicSlug || tierList.topicId }}</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" draggable="false" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
</div>
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
</div>
</div>
</button>
</article>
</div>
</section>
<section class="panel">
<div class="sectionLabel">최신 공개 티어표</div>
<div v-if="tierLists.length === 0" class="empty">{{ query ? '검색어에 맞는 공개 티어표가 없어요.' : '아직 공개 티어표가 없어요.' }}</div>
<div v-else class="list">
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
<div class="boardCard__thumbWrap">
<img
v-if="tierListThumbnailUrl(tierList)"
class="boardCard__thumb"
:src="tierListThumbnailUrl(tierList)"
:alt="tierList.title"
draggable="false"
@error="handleThumbnailError(tierList.id)"
/>
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ tierList.title }}</div>
<div class="favoriteStat">{{ tierList.isFavorited ? '♥' : '♡' }} {{ tierList.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__topic">{{ tierList.topicName || tierList.topicSlug || tierList.topicId }}</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" draggable="false" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
</div>
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
</div>
</div>
</button>
</article>
</div>
</section>
</section>
</template>
<style scoped>
.libraryGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
.pageHead__searchState {
margin-top: 8px;
color: var(--theme-text-muted);
}
.error {
margin: 0 0 16px;
@@ -128,143 +171,204 @@ 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;
}
.boardCard {
min-width: 0;
border-radius: 22px;
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
color: var(--theme-text);
cursor: pointer;
display: grid;
gap: 12px;
overflow: hidden;
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition: transform 0.16s ease, background 0.16s ease;
will-change: transform, opacity;
transition:
transform 0.16s ease,
background 0.16s ease;
}
.libraryCard:hover {
background: var(--theme-card-bg-hover);
.boardCard:hover {
transform: translateY(-2px);
background: var(--theme-card-bg-hover);
}
.libraryCard__main {
display: grid;
gap: 12px;
padding: 0;
.boardCard__body {
width: 100%;
min-width: 0;
text-align: left;
cursor: pointer;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
padding: 0;
display: grid;
overflow: hidden;
}
.libraryCard__favorite {
position: absolute;
bottom: 24px;
right: 14px;
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid var(--theme-favorite-border);
background: var(--theme-favorite-bg);
color: var(--theme-favorite-icon);
font-size: 17px;
line-height: 1;
cursor: pointer;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.libraryCard__favorite--active {
background: var(--theme-favorite-active-bg);
border-color: var(--theme-favorite-active-border);
}
.libraryCard__favoriteIcon {
opacity: 0.76;
color: var(--theme-favorite-icon);
}
.libraryCard__favorite--active .libraryCard__favoriteIcon {
opacity: 1;
color: var(--theme-favorite-active-icon);
}
.libraryCard__thumbWrap {
.boardCard__thumbWrap {
min-width: 0;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 14px;
border: 1px solid var(--theme-surface-soft-2);
background: var(--theme-thumb-fallback-bg);
overflow: hidden;
display: grid;
place-items: center;
padding: 14px 14px 0;
box-sizing: border-box;
}
.libraryCard__thumb {
.boardCard__thumb,
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
display: block;
border-radius: 18px;
}
.boardCard__thumb {
object-fit: cover;
}
.libraryCard__thumbFallback {
font-size: 14px;
color: var(--theme-text-faint);
}
.libraryCard__body {
.boardCard__thumbPlaceholder {
background: var(--theme-thumb-fallback-bg);
display: grid;
place-items: center;
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 700;
}
.libraryCard__title {
.boardCard__head {
min-width: 0;
padding: 16px 18px 18px;
display: grid;
gap: 8px;
overflow: hidden;
}
.boardCard__titleRow,
.boardCard__metaRow {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.boardCard__titleRow {
align-items: flex-start;
}
.boardCard__metaRow {
align-items: flex-end;
}
.boardCard__title {
min-width: 0;
font-weight: 800;
letter-spacing: -0.02em;
font-size: 18px;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.libraryCard__meta {
.boardCard__topic {
min-width: 0;
color: var(--theme-text-soft);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.libraryCard-move,
.libraryCard-enter-active,
.libraryCard-leave-active {
transition: transform 280ms ease, opacity 220ms ease;
.boardCard__author {
min-width: 0;
max-width: 100%;
display: inline-flex;
gap: 7px;
align-items: center;
font-size: 13px;
opacity: 0.86;
overflow: hidden;
}
.libraryCard-enter-from,
.libraryCard-leave-to {
opacity: 0;
transform: translateY(10px) scale(0.985);
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.libraryCard-leave-active {
position: absolute;
width: calc(100% - 0px);
pointer-events: none;
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 9999px;
object-fit: cover;
border: 1px solid var(--theme-avatar-border);
background: var(--theme-border);
flex: 0 0 auto;
}
.libraryEmpty {
padding: 20px 0;
color: var(--theme-text-muted);
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 11px;
font-weight: 800;
color: var(--theme-text);
}
.boardCard__date,
.favoriteStat {
flex: 0 0 auto;
font-size: 12px;
color: var(--theme-text-soft);
}
.favoriteStat {
font-weight: 800;
}
@media (max-width: 1400px) {
.libraryGrid {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1200px) {
.libraryGrid {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.libraryGrid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.libraryGrid {
grid-template-columns: 1fr;
.list {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -5,6 +5,7 @@ import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast'
import { editorPath, loginPath } from '../lib/paths'
import { displayInitialFrom } from '../lib/display'
const router = useRouter()
const toast = useToast()
@@ -35,7 +36,7 @@ function avatarSrcOf(tierList) {
}
function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
}
function tierListThumbnailUrl(tierList) {

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,263 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import SvgIcon from '../components/SvgIcon.vue'
import kidStarIcon from '../assets/icons/kid_star.svg'
import { toApiUrl } from '../lib/runtime'
import { loginPath, topicPath } from '../lib/paths'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const templateRecords = ref([])
const error = ref('')
const loadingFavoriteId = ref('')
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
const templates = computed(() => {
const filtered = templateRecords.value
.filter((item) => item.id !== 'freeform')
.filter((item) => {
if (!query.value) return true
const haystack = `${item.name || ''} ${item.slug || ''}`.toLowerCase()
return haystack.includes(query.value)
})
return filtered.slice().sort((a, b) => {
if (!!a.isFavorited !== !!b.isFavorited) return a.isFavorited ? -1 : 1
const rankA = a.displayRank == null ? Number.MAX_SAFE_INTEGER : a.displayRank
const rankB = b.displayRank == null ? Number.MAX_SAFE_INTEGER : b.displayRank
if (rankA !== rankB) return rankA - rankB
if (Number(a.createdAt || 0) !== Number(b.createdAt || 0)) {
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
}
return (a.name || '').localeCompare(b.name || '', 'ko')
})
})
async function loadTemplates() {
try {
const data = await api.listTopics()
templateRecords.value = data.topics || []
} catch (e) {
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
}
}
onMounted(loadTemplates)
watch(() => auth.user?.id, loadTemplates)
function openTopic(template) {
router.push(topicPath(template?.slug || template?.id || ''))
}
async function toggleFavorite(template, event) {
event?.stopPropagation()
if (!auth.user) {
router.push(loginPath(route.fullPath || '/templates'))
return
}
if (!template?.id || loadingFavoriteId.value === template.id) return
try {
loadingFavoriteId.value = template.id
const res = template.isFavorited ? await api.unfavoriteTopic(template.id) : await api.favoriteTopic(template.id)
templateRecords.value = templateRecords.value.map((entry) => (entry.id === template.id ? { ...entry, ...(res.topic || {}) } : entry))
} catch (e) {
error.value = '즐겨찾기 변경에 실패했어요.'
} finally {
loadingFavoriteId.value = ''
}
}
function templateThumbUrl(template) {
return template.thumbnailSrc ? toApiUrl(template.thumbnailSrc) : ''
}
</script>
<template>
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Topic</div>
<h1 class="pageHead__title">템플릿</h1>
<p class="pageHead__desc">자주 쓰는 주제 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 있어요.</p>
<p v-if="query" class="pageHead__searchState">"{{ query }}" 맞는 주제 템플릿만 보고 있어요.</p>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<TransitionGroup v-if="templates.length" name="libraryCard" tag="section" class="libraryGrid">
<article v-for="template in templates" :key="template.id" class="libraryCard">
<button
class="libraryCard__favorite"
type="button"
:class="{ 'libraryCard__favorite--active': template.isFavorited }"
:disabled="loadingFavoriteId === template.id"
@click.stop="toggleFavorite(template, $event)"
>
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
</button>
<button class="libraryCard__main" type="button" @click="openTopic(template)">
<div class="libraryCard__thumbWrap">
<img v-if="templateThumbUrl(template)" class="libraryCard__thumb" :src="templateThumbUrl(template)" :alt="template.name" draggable="false" />
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
</div>
<div class="libraryCard__body">
<div class="libraryCard__title">{{ template.name }}</div>
<div class="libraryCard__meta">{{ template.slug || template.id }}</div>
</div>
</button>
</article>
</TransitionGroup>
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 주제 템플릿이 없어요.' : '표시할 주제 템플릿이 없어요.' }}</div>
</template>
<style scoped>
.libraryGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.error {
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
color: var(--theme-text);
}
.pageHead__searchState {
margin-top: 8px;
color: var(--theme-text-muted);
}
.libraryCard {
position: relative;
text-align: left;
padding: 14px;
border-radius: 22px;
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
color: var(--theme-text);
cursor: pointer;
display: grid;
gap: 12px;
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition: transform 0.16s ease, background 0.16s ease;
will-change: transform, opacity;
}
.libraryCard:hover {
background: var(--theme-card-bg-hover);
transform: translateY(-2px);
}
.libraryCard__main {
display: grid;
gap: 12px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.libraryCard__favorite {
position: absolute;
bottom: 24px;
right: 14px;
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid var(--theme-favorite-border);
background: var(--theme-favorite-bg);
color: var(--theme-favorite-icon);
font-size: 17px;
line-height: 1;
cursor: pointer;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.libraryCard__favorite--active {
background: var(--theme-favorite-active-bg);
border-color: var(--theme-favorite-active-border);
}
.libraryCard__favoriteIcon {
opacity: 0.76;
color: var(--theme-favorite-icon);
}
.libraryCard__favorite--active .libraryCard__favoriteIcon {
opacity: 1;
color: var(--theme-favorite-active-icon);
}
.libraryCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 14px;
border: 1px solid var(--theme-surface-soft-2);
background: var(--theme-thumb-fallback-bg);
overflow: hidden;
display: grid;
place-items: center;
}
.libraryCard__thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.libraryCard__thumbFallback {
font-size: 14px;
color: var(--theme-text-faint);
}
.libraryCard__body {
display: grid;
min-width: 0;
}
.libraryCard__title {
font-weight: 800;
letter-spacing: -0.02em;
font-size: 18px;
}
.libraryCard__meta {
color: var(--theme-text-soft);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.libraryCard-move,
.libraryCard-enter-active,
.libraryCard-leave-active {
transition: transform 280ms ease, opacity 220ms ease;
}
.libraryCard-enter-from,
.libraryCard-leave-to {
opacity: 0;
transform: translateY(10px) scale(0.985);
}
.libraryCard-leave-active {
position: absolute;
width: calc(100% - 0px);
pointer-events: none;
}
.libraryEmpty {
padding: 20px 0;
color: var(--theme-text-muted);
}
@media (max-width: 1400px) {
.libraryGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1200px) {
.libraryGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.libraryGrid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

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'
@@ -90,13 +91,17 @@ let editorLoadToken = 0
const boardEl = ref(null)
const exportBoardEl = ref(null)
const groupListEl = ref(null)
const sidebarEl = ref(null)
const poolEl = ref(null)
const poolSearchEl = ref(null)
const groupDropEls = ref({})
const fileEl = ref(null)
const thumbnailFileEl = ref(null)
const groupSortable = ref(null)
const poolSortable = ref(null)
const dropSortables = ref([])
const editorSidebarMaxHeight = ref('')
let editorSidebarMeasureFrame = 0
const isNewTierList = computed(() => tierListId.value === 'new')
const isOwnTierList = computed(() => !!auth.user && !!ownerId.value && ownerId.value === auth.user.id)
@@ -155,6 +160,8 @@ const canRequestTemplateCreate = computed(
const canRequestTemplateUpdate = computed(
() => canEdit.value && hasSavedTierList.value && templateId.value !== 'freeform' && customItems.value.length > 0
)
const activeTierListId = computed(() => persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : ''))
const currentUserId = computed(() => auth.user?.id || '')
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const templateRequestTargetLabel = computed(() => (templateId.value === 'freeform' ? '새로운 템플릿' : (templateName.value || templateId.value || '선택한 주제')))
@@ -359,6 +366,35 @@ 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
const stickyTop = 14
const minHeight = 260
const sidebarTop = Math.max(sidebarEl.value.getBoundingClientRect().top, stickyTop)
const nextHeight = Math.max(minHeight, Math.floor(window.innerHeight - sidebarTop - bottomGap))
editorSidebarMaxHeight.value = `${nextHeight}px`
}
function scheduleEditorSidebarMeasure() {
if (typeof window === 'undefined') return
if (editorSidebarMeasureFrame) return
editorSidebarMeasureFrame = window.requestAnimationFrame(() => {
editorSidebarMeasureFrame = 0
updateEditorSidebarMaxHeight()
})
}
function focusPoolSearch() {
poolSearchEl.value?.focus()
poolSearchEl.value?.select()
}
function openItemContextMenu(itemId, event) {
if (!canEdit.value || !itemId || !itemsById.value[itemId] || shouldIgnoreItemClick()) return
selectedItemId.value = itemId
@@ -473,6 +509,25 @@ function duplicateItemToPool() {
toast.success('아이템 추가 완료')
}
function canRemoveEditorItem(itemId) {
return canEdit.value && itemsById.value[itemId]?.origin === 'custom'
}
function deleteEditorItem(itemId) {
if (!canRemoveEditorItem(itemId)) return
const targetItem = itemsById.value[itemId]
detachItemById(itemId)
if (typeof targetItem?.src === 'string' && targetItem.src.startsWith('blob:')) {
URL.revokeObjectURL(targetItem.src)
}
const nextItems = { ...itemsById.value }
delete nextItems[itemId]
itemsById.value = nextItems
if (selectedItemId.value === itemId) selectedItemId.value = ''
if (itemContextMenu.value.itemId === itemId) closeItemContextMenu()
toast.success('커스텀 이미지를 현재 티어표에서 제거했어요.')
}
function handleGlobalContextMenu(event) {
const target = event?.target
if (target?.closest?.('[data-item-context-menu]')) {
@@ -1009,12 +1064,20 @@ async function copyShareUrl() {
function openViewerMode() {
if (!canSwitchToViewerMode.value) return
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value, { preview: true }))
router.push({
path: editorPath(templateId.value, persistedTierListId.value || tierListId.value),
query: { ...route.query, preview: '1' },
})
}
function openEditMode() {
if (!canSwitchToEditMode.value) return
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value))
const nextQuery = { ...route.query }
delete nextQuery.preview
router.push({
path: editorPath(templateId.value, persistedTierListId.value || tierListId.value),
query: nextQuery,
})
}
function closeNavigationConfirmModal() {
@@ -1302,7 +1365,10 @@ async function loadEditorState() {
isFavorited.value = !!t.isFavorited
if (!previewMode.value && !canEdit.value) {
router.replace(editorPath(templateId.value, t.id, { preview: true }))
router.replace({
path: editorPath(templateId.value, t.id),
query: { ...route.query, preview: '1' },
})
return
}
@@ -1331,6 +1397,7 @@ async function loadEditorState() {
if (loadToken !== editorLoadToken) return
syncSavedEditorSnapshot()
scheduleEditorSidebarMeasure()
if (canEdit.value) {
await initSortables()
}
@@ -1350,6 +1417,10 @@ onMounted(() => {
window.addEventListener('contextmenu', handleGlobalContextMenu, true)
window.addEventListener('blur', closeItemContextMenu)
window.addEventListener('scroll', closeItemContextMenu, true)
window.addEventListener('resize', scheduleEditorSidebarMeasure)
window.addEventListener('scroll', scheduleEditorSidebarMeasure, true)
window.addEventListener('tier-maker:focus-editor-item-search', focusPoolSearch)
nextTick(() => scheduleEditorSidebarMeasure())
})
onUnmounted(() => {
@@ -1358,6 +1429,13 @@ onUnmounted(() => {
window.removeEventListener('contextmenu', handleGlobalContextMenu, true)
window.removeEventListener('blur', closeItemContextMenu)
window.removeEventListener('scroll', closeItemContextMenu, true)
window.removeEventListener('resize', scheduleEditorSidebarMeasure)
window.removeEventListener('scroll', scheduleEditorSidebarMeasure, true)
window.removeEventListener('tier-maker:focus-editor-item-search', focusPoolSearch)
if (editorSidebarMeasureFrame) {
window.cancelAnimationFrame(editorSidebarMeasureFrame)
editorSidebarMeasureFrame = 0
}
}
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
destroySortables()
@@ -1396,19 +1474,19 @@ onUnmounted(() => {
</div>
</div>
</div>
<div v-if="pool.length" class="previewOnly__pool">
<div class="previewOnly__poolTitle">남은 아이템</div>
<div class="previewOnly__poolGrid">
<div v-for="id in pool" :key="id" class="previewOnly__poolItem previewOnly__poolItem--inactive">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
</div>
</div>
</div>
<div class="previewOnly__footer">
<span>{{ effectiveAuthorName }}</span>
<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">
@@ -1571,7 +1649,15 @@ onUnmounted(() => {
<div class="editorMain">
<section class="head">
<div class="editorMain__headCopy">
<div class="editorMain__title">{{ templateName || templateId }}</div>
<button
class="editorMain__title editorMain__titleButton"
type="button"
title="본문을 화면 위로 이동"
@click="scrollWorkspaceBodyToTop"
@keydown.space.prevent="scrollWorkspaceBodyToTop"
>
{{ templateName || templateId }}
</button>
<div class="editorMain__subtitle">
<template v-if="canEdit">
/ 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 있어요.
@@ -1725,50 +1811,69 @@ onUnmounted(() => {
<button class="btn btn--ghost btn--small dropzone__button" @click="openFile">파일 선택</button>
</div>
</div>
<div class="editorTips">
<div class="editorTips__title">작업 </div>
<ul class="editorTips__list">
<li>마우스 오른쪽 클릭으로 아이템을 복수 사용하거나 커스텀 이미지를 빠르게 정리할 있어요.</li>
<li>미사용 아이템은 미리보기와 이미지 저장 결과에 표시되지 않으니, 필요한 것만 골라 배치해도 괜찮아요.</li>
<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="sidebar">
<div class="sidebar__titleRow">
<div class="sidebar__title">아이템</div>
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
</div>
<div class="sidebar__hint">
{{ canEdit ? '아이템을 드래그하거나, 클릭으로 선택한 뒤 원하는 셀/풀을 클릭해서 옮길 수 있어요.' : '공개 티어표는 보기 전용입니다.' }}
</div>
<input
v-model="poolSearchQuery"
class="sidebar__search"
type="text"
maxlength="60"
placeholder="아이템 이름 검색"
/>
<div
ref="poolEl"
class="pool"
:class="{ 'pool--clickTarget': canEdit && !!selectedItemId }"
data-list-type="pool"
@click.self="moveSelectedItemToPool"
>
<div class="sidebarStickyFrame">
<div ref="sidebarEl" class="sidebar" :style="{ '--editor-sidebar-max-height': editorSidebarMaxHeight || undefined }">
<div class="sidebar__titleRow">
<div class="sidebar__title">아이템</div>
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
</div>
<div class="sidebar__hint">
{{ canEdit ? '아이템을 드래그하거나, 클릭으로 선택한 뒤 원하는 셀/풀을 클릭해서 옮길 수 있어요.' : '공개 티어표는 보기 전용입니다.' }}
</div>
<input
ref="poolSearchEl"
v-model="poolSearchQuery"
class="sidebar__search"
type="text"
maxlength="60"
placeholder="아이템 이름 검색"
/>
<div
v-for="id in pool"
:key="id"
class="poolItem"
:class="{
'poolItem--readonly': !canEdit,
'poolItem--hidden': !isPoolItemVisible(id),
'poolItem--selected': selectedItemId === id,
}"
:data-item-id="id"
@click.stop="selectItemByClick(id)"
ref="poolEl"
class="pool"
:class="{ 'pool--clickTarget': canEdit && !!selectedItemId }"
data-list-type="pool"
@click.self="moveSelectedItemToPool"
>
<img
:src="resolveItemSrc(itemsById[id])"
class="thumb"
:alt="itemsById[id]?.label || id"
draggable="false"
/>
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
<div v-if="!canEdit" class="poolItem__state">미배치</div>
<div
v-for="id in pool"
:key="id"
class="poolItem"
:class="{
'poolItem--readonly': !canEdit,
'poolItem--hidden': !isPoolItemVisible(id),
'poolItem--selected': selectedItemId === id,
}"
:data-item-id="id"
@click.stop="selectItemByClick(id)"
>
<img
:src="resolveItemSrc(itemsById[id])"
class="thumb"
:alt="itemsById[id]?.label || id"
draggable="false"
/>
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
<div v-if="!canEdit" class="poolItem__state">미배치</div>
</div>
</div>
</div>
</div>
@@ -1785,6 +1890,9 @@ onUnmounted(() => {
<button class="itemContextMenu__action" type="button" @click="duplicateItemToPool">
아이템 복제
</button>
<button v-if="canRemoveEditorItem(itemContextMenu.itemId)" class="itemContextMenu__action itemContextMenu__action--danger" type="button" @click="deleteEditorItem(itemContextMenu.itemId)">
커스텀 이미지 제거
</button>
</div>
</div>
@@ -1926,12 +2034,36 @@ onUnmounted(() => {
grid-template-columns: minmax(0, clamp(680px, 58vw, 960px)) minmax(280px, 1fr);
gap: 16px;
align-items: start;
padding-bottom: 14px;
}
.sidebarStickyFrame {
min-width: 0;
align-self: stretch;
box-sizing: border-box;
}
.editorMain__title {
font-size: 28px;
font-weight: 900;
letter-spacing: -0.04em;
}
.editorMain__titleButton {
width: fit-content;
max-width: 100%;
border: 0;
padding: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.editorMain__titleButton:hover {
color: var(--theme-text-strong);
}
.editorMain__titleButton:focus-visible {
outline: 2px solid color-mix(in srgb, var(--theme-accent) 70%, white);
outline-offset: 4px;
border-radius: 8px;
}
.editorMain__subtitle {
color: var(--theme-text-soft);
font-size: 13px;
@@ -2067,7 +2199,7 @@ onUnmounted(() => {
}
.viewerSidebar__section {
margin-top: auto;
margin-top: 0;
display: grid;
gap: 10px;
padding: 18px;
@@ -2702,6 +2834,8 @@ onUnmounted(() => {
}
.sidebar {
min-width: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--theme-border);
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 94%, transparent), color-mix(in srgb, var(--theme-card-bg-hover) 88%, transparent));
border-radius: 22px;
@@ -2709,6 +2843,9 @@ onUnmounted(() => {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
position: sticky;
top: 14px;
max-height: var(--editor-sidebar-max-height, calc(100dvh - 136px));
overflow: hidden;
overscroll-behavior: contain;
}
.dropzone--board {
@@ -3023,15 +3160,47 @@ onUnmounted(() => {
color: var(--theme-text-faint);
}
.pool {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 10px;
align-content: start;
}
.pool::-webkit-scrollbar {
display: none;
}
.pool--clickTarget {
cursor: copy;
}
.editorTips {
margin-top: 14px;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: color-mix(in srgb, var(--theme-card-bg) 82%, transparent);
}
.editorTips__title {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--theme-text-soft);
}
.editorTips__list {
margin: 8px 0 0;
padding-left: 16px;
color: var(--theme-text-faint);
font-size: 12px;
line-height: 1.55;
}
.editorTips__list li + li {
margin-top: 6px;
}
.poolItem {
position: relative;
min-width: 0;
display: grid;
grid-template-columns: 1fr;
@@ -3051,7 +3220,6 @@ onUnmounted(() => {
}
.poolItem--selected {
border-color: rgba(96, 165, 250, 0.58);
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.92), 0 0 0 6px rgba(96, 165, 250, 0.18);
}
.poolItem .thumb {
width: 100%;
@@ -3078,6 +3246,23 @@ onUnmounted(() => {
text-transform: uppercase;
color: var(--theme-text-soft);
}
.poolItem__deleteBtn,
.cellDeleteBtn {
position: absolute;
right: 8px;
bottom: 8px;
border: 0;
border-radius: 999px;
padding: 6px 9px;
background: rgba(11, 18, 32, 0.84);
color: #fff;
font-size: 10px;
font-weight: 800;
cursor: pointer;
}
.cellDeleteBtn {
bottom: 34px;
}
.poolItem--hidden {
display: none;
}
@@ -3109,6 +3294,9 @@ onUnmounted(() => {
.itemContextMenu__action:hover {
background: var(--theme-pill-bg);
}
.itemContextMenu__action--danger {
color: #fca5a5;
}
.hidden {
display: none;
@@ -3117,7 +3305,7 @@ onUnmounted(() => {
opacity: 0.3;
}
.chosen {
outline: 2px solid rgba(110, 231, 183, 0.5);
border: 1px solid rgba(110, 231, 183, 0.5);
border-radius: 14px;
}
@media (max-width: 980px) {
@@ -3172,8 +3360,14 @@ onUnmounted(() => {
}
.sidebar {
position: static;
max-height: none;
overflow: visible;
}
.sidebarStickyFrame {
padding-bottom: 0;
}
.pool {
overflow: visible;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
}
@@ -3198,6 +3392,9 @@ onUnmounted(() => {
.previewOnly {
padding: 14px;
}
.viewerSidebar__section {
margin-top: 0;
}
.pool {
grid-template-columns: repeat(4, minmax(0, 1fr));
}

View File

@@ -5,6 +5,7 @@ import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { editorNewPath, editorPath, loginPath } from '../lib/paths'
import { useAuthStore } from '../stores/auth'
import { displayInitialFrom } from '../lib/display'
const route = useRoute()
const router = useRouter()
@@ -39,7 +40,7 @@ function avatarSrcOf(tierList) {
}
function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
return displayInitialFrom(tierList.authorName, tierList.authorAccountName, '?')
}
function tierListThumbnailUrl(tierList) {

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) {
@@ -333,8 +334,10 @@ watch(userId, loadProfile, { immediate: true })
color: inherit;
padding: 0;
display: grid;
overflow: hidden;
}
.boardCard__thumbWrap {
min-width: 0;
width: 100%;
aspect-ratio: 16 / 9;
padding: 14px 14px 0;
@@ -363,6 +366,7 @@ watch(userId, loadProfile, { immediate: true })
padding: 16px 18px 18px;
display: grid;
gap: 8px;
overflow: hidden;
}
.boardCard__titleRow,
.boardCard__metaRow {