Compare commits

...

66 Commits

Author SHA1 Message Date
60cc5a72c5 릴리스: v1.4.31 legacy game 호환층 제거 마감 2026-04-02 21:36:53 +09:00
8898ac24f9 릴리스: v1.4.30 로컬 DB 재초기화 및 legacy origin 자동 정리 2026-04-02 21:32:14 +09:00
9f6cb33bbd 릴리스: v1.4.29 레거시 game 흔적 최종 호환층 정리 2026-04-02 21:23:06 +09:00
b3575d59a6 릴리스: v1.4.28 관리자 템플릿 내부 이름층 추가 정리 2026-04-02 21:14:43 +09:00
d0ebc97bc3 릴리스: v1.4.27 내부 레거시 이름층 정리 2026-04-02 21:02:37 +09:00
139f78bb89 릴리스: v1.4.26 관리자/API 레거시 경로 정리 2026-04-02 20:58:30 +09:00
932b4e35a7 릴리스: v1.4.25 topic 응답/요청 키 정리 2026-04-02 20:51:03 +09:00
257d50f9c5 릴리스: v1.4.24 topic/template 응답 키 축소 2026-04-02 20:40:12 +09:00
6a8d4ddabd 릴리스: v1.4.23 레거시 game 별칭 추가 정리 2026-04-02 20:33:59 +09:00
75a3822502 릴리스: v1.4.22 공개 주제 라우트 파일명 정리 2026-04-02 20:31:11 +09:00
337bee8900 릴리스: v1.4.21 프런트 topic 응답 소비 정리 2026-04-02 20:25:49 +09:00
28fa7bb37d 릴리스: v1.4.20 백엔드 topic 이름층 정리 2026-04-02 20:22:49 +09:00
d089ba99e9 릴리스: v1.4.19 템플릿 아이템 삭제 영향 경고 및 보존 정책 정리 2026-04-02 20:15:17 +09:00
2923237813 릴리스: v1.4.18 요청 썸네일 새창 오류 수정 2026-04-02 20:07:06 +09:00
fd3e983cdc 릴리스: v1.4.17 editor 라우트 루프 수정 2026-04-02 20:03:11 +09:00
04ac5c6ede 릴리스: v1.4.16 장애 안내 화면 정리 2026-04-02 19:57:15 +09:00
79a187d120 릴리스: v1.4.15 db 초기화 안정화 2026-04-02 19:52:52 +09:00
0a87e3b1ec 릴리스: v1.4.14 topic 전환 안정화 2026-04-02 19:45:31 +09:00
136db137ec 릴리스: v1.4.13 topic 스키마 마이그레이션 정리 2026-04-02 19:11:45 +09:00
1fabf66f04 릴리스: v1.4.12 topics/templates API 경로 정리 2026-04-02 19:03:02 +09:00
9b97a7c23b 릴리스: v1.4.11 프런트 API 명칭 정리 1차 2026-04-02 18:59:29 +09:00
9b0a6d8f15 릴리스: v1.4.10 topicHub 라우트 명칭 정리 2026-04-02 18:57:12 +09:00
5af5202455 릴리스: v1.4.9 경로 헬퍼 도입과 사용자 이동 경로 정리 2026-04-02 18:55:12 +09:00
6b6676ceec 릴리스: v1.4.8 주제 헤더 안정화와 검색 헤더 통일 2026-04-02 18:50:21 +09:00
de640de4a1 릴리스: v1.4.7 컬렉션 레이아웃 정리와 topics 경로 1차 2026-04-02 18:47:37 +09:00
20955e277c 릴리스: v1.4.6 관리자 내부 명칭 정리 2차 2026-04-02 18:43:39 +09:00
1ed08d1e34 릴리스: v1.4.5 프런트 내부 명칭 정리 1차 2026-04-02 18:26:18 +09:00
a733c97991 릴리스: v1.4.4 화면 용어 정리 마무리 2026-04-02 18:20:13 +09:00
31613e4613 릴리스: v1.4.3 사용자 노출 용어 정리 3차 2026-04-02 18:17:50 +09:00
d5621362f1 릴리스: v1.4.2 사용자 노출 용어 정리 2차 2026-04-02 18:14:13 +09:00
caaddb8448 릴리스: v1.4.1 메뉴와 화면 타이틀 한글 통일 2026-04-02 18:11:17 +09:00
20186f7fe2 릴리스: v1.4.0 주제·템플릿 용어 정리 1차 2026-04-02 18:06:02 +09:00
77605791fb 릴리스: v1.3.93 목록 썸네일 드래그 방지 2026-04-02 17:34:40 +09:00
9bb64b52f3 릴리스: v1.3.92 왼쪽 네비 활성 배경 애니메이션 추가 2026-04-02 17:31:31 +09:00
c4d896ce36 릴리스: v1.3.91 로그인 토글 전환 애니메이션 추가 2026-04-02 17:28:34 +09:00
1957f30341 릴리스: v1.3.90 관리자 CSS 경고 정리 2026-04-02 17:22:22 +09:00
8d257e21ff 릴리스: v1.3.89 미사용 자산 정리와 공유 이미지 반영 2026-04-02 17:18:01 +09:00
19fdf85dcc 릴리스: v1.3.88 홈페이지 공유 메타와 파비콘 정리 2026-04-02 17:08:50 +09:00
2626fe2335 릴리스: v1.3.87 프리뷰 아이콘 크기 복원 보정 2026-04-02 16:58:24 +09:00
074d028f04 릴리스: v1.3.86 티어표 아이콘 크기 저장 지원 2026-04-02 16:54:59 +09:00
208e9709f8 릴리스: v1.3.85 모바일 열 헤더/행 라벨 보정 2026-04-02 16:48:07 +09:00
4ed7f275ba 릴리스: v1.3.84 모바일 열 배지 위치 및 헤더 정리 2026-04-02 16:45:43 +09:00
88ce413c31 릴리스: v1.3.83 모바일 열 배지 추가 2026-04-02 16:42:17 +09:00
7f7475fb20 릴리스: v1.3.82 프리뷰 메타와 요청 카드 동작 정리 2026-04-02 16:38:57 +09:00
8a44b51cce 릴리스: v1.3.81 티어표 공유 링크 복사 추가 2026-04-02 16:34:09 +09:00
9d63ed2e76 릴리스: v1.3.80 공통 오른쪽 레일 카피라이트 적용 2026-04-02 16:29:40 +09:00
99eb79f2c3 릴리스: v1.3.79 관리자 카피라이트 위치 고정 2026-04-02 16:26:04 +09:00
6b8abea203 릴리스: v1.3.78 축소 액션 아이콘과 카피라이트 정리 2026-04-02 16:22:12 +09:00
d692798358 릴리스: v1.3.77 축소 레일 티어표 만들기 버튼 추가 2026-04-02 16:13:50 +09:00
49d4946735 릴리스: v1.3.76 왼쪽 레일 검색/축소 정리 2026-04-02 16:10:36 +09:00
bd53cf96dc 릴리스: v1.3.75 관리자 모달/사이드바 정리 2026-04-02 16:04:07 +09:00
66c3b1e7b7 릴리스: v1.3.74 관리자 선택 제약과 카피라이트 추가 2026-04-02 15:55:10 +09:00
494f04d9a7 릴리스: v1.3.73 관리자 게임 선택 UX 정리 2026-04-02 15:50:20 +09:00
5aae278fd3 릴리스: v1.3.72 관리자 게임 라우트 초기화 오류 수정 2026-04-02 15:37:18 +09:00
14dfe0ad75 릴리스: v1.3.71 관리자 게임 선택 모달 통합 2026-04-02 15:34:07 +09:00
a7cfb97131 릴리스: v1.3.70 관리자 티어표 필터와 관리 액션 보강 2026-04-02 15:28:52 +09:00
badf250967 릴리스: v1.3.69 관리자 티어표 집계와 아이템 UI 정리 2026-04-02 15:21:03 +09:00
a16b1e1025 릴리스: v1.3.68 관리자 아이템 모달 레이아웃 안정화 2026-04-02 15:12:36 +09:00
c1dfea41a5 릴리스: v1.3.67 아이템 참조 요약과 영향 표시 2026-04-02 15:04:20 +09:00
188576f8ac 릴리스: v1.3.66 누락 썸네일 정리 조건 보정 2026-04-02 14:49:17 +09:00
5db1e57f13 릴리스: v1.3.65 누락 이미지 참조 정리 도구 추가 2026-04-02 14:37:59 +09:00
2918a0423c 릴리스: v1.3.64 관리자 필터와 게임 설정 패널 정리 2026-04-02 14:23:02 +09:00
b542b963d2 릴리스: v1.3.63 이미지 최적화 작업 라벨 정리 2026-04-02 14:03:41 +09:00
b98a3d5a6d 릴리스: v1.3.62 에디터 저장 순서와 삭제 조건 정리 2026-04-02 13:57:23 +09:00
d3c5eeae6a 릴리스: v1.3.61 드롭존 아이콘과 업로드 UI 통일 2026-04-02 13:48:14 +09:00
e3559f4a84 릴리스: v1.3.60 관리자 공개 토글과 인증 새로고침 안정화 2026-04-02 13:41:14 +09:00
45 changed files with 3714 additions and 1548 deletions

View File

@@ -7,7 +7,7 @@ const FileStoreFactory = require('session-file-store')
const { ensureData } = require('./src/db')
const authRoutes = require('./src/routes/auth')
const gamesRoutes = require('./src/routes/games')
const topicsRoutes = require('./src/routes/topics')
const tierListsRoutes = require('./src/routes/tierlists')
const adminRoutes = require('./src/routes/admin')
@@ -74,12 +74,13 @@ app.use(async (req, res, next) => {
await ensureData()
next()
} catch (e) {
console.error('[backend] db init failed', e)
res.status(500).json({ error: 'db_init_failed' })
}
})
app.use('/api/auth', authRoutes)
app.use('/api/games', gamesRoutes)
app.use('/api/topics', topicsRoutes)
app.use('/api/tierlists', tierListsRoutes)
app.use('/api/admin', adminRoutes)

File diff suppressed because it is too large Load Diff

View File

@@ -7,21 +7,24 @@ const { z } = require('zod')
const { nanoid } = require('nanoid')
const {
findUserById,
findGameById,
findGameItemById,
listGameItems,
findTopicById,
findTopicItemById,
listTopicItems,
findImageAssetById,
createGame,
listGames,
updateGameThumbnail,
createGameItem,
updateGameItemLabel,
updateGameItemDisplayOrder,
createTopic,
listTopics,
updateTopicThumbnail,
updateTopicVisibility,
createTopicItem,
updateTopicItemLabel,
updateTopicItemDisplayOrder,
countTierListsUsingTopicItem,
updateCustomItemLabel,
updateImageAssetLabel,
deleteGameItem,
deleteGame,
updateGameDisplayOrder,
deleteTopicItem,
deleteTopic,
deleteTierList,
updateTopicDisplayOrder,
listCustomItems,
findCustomItemById,
findUnusedCustomItems,
@@ -30,11 +33,13 @@ const {
listUsers,
findPrimaryAdminUser,
listAdminTierLists,
summarizeAdminTierLists,
findTierListById,
updateAdminTierListMeta,
listAdminTemplateRequests,
findTemplateRequestById,
updateTemplateRequestStatus,
updateTemplateRequestTargetGame,
updateTemplateRequestTargetTopic,
adminUpdateUser,
adminUpdateUserPassword,
adminDeleteUser,
@@ -43,12 +48,17 @@ const {
getImageAssetStats,
listRecentImageOptimizationJobs,
clearImageOptimizationJobs,
cleanupMissingUploadReferences,
} = require('../db')
const { requireAdmin } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
const router = express.Router()
function getTemplateIdFromParams(req) {
return req.params.templateId || ''
}
function buildUploadFilename(file) {
const ext = path.extname(file.originalname || '').toLowerCase()
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
@@ -105,75 +115,95 @@ function canManageAdminRole(actingUser, primaryAdmin) {
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
}
router.post('/games', requireAdmin, async (req, res) => {
router.post('/templates', requireAdmin, async (req, res) => {
const schema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(60),
isPublic: z.boolean().optional().default(false),
thumbnailSrc: z.string().max(255).optional().default(''),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findGameById(parsed.data.id)
if (exists) return res.status(409).json({ error: 'game_id_taken' })
const game = await createGame({ id: parsed.data.id, name: parsed.data.name })
const exists = await findTopicById(parsed.data.id)
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
const template = await createTopic({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
if (parsed.data.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
await updateGameThumbnail(game.id, copiedThumb)
await updateTopicThumbnail(template.id, copiedThumb)
}
res.json({ game: await findGameById(game.id) })
const savedTemplate = await findTopicById(template.id)
res.json({ template: savedTemplate })
})
router.patch('/games/display-order', requireAdmin, async (req, res) => {
router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
const schema = z.object({
gameIds: z.array(z.string().min(1)).max(50),
isPublic: z.boolean(),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const games = await listGames()
const validGameIds = new Set(games.map((game) => game.id))
const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId))
const updatedGames = await updateGameDisplayOrder(filteredIds)
res.json({ games: updatedGames })
const templateId = getTemplateIdFromParams(req)
const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' })
const updated = await updateTopicVisibility(template.id, parsed.data.isPublic)
res.json({ template: updated })
})
router.patch('/games/:gameId/items/display-order', requireAdmin, async (req, res) => {
router.patch('/templates/display-order', requireAdmin, async (req, res) => {
const schema = z.object({
topicIds: z.array(z.string().min(1)).max(50),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const templates = await listTopics('', { includePrivate: true })
const validTopicIds = new Set(templates.map((template) => template.id))
const filteredIds = parsed.data.topicIds.filter((topicId) => validTopicIds.has(topicId))
const updatedTemplates = await updateTopicDisplayOrder(filteredIds)
res.json({ templates: updatedTemplates })
})
router.patch('/templates/:templateId/items/display-order', requireAdmin, async (req, res) => {
const schema = z.object({
itemIds: z.array(z.string().min(1)).min(1),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
const templateId = getTemplateIdFromParams(req)
const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' })
const items = await updateGameItemDisplayOrder(game.id, parsed.data.itemIds)
const items = await updateTopicItemDisplayOrder(template.id, parsed.data.itemIds)
res.json({ items })
})
router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
router.post('/templates/:templateId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'file_required' })
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
const templateId = getTemplateIdFromParams(req)
const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' })
const optimized = await writeOptimizedImage({
file: req.file,
directory: 'games',
directory: 'topics',
width: 1280,
height: 1280,
fit: 'inside',
quality: 84,
})
const updated = await updateGameThumbnail(req.params.gameId, optimized.src)
res.json({ game: updated })
const updated = await updateTopicThumbnail(templateId, optimized.src)
res.json({ template: updated })
})
router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), async (req, res) => {
router.post('/templates/:templateId/images', requireAdmin, upload.array('images', 50), async (req, res) => {
const files = Array.isArray(req.files) ? req.files : []
if (!files.length) return res.status(400).json({ error: 'file_required' })
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
const templateId = getTemplateIdFromParams(req)
const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' })
const labelsRaw = req.body?.labels
const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : []
@@ -184,16 +214,16 @@ router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), a
files.map(async (file, index) => {
const optimized = await writeOptimizedImage({
file,
directory: 'games',
directory: 'topics',
width: 512,
height: 512,
fit: 'inside',
quality: 84,
})
return createGameItem({
return createTopicItem({
id: nanoid(),
gameId: game.id,
topicId: template.id,
src: optimized.src,
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
})
@@ -203,30 +233,40 @@ router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), a
res.json({ item: items[0], items })
})
router.delete('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
await deleteGameItem(req.params.itemId)
router.delete('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
const template = await findTopicById(getTemplateIdFromParams(req))
if (!template) return res.status(404).json({ error: 'not_found' })
await deleteTopicItem(req.params.itemId)
res.json({ ok: true })
})
router.patch('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
router.get('/templates/:templateId/items/:itemId/usage', requireAdmin, async (req, res) => {
const template = await findTopicById(getTemplateIdFromParams(req))
if (!template) return res.status(404).json({ error: 'not_found' })
const item = await findTopicItemById(req.params.itemId)
if (!item || item.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
const usage = await countTierListsUsingTopicItem(req.params.itemId)
res.json({ usage })
})
router.patch('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
const schema = z.object({ label: z.string().trim().min(1).max(60) })
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
const template = await findTopicById(getTemplateIdFromParams(req))
if (!template) return res.status(404).json({ error: 'not_found' })
const updated = await updateGameItemLabel(req.params.itemId, parsed.data.label)
if (!updated || updated.gameId !== game.id) return res.status(404).json({ error: 'not_found' })
const updated = await updateTopicItemLabel(req.params.itemId, parsed.data.label)
if (!updated || updated.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
res.json({ item: updated })
})
router.delete('/games/:gameId', requireAdmin, async (req, res) => {
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
await deleteGame(req.params.gameId)
router.delete('/templates/:templateId', requireAdmin, async (req, res) => {
const templateId = getTemplateIdFromParams(req)
const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' })
await deleteTopic(templateId)
res.json({ ok: true })
})
@@ -246,7 +286,7 @@ router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
}
if (parsed.data.sourceType === 'template') {
const updated = await updateGameItemLabel(itemId, parsed.data.label)
const updated = await updateTopicItemLabel(itemId, parsed.data.label)
if (!updated) return res.status(404).json({ error: 'not_found' })
return res.json({ item: updated })
}
@@ -261,11 +301,7 @@ 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),
orphanOnly: z
.union([z.literal('true'), z.literal('false'), z.boolean()])
.optional()
.default('false')
.transform((value) => value === true || value === 'true'),
filter: z.enum(['all', 'user', 'template', 'asset', 'unused-user', 'unused-admin']).optional().default('all'),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -274,7 +310,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
queryText: parsed.data.q,
page: parsed.data.page,
limit: parsed.data.limit,
orphanOnly: parsed.data.orphanOnly,
filterMode: parsed.data.filter,
})
res.json(result)
})
@@ -282,6 +318,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
router.get('/tierlists', requireAdmin, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
topicId: 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),
})
@@ -290,6 +327,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
const result = await listAdminTierLists({
queryText: parsed.data.q,
topicId: parsed.data.topicId,
page: parsed.data.page,
limit: parsed.data.limit,
currentUserId: req.session?.userId || '',
@@ -297,6 +335,21 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
res.json(result)
})
router.get('/tierlists/stats', requireAdmin, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
topicId: z.string().trim().max(120).optional().default(''),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const result = await summarizeAdminTierLists({
queryText: parsed.data.q,
topicId: parsed.data.topicId,
})
res.json(result)
})
router.get('/template-requests', requireAdmin, async (req, res) => {
const requests = await listAdminTemplateRequests({ statuses: ['pending', 'reviewing'] })
res.json({ requests })
@@ -374,6 +427,11 @@ router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => {
res.json({ deletedCount })
})
router.post('/image-assets/missing/cleanup', requireAdmin, async (req, res) => {
const result = await cleanupMissingUploadReferences()
res.json({ result })
})
async function removeUploadFiles(srcs) {
await Promise.all(
(srcs || []).map(async (src) => {
@@ -402,10 +460,10 @@ async function removeCustomItemFiles(items) {
)
}
async function promoteLibraryItemToGameItem({ item, gameId }) {
return createGameItem({
async function promoteLibraryItemToTemplateItem({ item, templateId }) {
return createTopicItem({
id: nanoid(),
gameId,
topicId: templateId,
src: item.src || '',
label: item.label,
})
@@ -442,7 +500,7 @@ function uniqueTierListPoolItems(tierList) {
})
}
async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
async function promoteTierListItemsToTemplate({ tierList, templateId, itemIds = [] }) {
const allowedIds = new Set((itemIds || []).filter(Boolean))
const sourceItems = uniqueTierListPoolItems(tierList).filter((item) => item.origin === 'custom')
const itemsToCopy = allowedIds.size ? sourceItems.filter((item) => allowedIds.has(item.id)) : sourceItems
@@ -451,9 +509,9 @@ async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
for (const item of itemsToCopy) {
const copiedSrc = await copyUploadIntoGameAsset(item.src)
createdItems.push(
await createGameItem({
await createTopicItem({
id: nanoid(),
gameId,
topicId: templateId,
src: copiedSrc,
label: item.label,
})
@@ -463,8 +521,8 @@ async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
return createdItems
}
async function promoteSnapshotItemsToGame({ items, gameId }) {
const existingItems = await listGameItems(gameId)
async function promoteSnapshotItemsToTemplate({ items, templateId }) {
const existingItems = await listTopicItems(templateId)
const existingSrcs = new Set(
existingItems
.map((item) => (typeof item?.src === 'string' ? item.src.trim() : ''))
@@ -476,9 +534,9 @@ async function promoteSnapshotItemsToGame({ items, gameId }) {
const copiedSrc = await copyUploadIntoGameAsset(item.src)
if (!copiedSrc || existingSrcs.has(copiedSrc)) continue
createdItems.push(
await createGameItem({
await createTopicItem({
id: nanoid(),
gameId,
topicId: templateId,
src: copiedSrc,
label: item.label,
})
@@ -515,47 +573,47 @@ function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}
})
}
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
await createGame({ id: gameId, name: gameName })
async function createTemplateFromTierList({ tierList, templateId, templateName }) {
await createTopic({ id: templateId, name: templateName, isPublic: false })
if (tierList.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc)
await updateGameThumbnail(gameId, copiedThumb)
await updateTopicThumbnail(templateId, copiedThumb)
}
const createdItems = []
for (const item of uniqueTierListPoolItems(tierList)) {
const copiedSrc = await copyUploadIntoGameAsset(item.src)
createdItems.push(
await createGameItem({
await createTopicItem({
id: nanoid(),
gameId,
topicId: templateId,
src: copiedSrc,
label: item.label,
})
)
}
return { game: await findGameById(gameId), items: createdItems }
return { template: await findTopicById(templateId), items: createdItems }
}
async function createGameTemplateFromRequest({ templateRequest, gameId, gameName }) {
await createGame({ id: gameId, name: gameName })
async function createTemplateFromRequest({ templateRequest, templateId, templateName }) {
await createTopic({ id: templateId, name: templateName, isPublic: false })
if (templateRequest.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc)
await updateGameThumbnail(gameId, copiedThumb)
await updateTopicThumbnail(templateId, copiedThumb)
}
const items = await promoteSnapshotItemsToGame({
const items = await promoteSnapshotItemsToTemplate({
items: templateRequest.items || [],
gameId,
templateId,
})
return { game: await findGameById(gameId), items }
return { template: await findTopicById(templateId), items }
}
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
const result = await listCustomItems({ page: 1, limit: 10000, orphanOnly: false })
const result = await listCustomItems({ page: 1, limit: 10000, filterMode: 'all' })
const target = result.items.find((item) => item.id === req.params.itemId)
if (!target) return res.status(404).json({ error: 'not_found' })
if (target.sourceType === 'template') {
@@ -568,12 +626,12 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
return res.json({ ok: true, sourceType: 'template-asset' })
}
await deleteGameItem(target.id)
await deleteTopicItem(target.id)
return res.json({ ok: true, sourceType: 'template' })
}
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
if (target.linkedGames.length > 0) return res.status(409).json({ error: 'item_linked' })
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' })
const items = await findCustomItemsByIds([target.id])
@@ -584,21 +642,21 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
const schema = z.object({
gameId: z.string().min(1),
topicId: z.string().min(1),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' })
const template = await findTopicById(parsed.data.topicId)
if (!template) return res.status(404).json({ error: 'topic_not_found' })
const customItem = await findCustomItemById(req.params.itemId)
const gameItem = customItem ? null : await findGameItemById(req.params.itemId)
const templateItem = customItem ? null : await findTopicItemById(req.params.itemId)
const assetItemId = String(req.params.itemId || '')
const imageAsset = !customItem && !gameItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null
const imageAsset = !customItem && !templateItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null
const sourceItem =
customItem ||
gameItem ||
templateItem ||
(imageAsset
? {
src: imageAsset.src || '',
@@ -607,102 +665,137 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
: null)
if (!sourceItem) return res.status(404).json({ error: 'not_found' })
const item = await promoteLibraryItemToGameItem({ item: sourceItem, gameId: game.id })
const item = await promoteLibraryItemToTemplateItem({ item: sourceItem, templateId: template.id })
res.json({ item })
})
router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => {
const schema = z.object({
gameId: z.string().min(1),
topicId: z.string().min(1),
itemIds: z.array(z.string().min(1)).optional().default([]),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' })
const template = await findTopicById(parsed.data.topicId)
if (!template) return res.status(404).json({ error: 'topic_not_found' })
const tierList = await findTierListById(req.params.tierListId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
const items = await promoteTierListItemsToGame({
const items = await promoteTierListItemsToTemplate({
tierList,
gameId: game.id,
templateId: template.id,
itemIds: parsed.data.itemIds,
})
res.json({ items })
})
router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (req, res) => {
router.post('/tierlists/:tierListId/create-template', requireAdmin, async (req, res) => {
const schema = z.object({
gameId: z.string().trim().min(1).max(120),
topicId: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(120),
itemIds: z.array(z.string().min(1)).optional().default([]),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findGameById(parsed.data.gameId)
if (exists) return res.status(409).json({ error: 'game_id_taken' })
const exists = await findTopicById(parsed.data.topicId)
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
const tierList = await findTierListById(req.params.tierListId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
const result = await createGameTemplateFromTierList({
const result = await createTemplateFromTierList({
tierList: {
...tierList,
pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool,
},
gameId: parsed.data.gameId,
gameName: parsed.data.name,
templateId: parsed.data.topicId,
templateName: parsed.data.name,
})
res.json(result)
})
router.patch('/tierlists/:tierListId', requireAdmin, async (req, res) => {
const schema = z.object({
title: z.string().trim().min(1).max(120),
description: z.string().max(500).optional().default(''),
isPublic: z.boolean(),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const tierList = await findTierListById(req.params.tierListId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
const updated = await updateAdminTierListMeta({
id: tierList.id,
title: parsed.data.title,
description: parsed.data.description || '',
isPublic: parsed.data.isPublic,
})
res.json({ tierList: updated })
})
router.delete('/tierlists/:tierListId', requireAdmin, async (req, res) => {
const tierList = await findTierListById(req.params.tierListId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
await deleteTierList(tierList.id)
res.json({ ok: true })
})
router.post('/template-requests/:requestId/approve', requireAdmin, async (req, res) => {
const templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
if (templateRequest.status !== 'pending') return res.status(409).json({ error: 'request_already_handled' })
if (templateRequest.type === 'update') {
const targetGameId = templateRequest.targetGameId || templateRequest.sourceGameId
const game = await findGameById(targetGameId)
if (!game) return res.status(404).json({ error: 'game_not_found' })
const targetTopicId = templateRequest.targetTopicId || templateRequest.sourceTopicId
const template = await findTopicById(targetTopicId)
if (!template) return res.status(404).json({ error: 'topic_not_found' })
const items = await promoteSnapshotItemsToGame({
const items = await promoteSnapshotItemsToTemplate({
items: templateRequest.items || [],
gameId: game.id,
templateId: template.id,
})
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
return res.json({ request, items })
}
const schema = z.object({
gameId: z.string().trim().min(1).max(120),
topicId: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(120),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findGameById(parsed.data.gameId)
if (exists) return res.status(409).json({ error: 'game_id_taken' })
const exists = await findTopicById(parsed.data.topicId)
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
const result = await createGameTemplateFromRequest({
const result = await createTemplateFromRequest({
templateRequest,
gameId: parsed.data.gameId,
gameName: parsed.data.name,
templateId: parsed.data.topicId,
templateName: parsed.data.name,
})
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
res.json({ request, ...result })
})
router.post('/template-requests/:requestId/review', requireAdmin, async (req, res) => {
const templateRequest = await findTemplateRequestById(req.params.requestId)
let templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
if (templateRequest.status === 'completed' || templateRequest.status === 'rejected' || templateRequest.status === 'approved') {
return res.status(409).json({ error: 'request_already_handled' })
}
if (templateRequest.type === 'create' && templateRequest.targetTopicId && !templateRequest.targetTopicName) {
templateRequest = await updateTemplateRequestTargetTopic({
id: templateRequest.id,
targetTopicId: '',
})
}
if (templateRequest.status === 'reviewing') {
return res.json({ request: templateRequest })
}
@@ -711,9 +804,9 @@ router.post('/template-requests/:requestId/review', requireAdmin, async (req, re
res.json({ request })
})
router.post('/template-requests/:requestId/link-game', requireAdmin, async (req, res) => {
router.post('/template-requests/:requestId/link-template', requireAdmin, async (req, res) => {
const schema = z.object({
gameId: z.string().trim().min(1).max(120),
topicId: z.string().trim().min(1).max(120),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -725,19 +818,19 @@ router.post('/template-requests/:requestId/link-game', requireAdmin, async (req,
return res.status(409).json({ error: 'request_already_handled' })
}
const game = await findGameById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' })
const template = await findTopicById(parsed.data.topicId)
if (!template) return res.status(404).json({ error: 'topic_not_found' })
const request = await updateTemplateRequestTargetGame({
const request = await updateTemplateRequestTargetTopic({
id: templateRequest.id,
targetGameId: game.id,
targetTopicId: template.id,
})
res.json({ request })
})
router.post('/template-requests/:requestId/promote-items', requireAdmin, async (req, res) => {
const schema = z.object({
gameId: z.string().trim().min(1).max(120),
topicId: z.string().trim().min(1).max(120),
itemIds: z.array(z.string().min(1)).optional().default([]),
itemSrcs: z.array(z.string().min(1)).optional().default([]),
itemLabels: z.record(z.string(), z.string().min(1).max(60)).optional().default({}),
@@ -751,8 +844,8 @@ router.post('/template-requests/:requestId/promote-items', requireAdmin, async (
return res.status(409).json({ error: 'request_already_handled' })
}
const game = await findGameById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' })
const template = await findTopicById(parsed.data.topicId)
if (!template) return res.status(404).json({ error: 'topic_not_found' })
const promotableItems = pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels, parsed.data.itemSrcs)
if (!promotableItems.length) {
@@ -761,14 +854,14 @@ router.post('/template-requests/:requestId/promote-items', requireAdmin, async (
let items = []
try {
items = await promoteSnapshotItemsToGame({
items = await promoteSnapshotItemsToTemplate({
items: promotableItems,
gameId: game.id,
templateId: template.id,
})
} catch (error) {
console.error('[admin] template request promote-items failed', {
requestId: templateRequest.id,
gameId: game.id,
topicId: template.id,
itemCount: promotableItems.length,
message: error?.message || 'unknown_error',
code: error?.code || '',

View File

@@ -1,36 +0,0 @@
const express = require('express')
const { listGames, getGameDetail, findGameById, favoriteGame, unfavoriteGame } = require('../db')
const { requireAuth } = require('../middleware/auth')
const router = express.Router()
router.get('/', async (req, res) => {
const games = await listGames(req.session?.userId || '')
res.json({ games })
})
router.post('/:gameId/favorite', requireAuth, async (req, res) => {
const game = await findGameById(req.params.gameId)
if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' })
await favoriteGame({ userId: req.session.userId, gameId: game.id })
const games = await listGames(req.session.userId)
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: true }
res.json({ game: updated })
})
router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
const game = await findGameById(req.params.gameId)
if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' })
await unfavoriteGame({ userId: req.session.userId, gameId: game.id })
const games = await listGames(req.session.userId)
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: false }
res.json({ game: updated })
})
router.get('/:gameId', async (req, res) => {
const detail = await getGameDetail(req.params.gameId)
if (!detail) return res.status(404).json({ error: 'not_found' })
res.json({ game: detail.game, items: detail.items })
})
module.exports = router

View File

@@ -20,11 +20,11 @@ const { requireAuth } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
const router = express.Router()
const FREEFORM_GAME_ID = 'freeform'
const FREEFORM_TOPIC_ID = 'freeform'
const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기'
function normalizePoolItem(item) {
if (!item || item.origin !== 'game' || typeof item.src !== 'string') return item
if (!item || item.origin !== 'template' || typeof item.src !== 'string') return item
if (item.src.startsWith('/uploads/')) return item
try {
@@ -61,7 +61,7 @@ const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 }
const templateRequestSchema = z.object({
type: z.enum(['create', 'update']),
sourceTierListId: z.string().max(64).optional().default(''),
gameId: z.string().min(1).max(120),
topicId: z.string().min(1).max(120).optional(),
requestTitle: z.string().trim().min(1).max(120),
requestDescription: z.string().trim().min(1).max(1000),
thumbnailSrc: z.string().max(255).optional().default(''),
@@ -72,26 +72,31 @@ const templateRequestSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(16),
itemIds: z.array(z.string()).optional().default([]),
}).passthrough()
}).passthrough().superRefine((value, ctx) => {
if (!value.topicId) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['topicId'] })
}
})
),
boardItems: z.array(
z.object({
id: z.string().min(1),
src: z.string().min(1),
label: z.string().min(1).max(60),
origin: z.enum(['game', 'custom']).default('game'),
origin: z.enum(['template', 'custom']).default('template'),
})
),
})
const tierListUpsertSchema = z.object({
id: z.string().optional(),
gameId: z.string().min(1),
topicId: z.string().min(1).optional(),
title: z.string().min(1).max(120),
thumbnailSrc: z.string().max(255).optional().default(''),
description: z.string().max(1000).optional().default(''),
isPublic: z.boolean().default(false),
showCharacterNames: z.boolean().optional().default(false),
iconSize: z.number().int().min(48).max(112).optional().default(80),
sourceTierListId: z.string().max(64).optional().default(''),
sourceSnapshotTitle: z.string().max(120).optional().default(''),
sourceSnapshotAuthor: z.string().max(120).optional().default(''),
@@ -107,15 +112,19 @@ const tierListUpsertSchema = z.object({
id: z.string().min(1),
src: z.string().min(1),
label: z.string().min(1).max(60),
origin: z.enum(['game', 'custom']).default('game'),
origin: z.enum(['template', 'custom']).default('template'),
})
),
}).superRefine((value, ctx) => {
if (!value.topicId) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['topicId'] })
}
})
router.get('/public', async (req, res) => {
const gameId = req.query.gameId
const topicId = typeof req.query.topicId === 'string' ? req.query.topicId : ''
const queryText = typeof req.query.q === 'string' ? req.query.q : ''
const lists = await listPublicTierLists(gameId, req.session?.userId || '', queryText)
const lists = await listPublicTierLists(topicId, req.session?.userId || '', queryText)
res.json({ tierLists: lists })
})
@@ -225,14 +234,15 @@ router.post('/template-request', requireAuth, async (req, res) => {
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const payload = parsed.data
const topicId = payload.topicId
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
if (payload.type === 'create') {
if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
} else if (payload.gameId === FREEFORM_GAME_ID) {
return res.status(400).json({ error: 'game_template_required' })
if (topicId !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' })
} else if (topicId === FREEFORM_TOPIC_ID) {
return res.status(400).json({ error: 'topic_template_required' })
}
let sourceTierList = null
@@ -250,8 +260,8 @@ router.post('/template-request', requireAuth, async (req, res) => {
type: payload.type,
requesterId: req.session.userId,
sourceTierListId: sourceTierList?.id || '',
sourceGameId: payload.gameId,
targetGameId: payload.type === 'update' ? payload.gameId : '',
sourceTopicId: topicId,
targetTopicId: payload.type === 'update' ? topicId : '',
title: payload.requestTitle,
description: payload.requestDescription,
thumbnailSrc: payload.thumbnailSrc || '',
@@ -273,6 +283,7 @@ router.post('/', requireAuth, async (req, res) => {
const parsed = tierListUpsertSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const payload = parsed.data
const topicId = payload.topicId
const normalizedPool = payload.pool.map(normalizePoolItem)
let existing = null
@@ -283,12 +294,13 @@ router.post('/', requireAuth, async (req, res) => {
const updated = await saveTierList({
id: existing.id,
authorId: existing.authorId,
gameId: existing.gameId,
topicId: existing.topicId,
title: payload.title,
thumbnailSrc: payload.thumbnailSrc || '',
description: payload.description || '',
isPublic: !!payload.isPublic,
showCharacterNames: !!payload.showCharacterNames,
iconSize: Number(payload.iconSize || 80),
sourceTierListId: payload.sourceTierListId || existing.sourceTierListId || '',
sourceSnapshotTitle: payload.sourceSnapshotTitle || existing.sourceSnapshotTitle || '',
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || existing.sourceSnapshotAuthor || '',
@@ -301,12 +313,13 @@ router.post('/', requireAuth, async (req, res) => {
const created = await saveTierList({
id: nanoid(),
authorId: req.session.userId,
gameId: payload.gameId,
topicId,
title: payload.title,
thumbnailSrc: payload.thumbnailSrc || '',
description: payload.description || '',
isPublic: !!payload.isPublic,
showCharacterNames: !!payload.showCharacterNames,
iconSize: Number(payload.iconSize || 80),
sourceTierListId: payload.sourceTierListId || '',
sourceSnapshotTitle: payload.sourceSnapshotTitle || '',
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || '',

View File

@@ -0,0 +1,37 @@
const express = require('express')
const { listTopics, getTopicDetail, findTopicById, favoriteTopic, unfavoriteTopic } = require('../db')
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 })
res.json({ topics })
})
router.post('/:topicId/favorite', requireAuth, async (req, res) => {
const topic = await findTopicById(req.params.topicId)
if (!topic || topic.id === 'freeform') return res.status(404).json({ error: 'not_found' })
await favoriteTopic({ userId: req.session.userId, topicId: topic.id })
const topics = await listTopics(req.session.userId)
const updated = topics.find((entry) => entry.id === topic.id) || { ...topic, isFavorited: true }
res.json({ topic: updated })
})
router.delete('/:topicId/favorite', requireAuth, async (req, res) => {
const topic = await findTopicById(req.params.topicId)
if (!topic || topic.id === 'freeform') return res.status(404).json({ error: 'not_found' })
await unfavoriteTopic({ userId: req.session.userId, topicId: topic.id })
const topics = await listTopics(req.session.userId)
const updated = topics.find((entry) => entry.id === topic.id) || { ...topic, isFavorited: false }
res.json({ topic: updated })
})
router.get('/:topicId', async (req, res) => {
const detail = await getTopicDetail(req.params.topicId)
if (!detail) return res.status(404).json({ error: 'not_found' })
if (!detail.topic.isPublic && !req.session?.isAdmin) return res.status(404).json({ error: 'not_found' })
res.json({ topic: detail.topic, items: detail.items })
})
module.exports = router

View File

@@ -1,5 +1,236 @@
# 의사결정 이력
## 2026-04-02 v1.4.31
- 서비스가 아직 외부 공개 전이고 예전 북마크/예전 데이터베이스를 이어갈 필요가 없다는 전제가 확인되었으므로, 남겨둔 호환층을 유지하는 것보다 지금 마감 시점에 완전히 제거해 구조를 단순화하는 편이 맞다고 판단했다.
- 이 단계에서는 “기존 것도 읽어준다”보다 “현재 구조만 남긴다”가 더 중요한 목표가 되었으므로, redirect·legacy migration·`origin: 'game'` 허용까지 함께 정리해 실제 코드 검색에서 `game` 흔적을 0건으로 맞추는 방향으로 마감했다.
## 2026-04-02 v1.4.30
- 로컬 MariaDB는 테스트용으로 새로 밀어도 된다는 전제가 확인되었으므로, 개발 환경에서는 기존 데이터를 끌고 가는 것보다 현재 스키마가 “빈 DB에서 바로 정상 부팅되는지”를 먼저 검증하는 편이 더 가치 있다고 판단했다.
- `origin: 'game'` 호환층은 즉시 제거하기보다, `ensureData()`에서 저장 데이터와 요청 스냅샷을 자동 정규화하게 만들어 두고 새 DB에서도 legacy 값이 생기지 않는 상태를 먼저 확인한 뒤 다음 제거 판단으로 넘기는 편이 가장 안전하다고 정리했다.
## 2026-04-02 v1.4.29
- `origin: 'game'`는 이미 저장된 티어표 데이터와 직접 맞물리므로, 이 단계에서는 새 데이터 기본값만 `template`로 옮기고 예전 값도 계속 받아주는 점진 호환이 가장 안전하다고 판단했다.
- 아이템 라이브러리의 `linkedGames`는 실제 의미가 템플릿 연결 정보이므로, 이 응답 키까지 `linkedTemplates`로 바꿔두는 편이 이후 관리자 유지보수에서 훨씬 덜 헷갈린다고 정리했다.
## 2026-04-02 v1.4.28
- 이 시점 이후 코드 검색에 남는 `game`는 대부분 레거시 데이터 마이그레이션, 옛 주소 redirect, 저장 데이터의 `origin` 호환처럼 의도된 층이므로, 무리하게 전부 0으로 만들기보다 기능을 깨뜨리지 않는 선에서 의미 있는 이름층만 더 줄이는 편이 맞다고 판단했다.
- 관리자 화면 내부 상태명(`selectedTemplate.game`, `isGameLoading`, `gameVisibilitySaving`)은 실제 기능 의미와 어긋나므로, QA 전에 한 번 더 `template` 기준으로 옮겨두는 편이 이후 유지보수에 더 유리하다고 정리했다.
## 2026-04-02 v1.4.27
- 공개/관리자 API 표면까지 `topic/template`로 정리된 뒤에는, 관리자 내부 상태 이름과 DB export alias에 남은 `game` 흔적도 계속 유지할 이유가 작아졌으므로 이 단계에서 함께 걷어내는 편이 맞다고 판단했다.
- 다만 외부에서 직접 참조할 수 있는 공개 북마크와 달리, `adminGames`, `game-admin`, `favoriteGame` 같은 이름은 내부 구현 용어라서 이번 단계에서 정리해도 위험이 낮다고 정리했다.
## 2026-04-02 v1.4.26
- `topic/template` 표면을 거의 마감한 시점에서는 관리자 API와 관리자 화면 경로까지 계속 `/games` alias를 유지하는 편보다, 실제 사용하는 `templates` 경로만 남기고 예전 관리자 주소는 redirect로만 정리하는 편이 더 일관되고 안전하다고 판단했다.
- 공개 사용자 북마크는 여전히 `/games -> /topics` redirect가 필요하지만, 백엔드 API의 `/api/games`까지 계속 유지할 이유는 작아졌으므로 이 단계에서 `/api/topics`만 남기는 편이 맞다고 정리했다.
## 2026-04-02 v1.4.25
- 이제 프런트와 백엔드 소비층이 `topic/template`를 기본으로 읽을 준비가 되었으므로, 응답과 payload에 `gameId / gameName` 호환 키를 오래 남기는 것보다 실제 표면을 먼저 정리하는 편이 더 낫다고 판단했다.
- 다만 오래된 외부 링크까지 한 번에 끊는 건 위험하므로, 이번 단계에서는 데이터/응답/프런트 소비는 `topic`으로 마감하되 `/games/:gameId`와 관리자 route alias 같은 레거시 주소만 마지막 호환 레이어로 남기는 점진 종료가 가장 안전하다고 정리했다.
## 2026-04-02 v1.4.24
- `topic/template` 소비층이 이미 정리된 상태라면, 공개 주제 API와 관리자 템플릿 API 응답도 이제는 `game` 키를 기본으로 유지할 이유가 크지 않으므로 새 의미 키만 기본으로 내보내는 편이 맞다고 판단했다.
- 다만 관리자 화면 내부 상태 구조를 한 번에 뒤집는 건 위험하므로, 응답은 줄이되 `selectedTemplate.game`처럼 화면 구조에 깊게 퍼진 부분은 프런트에서 한 번 정규화해 받는 점진 방식이 가장 안전하다고 정리했다.
## 2026-04-02 v1.4.23
- 프런트가 이미 `topic/template` 메서드만 실제로 쓰고 있다면, `api.js` 안에 남은 레거시 `game` 별칭까지 계속 유지하는 건 오히려 정리 상태를 흐리므로 이 단계에서 정리하는 편이 맞다고 판단했다.
- 티어표 저장과 템플릿 요청처럼 핵심 생성 흐름은 백엔드 내부 payload도 먼저 `topicId` 기준으로 맞춰 두는 편이, 이후 응답 호환 키를 걷어낼 때 충격을 더 줄인다고 정리했다.
## 2026-04-02 v1.4.22
- 내부 함수명과 export를 정리한 뒤에도 라우트 파일명이 계속 `games.js`로 남아 있으면 마지막까지 개념 충돌을 남기게 되므로, 공개 주제 라우트 파일명도 실제 의미에 맞게 `topics.js`로 옮기는 편이 맞다고 판단했다.
- `/api/games` 호환 경로는 유지하더라도, 서버 내부 구현만큼은 `topic` 기준 param 이름과 파일 이름으로 정리해 두는 편이 이후 레거시 제거를 훨씬 더 쉽게 만든다고 정리했다.
## 2026-04-02 v1.4.21
- 백엔드에서 `topic/template` 응답을 내보내더라도 프런트가 계속 `game` 키만 읽으면 호환 레이어가 끝나지 않으므로, 이번 단계부터는 실제 사용자 화면과 관리자 저장 흐름도 새 키를 우선 읽게 맞추는 편이 맞다고 판단했다.
- 이 구간은 외부 API를 끊는 작업이 아니라 “프런트가 새 의미를 먼저 받아들이는 단계”이므로, 기존 `game` 키는 fallback으로만 남겨 두고 단계적으로 걷어내는 편이 가장 안전하다고 정리했다.
## 2026-04-02 v1.4.20
- 스키마만 `topic`으로 옮기고 함수명/라우트 내부가 계속 `game`으로 남아 있으면 이후 유지보수에서 계속 의미 충돌이 생기므로, 이번 단계부터는 백엔드 export와 주요 라우트 내부 이름도 `topic/template`를 기본으로 읽히게 정리하는 편이 맞다고 판단했다.
- 다만 외부 API와 프런트 호환을 한 번에 끊는 건 위험하므로, 실제 구현은 새 `topic` 이름을 기본으로 쓰되 기존 `game` 이름은 alias와 호환 응답으로 잠시 유지하는 점진 전환이 가장 안전하다고 정리했다.
## 2026-04-02 v1.4.19
- 템플릿 기본 아이템 삭제가 과거에 저장된 티어표까지 바꿔 버리면 운영자 편집 의도보다 사용자 결과물 보존이 더 크게 흔들리므로, 이 삭제는 “앞으로의 템플릿 구성만 바꾸고 기존 저장본은 보존”하는 편이 맞다고 판단했다.
- 다만 이미 여러 티어표에서 쓰인 아이템인지 모른 채 지우게 두는 것도 위험하므로, 삭제 자체를 막기보다는 저장된 티어표 사용 개수와 공개/비공개 범위를 먼저 보여주고 운영자가 맥락을 알고 결정하게 하는 쪽이 더 현실적이라고 정리했다.
## 2026-04-02 v1.4.18
- 새 창 열기처럼 브라우저 기본 앵커가 충분한 동작은 템플릿 안에서 `window.open`을 직접 부르기보다, 기본 링크 동작에 맡기는 편이 더 단순하고 안전하다고 판단했다.
## 2026-04-02 v1.4.17
- `editor` 주소는 이전과 현재가 같은 URL 형태를 공유하므로, 여기까지 redirect를 두면 호환성이 아니라 자기 자신으로의 재해석만 반복하게 된다. 이 구간은 별도 레거시 레코드를 두지 않고 현재 라우트 하나로 수용하는 편이 맞다고 판단했다.
## 2026-04-02 v1.4.16
- 백엔드/DB 장애 상황을 단순 연결 실패처럼 보여주면 사용자가 원인을 잘못 이해하게 되므로, 네트워크 단절과 서버 점검/초기화 실패를 전역 UI에서 분리해서 안내하는 편이 맞다고 판단했다.
- 이런 장애 안내는 각 화면별 에러 문구를 따로 손보는 것보다 `api` 공통 계층에서 상태를 감지하고 `App` 셸이 한 번에 전환하는 구조가 재사용성과 유지보수 측면에서 더 안전하다고 정리했다.
## 2026-04-02 v1.4.15
- 실제 운영 DB에서 마지막 500 원인을 먼저 재현해본 결과, 스키마 설계보다 MariaDB의 `SHOW ... LIKE ?` 플레이스홀더 비호환과 부분 마이그레이션 상태 재진입 이슈가 핵심이었으므로, 이 단계에선 구조 변경보다 기동 안정성을 먼저 회복하는 편이 맞다고 판단했다.
- 마이그레이션 로직은 “처음 실행”뿐 아니라 “반쯤 적용된 상태에서 다시 실행”도 견뎌야 하므로, 컬럼 존재 확인과 조건 분기를 모두 공용 `information_schema` 검사로 모으는 편이 더 안전하다고 정리했다.
## 2026-04-02 v1.4.14
- 기존 `/games` 주소 호환은 alias보다 redirect가 더 맞다고 판단했다. 이번 단계에선 주소는 유지하되 라우트 파라미터 의미는 항상 `topicId`로 정규화해 Vue Router 경고와 내부 분기를 함께 줄였다.
- 운영 DB에 직접 `RENAME TABLE`과 컬럼 `CHANGE`를 거는 방식은 실제 환경에서 실패 여지가 커서, 마지막 스키마 전환도 새 topic 스키마를 먼저 만들고 기존 game 데이터를 복사하는 비파괴 마이그레이션이 더 안전하다고 정리했다.
## 2026-04-02 v1.4.13
- 사용자 표면과 API 이름층까지 `topic/template`로 옮긴 뒤에는, DB 스키마도 실제로 따라오게 해야 이후 유지보수 비용이 덜 쌓이므로 `games` 계열 실명을 `topics` 계열로 마이그레이션하는 편이 맞다고 판단했다.
- 다만 한 번에 응답 키까지 완전히 끊으면 프런트와 관리자 흐름이 너무 크게 흔들릴 수 있으므로, 이번 단계에서는 실제 저장 스키마는 `topic`으로 옮기고 응답의 `gameId / gameName`은 호환 키로 잠시 함께 유지하는 점진 마감이 가장 안전하다고 정리했다.
## 2026-04-02 v1.4.12
- 프런트 이름만 바꾸는 단계가 끝난 뒤에는, 백엔드도 새 `/api/topics`, `/api/admin/templates` 경로를 열고 기존 `/games`는 호환용으로 남기는 점진 전환이 가장 안전하다고 판단했다.
## 2026-04-02 v1.4.11
- 백엔드 `/api/games` 경로를 바로 바꾸기보다, 프런트 API 객체에서 먼저 `topic/template` 의미 이름을 제공하고 호출부를 옮기는 편이 위험이 훨씬 낮다고 판단했다.
## 2026-04-02 v1.4.10
- 사용자 주소는 이미 `/topics`로 옮기기 시작했으므로, 라우트 이름과 기본 파라미터도 `topicHub / topicId` 기준으로 맞추고 기존 `gameId`는 호환 fallback으로만 남기는 편이 더 자연스럽다고 판단했다.
## 2026-04-02 v1.4.9
- 경로 전환은 화면마다 문자열을 직접 고치는 방식보다, 공용 경로 헬퍼를 먼저 세워 주제·에디터·로그인 리다이렉트 흐름을 한 기준으로 묶는 편이 이후 리네이밍 비용을 훨씬 줄인다고 판단했다.
## 2026-04-02 v1.4.8
- 주제 상세 화면 제목은 내부 ID를 잠깐 보여주는 것보다, 로딩 문구를 거쳐 실제 이름으로 자연스럽게 바뀌는 편이 사용자 체감상 더 안정적이라고 판단했다.
- 주요 목록 화면은 `pageHead` 문법을 계속 통일해 두는 편이, 이후 검색/필터 툴바를 더 붙이더라도 구조를 예측하기 쉽다고 판단했다.
## 2026-04-02 v1.4.7
- 주제 상세 컬렉션 화면도 즐겨찾기·나의 티어표와 같은 `pageHead` 문법으로 맞춰야, 네비게이션으로 이동하는 주요 화면들의 리듬이 더 자연스럽다고 판단했다.
- 라우트 전환은 한 번에 `/games`를 없애기보다, 먼저 `/topics`를 기본 진입 경로로 세우고 기존 `/games`는 alias로 유지하는 점진 전환이 더 안전하다고 정리했다.
## 2026-04-02 v1.4.6
- 내부 리네이밍 2단계는 관리자 화면처럼 상태와 액션이 많은 영역부터 정리해 두는 편이, 이후 `/games` 라우트와 API 계층을 손볼 때 위험을 줄이는 데 더 유리하다고 판단했다.
## 2026-04-02 v1.4.5
- 내부 리네이밍은 한 번에 API와 DB까지 건드리기보다, 홈·주제 화면·에디터처럼 영향 범위가 비교적 명확한 프런트 핵심 흐름부터 `game` 의존 이름을 줄여 나가는 편이 더 안전하다고 판단했다.
## 2026-04-02 v1.4.4
- 용어 정리 마무리 단계에서는 눈에 잘 띄는 영어 헤더를 그대로 두기보다, 홈과 관리자처럼 진입 빈도가 높은 화면의 상단 라벨까지 한국어로 맞춰야 전체 제품 인상이 더 자연스럽다고 판단했다.
## 2026-04-02 v1.4.3
- 용어 전환은 메뉴 타이틀만 바꾸는 것으로 끝나지 않고, 관리자 작업 중 반복해서 보게 되는 토스트와 확인창까지 맞춰야 실제 체감 일관성이 살아난다고 판단했다.
## 2026-04-02 v1.4.2
- 용어 정리를 시작한 뒤에는 일부 화면만 바꾸는 것보다, 관리자 모달과 확인 메시지처럼 실제 운영 중 많이 보는 문구도 함께 맞춰 주는 편이 체감 일관성이 더 높다고 판단했다.
## 2026-04-02 v1.4.1
- 좌측 메뉴와 화면 타이틀의 명칭이 서로 다르면 사용자가 현재 위치를 직관적으로 매칭하기 어렵기 때문에, 메뉴 이름과 진입 타이틀을 같은 문구로 맞추는 편이 맞다고 판단했다.
## 2026-04-02 v1.4.0
- 서비스가 게임 외 주제 전반을 다룰 수 있는 단계에 온 만큼, 내부 모델명은 유지하더라도 사용자에게 보이는 주요 용어는 `주제 / 템플릿` 기준으로 먼저 정리하는 편이 맞다고 판단했다.
- 대규모 내부 리네이밍은 API와 DB까지 손대야 하므로, 이번 단계에서는 사용자 화면 문구만 우선 바꾸고 내부 `game` 모델은 그대로 두는 점진적 전환이 더 안전하다고 정리했다.
## 2026-04-02 v1.3.93
- 목록 카드 썸네일은 드래그 대상이 아니라 클릭 대상에 가깝기 때문에, 브라우저 기본 이미지 드래그 프리뷰는 전부 막아 두는 편이 UX 측면에서 맞다고 판단했다.
## 2026-04-02 v1.3.92
- 왼쪽 레일 활성 메뉴도 로그인 토글과 같은 이동형 배경 문법을 쓰는 편이 앱 전체 인터랙션 언어를 더 일관되게 만든다고 판단했다.
## 2026-04-02 v1.3.91
- 로그인/회원가입 탭은 즉시 배경 교체보다, 선택 배경이 실제로 이동하는 토글 문법이 더 직관적이고 상태 전환이 잘 읽힌다고 판단했다.
## 2026-04-02 v1.3.90
- 경고 수준의 CSS 진단이라도 실제 의미 없는 속성이나 벤더 전용 속성 누락이라면 바로 정리해 두는 편이 이후 유지보수 피로를 줄인다고 판단했다.
## 2026-04-02 v1.3.89
- 더 이상 참조되지 않는 Vite 기본 자산과 레거시 public 아이콘 묶음은 남겨둘수록 혼동만 커지므로, 실제 사용 파일만 남기고 정리하는 편이 맞다고 판단했다.
- 공유용 썸네일은 코드 수정과 별개로 시각 자산 손질이 자주 일어날 수 있으므로, 이번처럼 워크트리에 이미 반영된 최신 이미지 수정본은 함께 릴리스에 포함하는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.88
- 헤더의 `by zenn`은 이미 공통 카피라이트 링크가 생긴 뒤 역할이 겹치므로, 브랜드 영역은 서비스명 중심으로 정리하는 편이 맞다고 판단했다.
- 외부 공유 미리보기는 메타 태그만 넣는 것보다 실제 전용 썸네일 자산을 함께 두는 편이 메신저/소셜/모바일 홈 화면까지 더 안정적으로 동작한다고 정리했다.
- 파비콘은 인라인 data URL 하나에 의존하기보다 `svg + png + apple-touch-icon` 조합으로 두는 편이 브라우저와 기기 호환성 측면에서 안전하다고 판단했다.
## 2026-04-02 v1.3.86
- 아이콘 크기는 이미지 다운로드 결과에만 반영되고 저장본에는 남지 않으면 사용자가 체감상 “저장되지 않는 설정”으로 느끼게 되므로, 티어표 본문 설정으로 저장하는 편이 맞다고 정리했다.
- 저장 경로를 고친 뒤에도 프리뷰 화면이 기본값으로 보인다면, 데이터보다 프런트 렌더링 루트에 동일 CSS 변수가 전달되는지 먼저 확인하는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.83
- 모바일에서 열 헤더가 칸과 시각적으로 분리되는 문제는 전체 레이아웃을 다시 갈아엎기보다, 각 칸 안에 열 이름 배지를 같이 보여주는 편이 가장 적은 변경으로 효과를 낸다고 정리했다.
- 배지를 쓰는 반응형 구간에서는 기존 상단 열 헤더까지 남겨두면 중복 정보가 되므로, 같은 브레이크포인트에서 헤더는 숨기고 칸 배지 하나만 남기는 편이 맞다고 정리했다.
- 반응형 보정은 한 미디어 구간 안에서 서로 다른 규칙이 다시 덮어쓰지 않게 정리해야 하므로, 모바일용 `1fr` 레이아웃을 선언한 뒤 예전 `140px/150px` 규칙은 제거하는 편이 맞다고 판단했다.
## 2026-04-02 v1.3.82
- 프리뷰 완성본도 결국 공유/열람용 결과물이므로, 이미지 다운로드 결과와 같은 작성자/저장 시각 메타를 같이 보여주는 편이 자연스럽다고 정리했다.
- 관리자 템플릿 요청 카드는 “요청 티어표 보기”가 실제로 새창 이동용이라면 하단 버튼과 썸네일 클릭을 둘 다 유지하기보다, 썸네일 클릭 하나로 통합하는 편이 더 단순하고 직관적이라고 판단했다.
## 2026-04-02 v1.3.81
- 저장된 티어표 공유는 별도 새 페이지를 만들기보다, 이미 완성본 열람에 쓰고 있는 `preview=1` 주소를 그대로 공유 링크로 재사용하는 편이 가장 단순하고 일관적이라고 정리했다.
- 공유 액션은 저장/삭제처럼 저장본 전제의 보조 기능이므로, 메인 저장 버튼 영역보다 하단 유틸리티 링크 영역에 두는 편이 더 자연스럽다고 판단했다.
## 2026-04-02 v1.3.79
- 카피라이트처럼 앱 전체 브랜딩 성격의 footer는 관리자 텔레포트 안에 두기보다, `App.vue`의 공통 오른쪽 레일 footer로 두는 편이 위치도 안정적이고 화면 간 일관성도 높다고 정리했다.
## 2026-04-02 v1.3.78
- 축소 상태에서는 텍스트가 사라지므로 같은 `티어표 만들기` 계열 액션이라도 커스텀 제작과 템플릿 기반 제작을 아이콘으로 구분해 주는 편이 맞다고 정리했다.
- 관리자 우측 카피라이트처럼 “사이드바 하단”에 붙어야 하는 정보는 텔레포트 루트의 형제 노드로 두기보다, 실제 사이드바 컨테이너 내부의 마지막 행으로 두는 편이 레이아웃상 안전하다고 판단했다.
## 2026-04-02 v1.3.77
- 왼쪽 레일을 접었을 때 하단 액션을 완전히 숨기면 `새 티어표 만들기` 진입점이 사라지므로, 펼친 상태의 하단 위치는 유지하되 축소 상태에서는 같은 위치의 아이콘 전용 버튼으로 바꿔 남겨두는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.76
- 왼쪽 사이드 레일을 접었을 때는 텍스트가 사라진 뒤에도 행 높이가 제각각이면 아이콘 전용 탐색기로 읽히지 않으므로, 아바타/검색/내비 항목의 높이를 같은 규격으로 통일하는 편이 맞다고 정리했다.
- 왼쪽 레일 검색은 화면에 따라 티어표 검색으로 바뀌면 사용자가 사이드 검색과 메인 검색 역할을 구분하기 어려우므로, 사이드는 게임 검색으로 고정하고 티어표 검색은 메인 화면 문맥에 맡기는 편이 더 자연스럽다고 판단했다.
## 2026-04-02 v1.3.75
- 관리자 공용 모달은 기본 카드 여백을 계속 쓰되, 내부에 자체 셸을 가진 대형 상세 모달까지 같은 패딩을 강제로 받으면 오히려 레이아웃이 무너지므로 예외 클래스로 분리하는 편이 맞다고 정리했다.
- 관리자 표기 링크는 텍스트만 두기보다, 추후 주소 변경이 쉬운 한 곳짜리 상수와 새 창 링크로 관리하는 편이 운영 측면에서 더 낫다고 판단했다.
- 왼쪽 사이드 레일 접힘 상태는 요소를 좁히는 것만으로는 높이와 정렬 문제가 계속 남으므로, 메타 텍스트는 실제로 숨기고 아이콘 중심 문법으로 따로 정리하는 편이 맞다고 판단했다.
## 2026-04-02 v1.3.74
- 관리자 공용 게임 선택 모달은 단순 검색만 제공하기보다, 현재 문맥에서 이미 선택 불가능한 대상을 `이미 추가됨`으로 명시하고 막아 주는 편이 운영 실수를 줄이는 데 더 효과적이라고 정리했다.
- 프로젝트 표기는 관리자 헤더 상단보다 사이드바 최하단의 작은 카피라이트 문구로 빼는 편이 정보 밀도를 덜 방해한다고 판단했다.
## 2026-04-02 v1.3.73
- 게임 선택이 여러 관리자 화면에 퍼지기 시작한 시점에서는 일부 화면만 셀렉트나 내부 리스트를 유지하기보다, 공용 검색 모달 하나로 통일하는 편이 장기적으로 더 일관되고 확장에 강하다고 정리했다.
- 검색 입력과 실행 버튼은 세로로 같은 문법으로 쌓기보다, 입력은 입력끼리 실행은 액션으로 읽히게 한 줄 배치로 적당히 구분해주는 편이 운영 화면에서 덜 답답하다고 판단했다.
## 2026-04-02 v1.3.72
- 라우트 복원용 watcher가 composable 반환값 초기화보다 먼저 돌 수 있는 구간에서는 직접 함수를 즉시 호출하기보다, 초기화 완료 뒤 실행되도록 한 템포 미루는 편이 안전하다고 정리했다.
## 2026-04-02 v1.3.71
- 관리자에서 게임 선택 지점이 늘어나는 구조라면 각 화면마다 셀렉트/긴 리스트를 따로 두기보다, 공용 검색 모달 하나로 통일하는 편이 이후 100개 이상 게임이 쌓여도 더 안정적이라고 정리했다.
- 아이템 모달은 참조 정보 정리 뒤에도 왼쪽 선택 요약 카드가 여전히 과하다고 판단해, 예전처럼 게임 선택 자체에 더 집중한 구조로 한 단계 더 되돌리는 편이 맞다고 판단했다.
## 2026-04-02 v1.3.70
- 관리자 티어표 목록은 페이지 단위 열람만으로는 운영 개입이 부족하므로, 게임별 필터와 카드 단위 관리 액션을 함께 붙여 실제 검수 도구로 쓰는 편이 맞다고 정리했다.
- 공개 여부는 문장 속 메타보다 배지로 따로 떼어 보여주는 편이 다른 관리자 카드들과 문법이 맞고, 공개/비공개 전환도 같은 관리 모달 안에서 바로 처리하는 쪽이 운영 흐름상 자연스럽다고 판단했다.
## 2026-04-02 v1.3.69
- 관리자 아이템 라이브러리의 참조 수는 저장 구조 설명에는 도움이 되지만 실제 운영에서는 대부분 의미가 약하므로, 카드와 모달에서 계속 전면에 두기보다 다시 숨기고 필요한 경우 내부 데이터로만 남기는 편이 맞다고 정리했다.
- 관리자 상단 요약 수치는 `활성/대기` 같은 상태 문구보다 실제 운영 판단에 바로 쓰이는 `공개/비공개 개수`가 더 중요하므로, 게임 관리와 티어표 관리 모두 공개 상태 집계를 먼저 보여주는 편이 낫다고 판단했다.
- 공개/비공개 수치는 현재 페이지 일부를 세면 오해가 생기기 쉬우므로, 검색/게임 기준 전체 결과를 집계하는 별도 관리자 API로 계산하는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.68
- 관리자 아이템 상세는 새 모달을 겹쳐 올리는 방식보다 기존 모달 안에서 `왼쪽 선택 대상 / 오른쪽 작업과 참조 정보` 역할만 분명히 나누는 편이 더 안정적이라고 정리했다.
- 같은 이미지를 두 위치에서 반복 노출하면 “모달이 두 개 겹친 것처럼” 느껴질 수 있으므로, 선택 썸네일은 한 곳에만 두고 양쪽 패널은 각자 스크롤되는 구조로 정리하는 편이 맞다고 판단했다.
## 2026-04-02 v1.3.67
- 같은 이미지 공유 구조는 저장 효율에는 유리하지만 운영자가 관계를 읽기 어렵기 때문에, 카드 단계에서는 참조 수를 바로 보여주고 상세 모달에서는 같은 `src`를 가리키는 기록들을 함께 펼쳐 보여주는 편이 맞다고 정리했다.
- 삭제 제한을 과하게 두기보다, 삭제 전 영향 범위를 문구와 개수로 먼저 보여주는 쪽이 운영 측면에서 더 현실적이라고 판단했다.
## 2026-04-02 v1.3.66
- 누락 참조 정리 도구는 커스텀 아이템 누락이 없어도 티어표/요청 썸네일 누락을 항상 따로 정리해야 하므로, 썸네일 정리를 커스텀 아이템 분기에 묶어두면 안 된다고 정리했다.
## 2026-04-02 v1.3.65
- 누락 파일 수치가 계속 쌓이는 상태에서는 원인 분석만으로는 운영 부담이 줄지 않으므로, 실제 파일이 없는 참조만 골라 썸네일/아바타/게임 아이템/커스텀 아이템 참조를 정리하는 관리자 액션을 제공하는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.64
- 관리자 이미지 최적화 기록은 “재사용” 자체보다 이번 업로드와 재사용 자산의 관계가 더 중요하므로, 용량 숫자와 설명을 함께 보여주는 편이 운영자가 오해하지 않기 쉽다고 정리했다.
- 관리자 아이템 라이브러리는 `사용자 업로드 미사용만` 같은 단일 체크박스보다 출처와 사용 상태를 함께 고를 수 있는 필터 모드가 더 실용적이므로, 사용자·템플릿·보관 자산·미사용 상태를 분리해 보는 구조가 맞다고 판단했다.
- 게임 목록이 커질수록 선택 게임 설정을 사이드바 하단에 두는 구조는 스크롤 부담이 커지므로, 공개 상태와 썸네일 관리 액션은 선택된 게임 본문 상단 카드로 올리는 편이 더 안정적이라고 정리했다.
## 2026-04-02 v1.3.63
- 이미지 최적화 기록은 내부 라우트 카테고리를 그대로 보여주면 운영자가 실제 의미를 해석해야 하므로, 관리자 화면에는 기능 기준의 한국어 라벨과 재사용 여부를 함께 보여주는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.62
- 커스텀 이미지가 많은 상태에서 저장할 때 사용자 체감 순서가 흔들리는 것은 업로드 성공보다 더 직접적인 UX 문제이므로, 내부 객체 키 순서가 아니라 현재 화면 배치 순서를 저장 기준으로 삼는 편이 맞다고 정리했다.
- 템플릿 요청이 저장본에서만 가능하다면 삭제도 같은 기준을 따르는 편이 흐름상 자연스러우므로, 저장되지 않은 초안에는 삭제 액션을 노출하지 않는 쪽으로 판단했다.
## 2026-04-02 v1.3.61
- 업로드 드롭존은 기능만 같고 생김새가 다르면 운영자와 사용자 모두 맥락 전환 비용이 생기므로, 관리자와 에디터에서 같은 아이콘·점선 보더·버튼 문법으로 읽히게 맞추는 편이 낫다고 정리했다.
- 썸네일 교체 영역은 일반 입력 필드처럼 보이면 클릭 가능성이 떨어지므로, 이미지 미리보기 위에서도 업로드 박스라는 인상이 유지되게 밝은 배경과 아이콘을 함께 쓰는 쪽으로 판단했다.
## 2026-04-02 v1.3.60
- 관리자 접근 차단은 유지하되, 이미 로그인된 관리자가 새로고침할 때 홈으로 튕기는 체감은 권한 제어보다 더 큰 문제이므로 인증 결과가 나올 때까지 같은 세션 확인 요청을 기다리는 편이 맞다고 정리했다.
- 관리자 썸네일 드롭존과 에디터 보드 드롭존은 기능은 같아도 현재 상태가 문구와 형태로 바로 드러나야 하므로, 빈 상태와 교체 상태를 텍스트로 구분하고 점선 박스 형태를 더 적극적으로 드러내는 쪽으로 판단했다.
## 2026-04-02 v1.3.59
- 템플릿 요청 아이템 반영은 관리자 주의에만 맡기기보다, 서버에서 같은 게임 안의 동일 `src` 중복 생성을 막고 프런트에서도 이미 반영된 요청 아이템을 다시 드래프트에 올리지 않는 편이 운영 안정성에 더 낫다고 정리했다.
- 신규 템플릿 요청은 “완전한 임시 게임” 구조로 바로 바꾸기보다, 우선 한 번 만든 게임을 요청과 연결해 다시 `확인하기`를 눌러도 같은 게임을 재사용하게 만드는 편이 리스크 대비 효과가 크다고 판단했다.

View File

@@ -2,18 +2,18 @@
## `/`
- 화면 파일: `frontend/src/views/HomeView.vue`
- 역할: 데스크톱 기본 4열 게임 카드 라이브러리 대시보드, 상단 메인 썸네일과 `게임명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
- 연동 API: `GET /api/games`
- 역할: 데스크톱 기본 4열 주제 카드 라이브러리 대시보드, 상단 메인 썸네일과 `주제명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 주제 카드 클릭 이동, `직접 티어표 만들기` 진입
- 연동 API: `GET /api/topics`
## `/games/:gameId`
## `/topics/:topicId`
- 화면 파일: `frontend/src/views/GameHubView.vue`
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
- 역할: 선택한 주제 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
- 화면 파일: `frontend/src/views/TierEditorView.vue`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청
- 연동 API: `GET /api/games/:gameId`, `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`
- 연동 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`
## `/login`
- 화면 파일: `frontend/src/views/LoginView.vue`
@@ -37,8 +37,8 @@
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/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/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, 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/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`
@@ -48,7 +48,7 @@
## 공통 레이아웃
- 앱 셸 파일: `frontend/src/App.vue`
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
## 백엔드 진입점
- 서버 엔트리: `backend/index.js`
@@ -56,6 +56,6 @@
- 로컬 DB 실행 설정: `docker-compose.yml`
- 로컬 MariaDB 가이드: `docs/local-mariadb.md`
- 인증 라우트: `backend/src/routes/auth.js`
- 게임 라우트: `backend/src/routes/games.js`
- 주제 라우트: `backend/src/routes/topics.js`
- 티어표 라우트: `backend/src/routes/tierlists.js`
- 관리자 라우트: `backend/src/routes/admin.js`

View File

@@ -114,12 +114,12 @@
- `GET /api/auth/me`
- `GET /api/auth/meta`
- `POST /api/auth/profile`
- 게임
- `GET /api/games`
- `GET /api/games/:gameId`
- 주제
- `GET /api/topics`
- `GET /api/topics/:topicId`
- 티어표
- `GET /api/tierlists/public`
- `gameId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
- `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
- `GET /api/tierlists/me`
- `GET /api/tierlists/favorites/me`
- `GET /api/tierlists/:id`
@@ -131,17 +131,18 @@
- `POST /api/tierlists/custom-items`
- `POST /api/tierlists`
- 관리자
- `POST /api/admin/games`
- `POST /api/admin/games/:gameId/thumbnail`
- `POST /api/admin/games/:gameId/images`
- `POST /api/admin/templates`
- `POST /api/admin/templates/:templateId/thumbnail`
- `POST /api/admin/templates/:templateId/images`
- 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
- `PATCH /api/admin/games/:gameId/items/:itemId`
- `PATCH /api/admin/templates/:templateId/items/:itemId`
- `GET /api/admin/tierlists`
- `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-game-template`
- `POST /api/admin/tierlists/:tierListId/create-template`
- `GET /api/admin/custom-items`
- `POST /api/admin/custom-items/:itemId/promote`
- `DELETE /api/admin/custom-items/:itemId`
@@ -150,8 +151,8 @@
- `PATCH /api/admin/users/:userId`
- `PATCH /api/admin/users/:userId/password`
- `DELETE /api/admin/users/:userId`
- `DELETE /api/admin/games/:gameId/items/:itemId`
- `DELETE /api/admin/games/:gameId`
- `DELETE /api/admin/templates/:templateId/items/:itemId`
- `DELETE /api/admin/templates/:templateId`
## 관리자 화면 메모
- 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다.
@@ -196,7 +197,7 @@
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
- 티어표 편집 화면의 우측 패널은 공통 `rightRail``localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 고정 사이트 타이틀 `Tier Maker by zenn`을 표시한다.
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 사이트 타이틀 `Tier Maker`와 현재 서비스 설명을 표시한다.
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.

View File

@@ -1,5 +1,101 @@
# 할 일 및 이슈
## 단기 확인
- `v1.4.31`에서 `/games` redirect와 legacy DB 마이그레이션까지 제거했으므로, 실제 QA에서는 오직 현재 주소(`/topics`, `/admin/templates`)와 새 DB 기준 흐름만 집중적으로 확인하면 된다.
- 현재 `backend/src`, `frontend/src` 기준 `game` 검색은 0건이므로, 이후 남는 확인 작업은 기능 QA와 운영 환경 배포 점검 쪽에만 집중한다.
- `v1.4.30`에서 빈 로컬 MariaDB 재초기화 검증까지 통과했으므로, 다음 실제 QA에서는 “기존 데이터가 있는 환경”에서 `ensureData()`가 저장 티어표와 템플릿 요청 스냅샷의 legacy origin을 정상 정규화하는지만 추가 확인하면 된다.
- 개발 환경 기준으로는 새 DB에서 `legacyTierItems=0`, `legacyRequestItems=0`가 확인됐으므로, 이후에는 `origin: 'game'` 호환 코드를 언제 완전히 제거할지 운영 데이터 기준으로만 판단하면 된다.
- `v1.4.29`에서 새 티어표 데이터 기본 origin을 `template`로 바꿨으므로, 저장 후 다시 열기/복사/요청 생성/관리자 가져오기 흐름에서 예전 데이터와 새 데이터가 함께 섞여도 정상 동작하는지 한 번 더 확인한다.
- 관리자 아이템 라이브러리 응답 키가 `linkedTemplates`로 정리됐으므로, 사용자 업로드 이미지 삭제 차단과 템플릿 이동 모달이 그대로 정상 동작하는지 확인한다.
- 현재 남아 있는 `game`는 레거시 redirect, DB 마이그레이션, 호환용 origin만 남겨둔 상태이므로, `v1.4` QA 후에는 이 레거시 층을 언제 제거할지 별도 마감 판단만 하면 된다.
- `v1.4.28`에서 관리자 템플릿 상세 상태와 기본 아이템 정렬 상태 이름을 `template` 기준으로 더 정리했으므로, 관리자 템플릿 선택/공개 전환/기본 아이템 정렬 저장이 그대로 정상인지 한 번 더 확인한다.
- 새 템플릿 썸네일/기본 아이템 업로드는 이제 `topics` 디렉터리로 저장되므로, 실제 업로드 후 최적화 작업 분류와 관리자 최근 작업 표시가 자연스럽게 보이는지 확인한다.
- 현재 코드 검색에 남는 `game`는 레거시 redirect, DB 마이그레이션, `origin: 'game'` 호환이 중심이므로, 이 층까지 실제로 없앨지 여부는 `v1.4` QA 후 안정성 기준으로 다시 판단한다.
- `v1.4.27`에서 관리자 내부 탭/라우트 이름과 DB alias export까지 더 정리했으므로, 관리자 템플릿 탭 이동, 커스텀 아이템에서 템플릿 관리로 점프, 템플릿 요청 확인하기 이동이 모두 정상인지 한 번 더 확인한다.
- `v1.4.26`에서 관리자 기본 경로를 `/admin/templates`로 바꾸고 `/api/admin/templates`만 남겼으므로, 관리자 진입/새로고침/뒤로가기와 템플릿 생성·썸네일 업로드·아이템 추가가 모두 정상인지 확인한다.
- `v1.4.26`에서 공개 API `/api/games`를 제거했으므로, 실제 서버 재시작 후 홈/주제 상세/티어표 편집기에서 `/api/topics`만으로 모두 정상 동작하는지 확인한다.
- 오래된 관리자 주소 `/admin/games`는 redirect만 남겼으므로, 북마크로 직접 진입해도 `/admin/templates`로 자연스럽게 바뀌는지 본다.
- `v1.4.25`에서 티어표/요청 응답의 `gameId / gameName` 호환 키를 실제로 제거했으므로, 브라우저에서 홈 목록, 주제 상세, 저장된 티어표 열기, 즐겨찾기, 검색 결과, 관리자 템플릿 요청/전체 티어표 관리가 모두 정상 동작하는지 한 번 더 QA한다.
- `v1.4.25`에서 관리자 route query와 편집기 저장/request payload를 `topicId` 기준으로 옮겼으므로, `/admin/templates?topicId=...`, `/admin/tierlists?mode=all&topicId=...`, 티어표 저장, 템플릿 요청, 추가 아이템 가져오기 흐름이 모두 정상인지 확인한다.
- 남은 `gameId`는 의도적으로 유지한 레거시 주소 alias(`/games/:gameId`)와 관리자 alias route path뿐이므로, 오래된 외부 링크 진입 후 주소가 새 `topic` 체계로 자연스럽게 정규화되는지만 마지막으로 본다.
- `v1.4.24`에서 공개 주제 API와 관리자 템플릿 API의 기본 응답 키를 더 줄였으므로, 실제 브라우저에서 홈 목록, 즐겨찾기 토글, 주제 상세, 티어표 편집기, 관리자 템플릿 공개 전환/생성이 모두 그대로 정상인지 한 번 더 QA한다.
- 다음 단계에서는 `mapTierListRow`, `mapTemplateRequestRow`, 관리자 route query, 저장 payload 입력 호환에 남아 있는 `gameId/gameName/sourceGameId/targetGameId`를 끝까지 걷어낼지 최종 결정한다.
- `v1.4.23`에서 프런트 `api.js`의 레거시 `game` 별칭 메서드와 티어표 저장/요청 내부 payload를 더 걷어냈으므로, 실제 브라우저에서 저장/복사/템플릿 요청/관리자 요청 카드 표시가 그대로 정상인지 한 번 더 QA한다.
- 다음 단계에서는 응답의 `game`, `gameId`, `gameName`, `sourceGameId`, `targetGameId` 호환 키를 실제로 제거할지, 아니면 `v1.4` 마감 후 안정화 기간을 두고 걷어낼지 최종 결정한다.
- `v1.4.22`에서 공개 주제 라우트 파일을 `topics.js`로 옮겼으므로, 실제 서버 재기동 후 `/api/topics``/api/games` 호환 경로가 모두 정상 응답하는지 한 번 더 QA한다.
- 다음 단계에서는 응답의 `game`, `gameId`, `gameName` 호환 키를 실제로 어느 범위까지 제거할지, 그리고 관리자/티어표 저장 payload에서 남은 `gameId` 입력 호환을 어디까지 유지할지 최종 결정한다.
- `v1.4.21`에서 홈/주제 상세/에디터/나의 티어표/즐겨찾기/검색 결과/관리자 템플릿 생성이 `topic/template` 응답 키를 우선 읽도록 바뀌었으므로, 실제 브라우저에서 즐겨찾기 토글과 에디터 이동, 관리자 신규 템플릿 생성이 모두 정상인지 한 번 더 QA한다.
- 다음 단계에서는 실제 응답의 `game`, `gameId`, `gameName` 호환 키를 어디까지 남길지, 그리고 `/api/games` 호환 경로와 `games.js` 파일명을 언제 걷어낼지 최종 범위를 정한다.
- `v1.4.20`에서 백엔드 `db` export와 공개/관리자 라우트 내부 이름을 `topic/template` 기준으로 정리했으므로, 실제 브라우저와 관리자 화면에서 주제 목록/즐겨찾기/템플릿 생성/요청 반영 흐름이 모두 정상인지 한 번 더 QA한다.
- 다음 단계에서는 남아 있는 호환 응답 키 `game`, `gameId`, `gameName`과 레거시 route 파일명 `games.js`를 어디까지 실제 `topic` 이름으로 마감할지 범위를 결정한다.
- `v1.4.19`에서 템플릿 기본 아이템 삭제는 기존 저장 티어표를 보존하도록 정책이 바뀌었으므로, 실제 운영 데이터에서 삭제 후 예전 티어표의 배치/대기풀이 그대로 유지되는지와 새 티어표 생성 시에만 아이템이 빠지는지 한 번 더 QA한다.
- `v1.4.19`에서 삭제 전 영향 개수 경고를 붙였으므로, 공개/비공개 티어표가 섞인 템플릿에서 숫자가 기대대로 보이는지와 삭제 취소/확정 후 스크롤 위치가 안정적으로 유지되는지 한 번 더 QA한다.
- `v1.4.19`에서 템플릿 썸네일 등록 아이콘은 썸네일이 있을 때 숨기도록 정리했으므로, 썸네일 있음/없음 상태 전환과 드래그 오버 활성 상태에서 안내 문구가 겹치지 않는지 한 번 더 QA한다.
- `v1.4.18`에서 관리자 템플릿 요청 카드 썸네일 클릭을 브라우저 기본 새 창 열기로 정리했으므로, 요청 썸네일 클릭 시 오류 없이 새 탭이 열리고 `전체 티어표 관리` 썸네일 모달 동작과도 섞이지 않는지 한 번 더 QA한다.
- `v1.4.17`에서 주제 컬렉션 카드 클릭 시 에디터 진입 무한 루프를 끊었으므로, 새 티어표 만들기/기존 티어표 열기/공유 링크 열기 세 흐름이 모두 정상 진입하는지 한 번 더 QA한다.
- `v1.4.16`에서 장애 전용 안내 화면을 붙였으므로, 실제로 `db_init_failed`와 네트워크 차단 상황에서 각각 `서비스 점검 중`, `서버 연결 확인 중` 화면이 기대대로 분기되는지 한 번 더 QA한다.
- `v1.4.15`에서 `ensureData()`가 실제 운영 DB 설정으로 `ok`까지 통과한 것은 확인했으므로, 이제는 브라우저에서 `/api/auth/me`, `/api/auth/meta`, `/api/topics` 500이 실제로 사라졌는지와 기존 세션 로그인 흐름이 복구됐는지 한 번 더 QA한다.
- `v1.4.14`부터는 DB 마이그레이션이 rename 대신 복사 기반으로 바뀌었으므로, 실제 운영 DB에서 서버 재시작 후 `topics` 계열 테이블과 `tierlists.topic_id`, `template_requests.source_topic_id/target_topic_id`가 기대대로 채워지는지 먼저 확인한다.
- 레거시 `/games/...``/editor/:gameId/...`는 redirect로 남겼으므로, 오래된 북마크 진입 후 주소가 `/topics/...`, `/editor/:topicId/...`로 자연스럽게 정규화되는지 한 번 더 QA한다.
- `v1.4.13`부터 DB 실명도 `topics / topic_items / favorite_topics / topic_id` 기준으로 옮겼으므로, 기존 운영 DB에서 서버 재시작 후 자동 마이그레이션이 한 번만 자연스럽게 수행되는지 먼저 확인한다.
- 백엔드 응답은 현재 `topicId / topicName``gameId / gameName`을 함께 내려주고 있으므로, 다음 단계에서는 실제 프런트/관리자에서 더 이상 `gameId` fallback이 필요 없는 지점을 확인해 호환 키 제거 순서를 정한다.
- 티어표 공개 목록, 관리자 전체 티어표 관리, 저장/요청 API는 `topicId`를 우선 받도록 바꿨으므로, 실제 브라우저에서 검색/저장/공유/관리자 필터가 모두 같은 파라미터 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
- `/api/topics`, `/api/admin/templates` alias를 연 뒤 프런트 호출도 새 경로로 옮겼으므로, 실제 브라우저에서 주제 목록/즐겨찾기/주제 상세/관리자 템플릿 관리가 모두 같은 세션으로 자연스럽게 동작하는지 한 번 더 QA한다.
- 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다.
- 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 API 이름층으로 바뀐 뒤에도 저장, 즐겨찾기, 템플릿 생성, 아이템 정렬 흐름이 자연스러운지 한 번 더 QA한다.
- `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다.
- 경로 헬퍼를 사용자 주요 화면에 연결했으므로, 로그인 리다이렉트·공유 프리뷰·복사본 이동·주제 복귀 흐름이 실제 브라우저에서 모두 같은 주소 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
- 다음 단계에서는 `router/index.js``gameHub`, `route.params.gameId`, `api.getGame`처럼 남아 있는 프런트 내부 이름도 어디까지 `topic/template` 의미로 감쌀지 범위를 더 좁힌다.
- 주제 상세 화면 제목은 ID fallback 대신 로딩 문구를 쓰도록 바꿨으므로, 직접 진입·뒤로가기·다른 주제로 연속 이동할 때 헤더가 깜빡이거나 이전 제목을 잠깐 유지하지 않는지 한 번 더 QA한다.
- 검색 결과 화면도 `pageHead` 구조로 맞췄으므로, 주요 목록 화면들 간 상단 여백과 타이포 리듬이 자연스러운지 한 번 더 비교 QA한다.
- 주제 상세 컬렉션 화면은 `pageHead` 공통 레이아웃과 `/topics` 기본 경로로 옮겼으므로, 직접 진입·뒤로가기·검색 후 재진입 시 주소와 헤더 흐름이 자연스러운지 한 번 더 QA한다.
- `/topics/:gameId`를 기본 경로로 세우고 `/games/:gameId`는 alias로 남겼으므로, 다음 단계에서는 에디터/검색/공유 흐름에서 어떤 링크를 새 경로로 더 전환할지 범위를 정한다.
- 내부 리네이밍 2단계로 관리자 `selectedTemplate / templates / loadTemplate / refreshTemplates` 묶음까지 정리했으므로, 다음 단계에서는 `/games/:gameId` 라우트와 프런트 API 호출부를 어디까지 `topic/template` 의미로 감쌀지 범위를 먼저 정리한다.
- 화면/문서 층의 용어 정리는 거의 마무리됐으므로, 다음 단계에서는 내부 `gameId / games` 모델을 실제로 옮길지, 아니면 라우트 alias/리다이렉트부터 둘지 점진 전환 순서를 정한다.
- 관리자 화면까지 포함한 사용자 노출 `게임` 문구를 3차까지 정리했으므로, 실제 운영 흐름에서 뜨는 확인창/토스트/선택 모달까지 표현이 자연스러운지 한 번 더 QA한다.
- 왼쪽 사이드 메뉴와 각 화면 타이틀을 한글 기준으로 맞췄으므로, 홈/나의 티어표/즐겨찾기/설정 진입 시 실제 체감이 자연스럽고 중복 표현이 어색하지 않은지 한 번 더 QA한다.
- 사용자 노출 용어는 `주제 / 템플릿` 기준으로 계속 걷어내고 있으므로, 홈/주제 화면/관리자 템플릿 관리에서 어색하게 남은 `게임` 문구가 없는지 한 번 더 QA한다.
- 내부 모델명은 아직 `game`을 유지하므로, 다음 단계에서는 문서와 보조 화면 문구를 더 정리할지, 아니면 내부 리네이밍 계획을 따로 잡을지 결정한다.
- 주제 목록과 티어표 카드 썸네일은 기본 이미지 드래그를 막았으므로, 데스크톱 브라우저에서 클릭/드래그 시 원본 이미지 프리뷰가 더 이상 뜨지 않는지 한 번 더 QA한다.
- 왼쪽 레일 활성 배경은 공용 인디케이터가 이동하는 방식으로 바뀌었으므로, 홈/내 티어표/즐겨찾기/설정 전환과 레일 접힘 상태 양쪽에서 위치 보정이 자연스러운지 한 번 더 QA한다.
- 로그인 화면 상단 토글은 이동형 인디케이터로 바뀌었으므로, 데스크톱과 모바일에서 `로그인 / 회원가입` 전환 애니메이션이 어색하지 않고 포커스/클릭 상태도 자연스러운지 한 번 더 QA한다.
- 관리자 카드 설명 줄임은 `line-clamp` 표준 속성까지 함께 선언했으므로, 실제 브라우저별 표시 차이가 없는지 한 번 더 QA한다.
- 사용하지 않는 기본 자산을 정리했으므로, 배포본에서 누락 참조 없이 파비콘/공유 썸네일/좌측 레일 아이콘이 정상 노출되는지 한 번 더 QA한다.
- 공유 썸네일 `og-card`는 이번에 이미지 수정본까지 함께 반영했으므로, 실제 메신저 미리보기에서 최신 그림이 캐시 갱신 후 정상 노출되는지 한 번 더 QA한다.
- 홈페이지 공유 메타와 새 `og-card.png`는 이번에 처음 붙였으므로, 카카오톡/디스코드/슬랙/모바일 브라우저에서 제목·설명·썸네일이 기대대로 보이는지 한 번 더 QA한다.
- 파비콘은 `svg + 32px png + apple-touch-icon` 조합으로 정리했으므로, 데스크톱 브라우저 탭과 iOS 홈 화면 추가에서 모두 정상 노출되는지 한 번 더 QA한다.
- 티어표 `아이콘 크기`는 이제 저장 데이터로 승격됐으므로, 저장 후 재진입/프리뷰/복사본 생성에서 같은 크기가 유지되는지 한 번 더 QA한다.
- 티어표 편집/프리뷰 모바일 열 배지는 새로 붙였으므로, 실제 좁은 화면에서 칸 상단 배지와 아이템 썸네일이 겹치지 않고 열 구분이 자연스러운지 한 번 더 QA한다.
- 모바일 열 배지는 같은 구간에서 상단 열 제목을 숨기도록 다시 맞췄으므로, 720px 안팎뿐 아니라 980px 이하 전 구간에서 중복 표기 없이 자연스러운지 한 번 더 QA한다.
- 모바일 티어표 편집 레이아웃은 행 라벨 폭을 다시 덮어쓰던 규칙을 걷어냈으므로, 실제 980px 이하 구간에서 행 라벨이 과하게 넓지 않고 칸 폭을 충분히 남기는지 한 번 더 QA한다.
- 프리뷰 완성본 하단 메타는 새로 붙였으므로, 작성자/저장 시각이 공개 열람 화면과 이미지 다운로드 결과 기준에서 모두 자연스럽게 읽히는지 한 번 더 QA한다.
- 관리자 템플릿 요청 카드는 썸네일 클릭이 새창 열기 역할로 바뀌었으므로, 썸네일 클릭과 `확인하기` 액션이 서로 헷갈리지 않는지 한 번 더 QA한다.
- 티어표 만들기 화면의 `공유하기`는 저장된 티어표에서만 노출되므로, 저장 직후/수정 중/복사본/읽기 전용 상태 각각에서 노출 조건과 클립보드 복사가 자연스러운지 한 번 더 QA한다.
- 우측 카피라이트는 이제 공통 오른쪽 레일 footer이므로, 관리자 화면뿐 아니라 홈/프로필 등 오른쪽 사이드가 보이는 화면에서도 같은 최하단 위치에 유지되는지 한 번 더 QA한다.
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 홈과 주제 화면에서 서로 다른 아이콘을 쓰도록 나눴으므로, 실제로 두 문맥이 한눈에 구분되는지 한 번 더 QA한다.
- 왼쪽 레일 축소 상태 최하단의 `티어표 만들기` 아이콘 버튼은 새로 추가했으므로, 홈/주제 화면에서 실제로 같은 위치 감각으로 동작하는지 한 번 더 QA한다.
- 관리자 우측 카피라이트 문구는 사이드바 내부 최하단으로 다시 옮겼으므로, 실제 관리자 화면에서 스크롤/창 크기 변화에도 계속 보이는지 한 번 더 QA한다.
- 왼쪽 레일 축소 상태는 아이콘 줄 높이를 50px 기준으로 통일했으므로, 실제 데스크톱에서 아바타/검색/메뉴 아이콘이 시각적으로 같은 리듬으로 보이는지 한 번 더 QA한다.
- 왼쪽 레일 검색은 이제 항상 주제 템플릿 검색으로 홈으로 이동하므로, 홈이 아닌 화면에서 사이드 검색 후 주제 목록 결과로 자연스럽게 이동하는지 한 번 더 QA한다.
- 앱 왼쪽 사이드 레일은 접힘 상태 레이아웃을 다시 손봤으므로, 데스크톱에서 접기/펼치기 반복 시 아바타 영역 높이, 아이콘 중앙 정렬, 검색 버튼 간격, 네비게이션 히트 영역이 모두 자연스러운지 한 번 더 QA한다.
- 관리자 우측 사이드바 하단 카피라이트 링크는 새 창 외부 링크로 바꿨으므로, 실제 클릭 시 `zenn.town` 연결과 hover 대비가 자연스러운지 한 번 더 QA한다.
- 관리자 아이템 상세 모달은 공통 패딩 예외 처리를 다시 넣었으므로, 대형 상세 모달과 일반 템플릿 선택 모달이 각각 기대한 크기로 보이는지 한 번 더 QA한다.
- 아이템 관리 모달의 공용 템플릿 선택기에서는 이미 연결된 템플릿이 비활성화되므로, 실제 운영 데이터에서 중복 연결 방지와 `이미 추가됨` 표시가 기대대로 읽히는지 한 번 더 QA한다.
- 공용 템플릿 선택 모달을 아이템 관리 모달에도 붙였으므로, 관리자 `템플릿 관리 / 전체 티어표 관리 / 아이템 관리` 세 흐름에서 같은 검색/선택 UX가 자연스럽게 느껴지는지 한 번 더 QA한다.
- 관리자 `/admin/games?gameId=...` 직접 진입과 새로고침에서 선택 템플릿이 정상 복원되고 콘솔 오류가 없는지 한 번 더 QA한다.
- 공용 `템플릿 선택` 검색 모달은 새로 붙였으므로, 템플릿 관리 선택/전체 티어표 관리 필터 양쪽에서 검색어 입력, 선택, 해제, 뒤로가기 흐름을 한 번 더 QA한다.
- 관리자 `전체 티어표 관리`의 템플릿 필터와 관리 모달은 새로 붙였으므로, 실제 운영 데이터에서 필터 전환 후 공개/비공개 집계, 제목 수정, 비공개 전환, 삭제 흐름이 자연스럽게 이어지는지 한 번 더 QA한다.
- 관리자 템플릿 관리 상단의 `전체 / 공개 / 비공개` 티어표 수치와 전체 티어표 관리 상단 집계가 실제 운영 데이터와 맞는지, 공개 전환 직후 숫자가 자연스럽게 갱신되는지 한 번 더 QA한다.
- 관리자 아이템 상세 모달은 왼쪽 선택 카드와 오른쪽 상세 본문으로 다시 정리했으므로, 데스크톱/모바일에서 긴 템플릿 목록과 긴 참조 목록이 각각 자연스럽게 스크롤되는지 한 번 더 QA한다.
- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다.
- 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다.
- 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다.
- 커스텀 이미지가 많은 티어표를 저장할 때 커스텀 이름 정리 목록과 미배치 아이템 영역 순서가 실제로 안정적으로 유지되는지, 10장 이상 업로드한 상태로 한 번 더 QA한다.
- 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다.
- 관리자 게임 설정 카드를 본문 상단으로 옮겼으므로, 긴 게임 목록 상태에서 선택-썸네일 교체-공개 전환-삭제 흐름이 스크롤 없이 자연스러운지 한 번 더 QA한다.
- `누락 참조 정리`는 실제 파일이 없는 참조만 지우도록 넣었으므로, 운영 데이터에서 실행했을 때 누락 수치가 줄고 대표 썸네일/티어표/템플릿 요청 표시가 기대대로 비워지는지 한 번 더 QA한다.
- 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다.
## 중기 개선
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
- 관리자 템플릿 요청은 동일 `src` 중복 생성 방지와 `연결된 게임/이미 반영 n개` 표시까지 붙였으므로, 이후에는 요청별 반영 이력(예: 어떤 아이템을 언제 반영했는지)까지 별도로 남길지 검토한다.
@@ -20,16 +116,10 @@
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
- 관리자 템플릿 요청 카드와 전체 티어표 카드가 같은 문법으로 맞춰졌으므로, 이후에는 라이트모드까지 포함해 두 카드의 썸네일 높이·입력창 밀도·아이템 그리드를 한 번 더 비교 QA한다.
- 템플릿 요청 저장 흐름은 저장된 티어표 기준으로 바뀌었으므로, 이후 실제 데이터로 빈 제목 저장 시 자동 생성 제목·요청 버튼 노출 시점·관리자 요청 미리보기 밀도를 한 번 더 비교 QA한다.

View File

@@ -1,5 +1,272 @@
# 업데이트 로그
## 2026-04-02 v1.4.31
- 서비스가 아직 공개 전이고 예전 링크/예전 DB를 이어갈 필요가 없다는 전제에 맞춰, `/games` redirect와 관리자 `/admin/games` redirect, DB 레거시 마이그레이션 코드, legacy origin 정규화 코드를 실제로 제거했다.
- 티어표 저장/request schema도 이제 `origin: 'template' | 'custom'`만 받도록 정리했고, 관리자 최근 최적화 작업 분류 fallback에 남아 있던 `games` 처리도 걷어냈다.
- seed 데이터 ID까지 `example-topic`, `another-topic` 기준으로 바꿔, 현재 `backend/src``frontend/src` 코드 검색에서 `game` 흔적이 0건인 상태까지 정리했다.
## 2026-04-02 v1.4.30
- `ensureData()` 단계에서 저장된 티어표 `pool_json`과 템플릿 요청 스냅샷(`items_json`, `board_items_json`) 안에 남아 있을 수 있는 `origin: 'game'` 값을 자동으로 `template`로 정리하도록 보강했다.
- 로컬 MariaDB를 비운 뒤 현재 스키마로 다시 올리는 검증도 함께 진행했고, 새 DB 기준으로 `topics=3`, `tierlists=0`, `legacyTierItems=0`, `legacyRequestItems=0` 상태까지 확인했다.
- 즉 현재 개발 환경에서는 새로 생성되거나 다시 초기화한 데이터에 `game` 기반 origin이 남지 않으며, 남은 `game` 코드는 레거시 redirect·DB 마이그레이션 감지·과거 데이터 호환층만 담당하게 됐다.
## 2026-04-02 v1.4.29
- 티어표 저장/request schema는 이제 새 데이터에서 `origin: 'template'`를 기본으로 쓰고, 예전 `origin: 'game'`도 계속 읽을 수 있게 호환 레이어를 남겼다.
- 관리자 아이템 라이브러리의 템플릿 연결 정보도 `linkedTemplates` 기준으로 정리해, 내부 응답/프런트 상태에 남아 있던 `linkedGames` 흔적을 제거했다.
- 현재 `game` 검색에 남는 것은 레거시 주소 redirect, DB 마이그레이션용 legacy 테이블/컬럼명, 과거 저장 데이터 호환용 `origin: 'game'`처럼 의도적으로 남겨둔 층만 남도록 정리했다.
## 2026-04-02 v1.4.28
- 관리자 템플릿 상세 상태(`selectedTemplate.game`)와 관련 응답 키를 `template` 기준으로 정리해, 내부 코드 검색에서 남던 `game` 흔적을 더 줄였다.
- 관리자 기본 아이템 정렬/로딩 상태 이름도 `templateItem*`, `isTemplateLoading`, `templateVisibilitySaving` 기준으로 바꾸고, 새 템플릿 자산 업로드는 `topics` 디렉터리로 저장되게 맞췄다.
- 현재 코드 검색에서 남는 `game`는 주로 레거시 주소 redirect(`/games/:gameId`), DB 마이그레이션용 legacy 테이블/컬럼명, 기존 저장 데이터와 맞춘 `origin: 'game'` 값처럼 의도적으로 남겨둔 호환층만 남도록 정리했다.
## 2026-04-02 v1.4.27
- 관리자 내부 탭/라우트 이름도 `template-admin`, `adminTemplates`, `/admin/templates` 기준으로 더 정리해, 화면 상태값과 라우트 이름에 남아 있던 `game-admin`, `adminGames` 흔적을 줄였다.
- 더 이상 참조되지 않는 DB alias export(`listGames`, `createGame`, `favoriteGame` 등)와 `updateTemplateRequestTargetGame` 별칭도 제거해, 백엔드 모듈 표면에서 남아 있던 레거시 `game` 이름층을 더 걷어냈다.
- 커스텀 아이템 모달 내부 클래스명도 `createTemplateButton` 기준으로 정리해, 관리자 코드 검색에서 남는 `createGame` 흔적을 줄였다.
## 2026-04-02 v1.4.26
- 관리자 API 레거시 `/games` alias를 걷어내고 `POST /api/admin/templates`, `.../templates/:templateId/...`만 남기도록 정리했다. 관리자 템플릿 연결/가져오기 액션도 `link-template`, `create-template` path로 바꿨다.
- 백엔드 공개 주제 라우트도 이제 `/api/topics`만 마운트하고, 오래된 `/api/games` 경로는 제거했다. 관리자 화면 URL 역시 `/admin/games` 대신 `/admin/templates`를 기본 경로로 쓰고, 예전 주소는 redirect만 남겼다.
- 문서의 API/화면 매핑도 현재 구조 기준으로 갱신해, `games` 중심 설명 대신 `topics / templates` 기준으로 읽히게 맞췄다.
## 2026-04-02 v1.4.25
- 티어표와 템플릿 요청 응답에서 `gameId / gameName / sourceGameId / targetGameId` 호환 키를 실제로 제거하고, 프런트 화면도 `topicId / topicName / sourceTopicId / targetTopicId`만 읽도록 정리했다.
- 관리자 전체 티어표 관리와 템플릿 요청 관리, 나의 티어표/즐겨찾기/검색 결과 이동, 티어표 편집기 저장·요청 payload도 `topicId` 기준으로 맞춰, 화면과 요청 바디에서 보이는 `game` 흔적을 더 줄였다.
- 관리자 템플릿 정렬 저장과 템플릿 아이템/요청 반영 API body도 `topicIds / topicId` 기준으로 옮겼고, 남은 `gameId`는 이제 레거시 주소 호환용 `/games/:gameId`와 관리자 alias route path 쪽에만 남도록 정리했다.
## 2026-04-02 v1.4.24
- 공개 주제 API는 이제 `topics` 목록과 `topic` 상세만 기본 응답으로 내려주고, 즐겨찾기 토글도 `topic`만 반환하도록 정리했다. 관리자 템플릿 생성/공개 상태 저장도 `template`만 기본 응답으로 맞췄다.
- 홈, 주제 상세, 티어표 편집기, 관리자 템플릿 관리 화면도 이 변경에 맞춰 `data.topics`, `data.topic`, `data.template`를 직접 읽도록 바꿨다.
- 관리자 내부 상태는 `api.getTopic()` 응답을 받아도 `selectedTemplate.game`에 한 번 정규화하도록 보강해, UI 구조를 크게 흔들지 않으면서 응답 호환 키는 더 줄일 수 있게 정리했다.
## 2026-04-02 v1.4.23
- 프런트 `api.js`에서 더 이상 쓰지 않는 `listGames / getGame / favoriteGame / updateAdminGame* / listPublicTierLists` 같은 레거시 별칭 메서드를 정리해, 공개/관리자 호출부가 실제로 쓰는 `topic/template` API만 남기도록 정리했다.
- 관리자 템플릿 요청 상태와 전체 티어표 관리 카드도 `sourceTopicId / targetTopicId / topicName`을 우선 읽도록 더 당겨, 화면에서 `game` 키를 보는 범위를 줄였다.
- 티어표 저장/템플릿 요청 백엔드는 이제 내부적으로 `sourceTopicId / targetTopicId / topicId`만 넘기도록 정리하고, 기존 `sourceGameId / gameId`는 저장 경로에서 한 단계 더 덜어냈다.
## 2026-04-02 v1.4.22
- 백엔드 공개 주제 라우트 파일을 [topics.js](/Users/bicute/Desktop/zenn.dev/tier-cursor/backend/src/routes/topics.js)로 옮기고, 진입점도 이 이름으로 읽히게 정리했다. 이제 서버 코드에서 `games.js` 파일명이 남아 있던 마지막 큰 표면도 실제 의미에 더 가깝게 맞춰졌다.
- 공개 주제 라우트의 path 파라미터도 `:topicId` 기준으로 읽히게 바꿔, 내부 구현에서 더 이상 `req.params.gameId`를 기본 전제로 보지 않도록 정리했다.
## 2026-04-02 v1.4.21
- 프런트의 실제 소비 지점도 `topic/template` 응답 키를 우선 읽도록 옮겼다. 홈의 즐겨찾기 토글, 주제 상세 헤더, 티어표 편집기 템플릿 로딩, 나의 티어표/즐겨찾기/검색 결과의 에디터 이동이 이제 `topic`, `topicId`, `template`를 먼저 사용한다.
- 관리자 템플릿 공개 상태 저장과 신규 템플릿 생성 흐름도 `data.template`를 우선 읽고, 기존 `data.game`은 fallback으로만 남겨 프런트와 백엔드의 의미 이름이 한 단계 더 가까워지게 맞췄다.
## 2026-04-02 v1.4.20
- 백엔드 `db`와 라우트 내부 이름층을 한 단계 더 `topic` 기준으로 옮겼다. `listTopics / findTopicById / getTopicDetail / createTopic / updateTopicThumbnail / updateTopicVisibility`, `createTopicItem / updateTopicItemLabel / updateTopicItemDisplayOrder / deleteTopicItem / deleteTopic` 같은 이름을 실제 export로 추가하고, 기존 `game` 이름은 호환 alias로만 남겼다.
- 공개 주제 라우트는 이제 `listTopics`, `getTopicDetail`, `favoriteTopic` 기준으로 동작하고, 백엔드 진입점도 `gamesRoutes` 대신 `topicsRoutes`라는 이름으로 읽히도록 정리했다.
- 관리자 라우트 역시 핵심 템플릿 흐름에서 `findTopicById`, `createTopic`, `createTopicItem`, `promoteSnapshotItemsToTemplate`, `createTemplateFromTierList` 같은 의미 이름을 직접 사용하도록 바꿔, 실제 저장 스키마와 코드 언어가 더 가까워지게 맞췄다.
## 2026-04-02 v1.4.19
- 관리자 템플릿 기본 아이템 삭제는 이제 기존에 저장된 티어표의 그룹/대기풀 데이터를 건드리지 않고, 템플릿의 현재 기본 아이템 목록에서만 제거되도록 바꿨다. 그래서 이미 만들어진 티어표는 그대로 유지되고, 이후 새로 만드는 티어표에서만 해당 아이템이 빠진다.
- 삭제 전에는 이 아이템이 이미 저장된 티어표 몇 개에서 사용 중인지(공개/비공개 포함) 확인 문구를 먼저 보여주도록 바꿔, 운영자가 영향 범위를 알고 삭제할 수 있게 했다.
- 템플릿 썸네일이 이미 등록된 상태에서는 등록 아이콘이 겹쳐 보이지 않도록 정리했고, 기본 아이템 삭제 후 템플릿을 다시 불러와도 페이지가 맨 위로 튀지 않게 스크롤 위치를 복원하도록 보강했다.
## 2026-04-02 v1.4.18
- 관리자 템플릿 요청 카드 썸네일 클릭은 `window.open(...)`을 템플릿 이벤트 안에서 직접 호출하던 구조 때문에 브라우저 새 창 열기 시 `Cannot read properties of undefined (reading 'open')`가 날 수 있었고, 이를 제거해 앵커의 기본 새 창 동작만 사용하도록 정리했다.
## 2026-04-02 v1.4.17
- 주제 컬렉션에서 티어표 카드를 클릭할 때 `Maximum call stack size exceeded`가 나던 원인은 `editor` 레거시 redirect가 새 라우트와 동일한 URL 패턴을 다시 자기 자신에게 redirect하던 구조였고, 불필요한 `editor` redirect 레코드를 제거해 무한 라우팅 루프를 끊었다.
## 2026-04-02 v1.4.16
- 백엔드나 DB 장애가 났을 때 일반 화면에서 계속 `연결할 수 없어요` 식으로 보이던 흐름을 정리하고, `api` 공통 요청 계층에서 `db_init_failed` 같은 500과 네트워크 실패를 감지해 앱 전체를 점검/연결 확인 화면으로 전환하도록 바꿨다.
- 이제 데이터베이스 초기화 실패나 서버 내부 500은 `서비스 점검 중`, 네트워크 단절은 `서버 연결 확인 중`으로 구분되어 보이며, 사용자는 일반 페이지 대신 전용 안내 화면과 다시 시도 버튼을 보게 된다.
## 2026-04-02 v1.4.15
- `db_init_failed`의 직접 원인은 MariaDB에서 `SHOW TABLES LIKE ?`, `SHOW COLUMNS ... LIKE ?` 플레이스홀더를 허용하지 않던 부분이었고, 이를 `information_schema` 조회 기반으로 바꿔 실제 운영 DB에서도 `ensureData()`가 정상 통과되게 고쳤다.
- 중간 마이그레이션 상태에서 `template_requests.target_topic_id`가 이미 생긴 DB는 중복 컬럼 추가로 다시 실패할 수 있었으므로, 해당 확인도 `columnExists()` 기준으로 바꿔 부분 적용된 DB까지 안전하게 다시 기동되게 정리했다.
## 2026-04-02 v1.4.14
- `/games/:gameId`, `/editor/:gameId/...` 레거시 주소는 Vue Router alias 대신 redirect로 정리해, `topicId` 기준 라우트와 섞일 때 뜨던 param mismatch 경고를 제거했다.
- 운영 DB에서 바로 `RENAME/CHANGE`를 치던 초기 마이그레이션은 위험도가 높아, `topics / topic_items / favorite_topics / topic_id` 스키마를 안전하게 만들고 기존 `games` 계열 데이터를 복사해 오는 방식으로 바꿨다.
- DB 초기화 실패 시 원인을 바로 확인할 수 있도록 백엔드에서 `db_init_failed` 응답 전에 실제 에러를 서버 로그에 남기도록 보강했다.
## 2026-04-02 v1.4.13
- DB 실명 변경 마지막 단계로 `games / game_items / favorite_games``topics / topic_items / favorite_topics` 기준으로 자동 마이그레이션하도록 정리하고, `tierlists.game_id`, `template_requests.source_game_id/target_game_id`도 각각 `topic_id`, `source_topic_id/target_topic_id`로 옮기게 했다.
- 백엔드 저장/조회 쿼리는 이제 새 topic 스키마를 기준으로 동작하고, 응답에는 `topicId / topicName`을 기본으로 내려주되 기존 프런트가 바로 깨지지 않도록 `gameId / gameName`도 잠시 함께 유지했다.
- 티어표 공개 목록, 관리자 전체 티어표 관리, 티어표 저장/요청 API는 `topicId`를 우선 받도록 정리하고 기존 `gameId`는 호환 입력으로만 남겨, 외부 표면과 실제 저장 스키마가 한 단계 더 가까워지게 맞췄다.
## 2026-04-02 v1.4.12
- 백엔드에 `/api/topics``/api/admin/templates` alias 경로를 추가하고, 주제/템플릿 응답도 `topic/topics`, `template/templates` 키를 함께 내려주도록 정리했다.
- 프런트의 새 의미 이름은 이제 실제로도 `/api/topics`, `/api/admin/templates`를 타도록 연결해, 경로 이름과 호출 이름이 다시 어긋나지 않게 맞췄다.
## 2026-04-02 v1.4.11
- 프런트 API 이름층을 한 단계 더 정리해 `listTopics / getTopic / favoriteTopic`, `updateAdminTemplate*`, `searchPublicTierListsByTopic` 같은 의미 기반 이름을 추가하고 실제 호출부도 이 기준으로 옮겼다.
- 백엔드 경로와 응답 구조는 그대로 유지한 채 프런트에서 읽는 이름만 먼저 바꿔, 다음 단계의 API/모델 리네이밍 부담을 더 줄였다.
## 2026-04-02 v1.4.10
- 주제 상세 라우트 이름을 `topicHub`로, 기본 경로 파라미터를 `topicId`로 바꾸고 기존 `gameId` 주소는 alias로 유지했다.
- 앱 셸, 주제 상세, 티어표 편집기는 이제 내부에서 `topicId`를 우선 읽고, 레거시 주소로 들어온 경우에만 `gameId` fallback을 쓰도록 정리했다.
## 2026-04-02 v1.4.9
- `frontend/src/lib/paths.js`를 추가해 주제 진입, 에디터 이동, 로그인 리다이렉트, 공유 프리뷰 주소 같은 사용자 표면 경로를 공용 함수로 모았다.
- 홈, 주제 상세, 나의 티어표, 즐겨찾기, 검색 결과, 로그인, 설정, 관리자 미리보기, 티어표 편집기까지 이 경로 헬퍼를 쓰도록 바꿔 이후 `topics` 전환을 더 안전하게 이어갈 수 있는 기반을 만들었다.
## 2026-04-02 v1.4.8
- 주제 상세 컬렉션 화면은 제목을 `topicId` fallback으로 먼저 노출하지 않도록 바꾸고, 주제 전환 시에는 로딩 문구를 거쳐 실제 이름으로 자연스럽게 바뀌게 정리했다.
- 검색 결과 화면도 공통 `pageHead` 문법으로 맞춰 주요 목록 화면들의 상단 리듬을 한 번 더 통일했다.
## 2026-04-02 v1.4.7
- 주제 선택 뒤에 들어가는 `Collection` 화면을 공통 `pageHead` 레이아웃으로 다시 맞추고, 검색 입력을 즐겨찾기 화면처럼 상단 우측 툴바로 정리했다.
- `공개 티어표` 보조 설명 줄은 제거해 헤더 밀도를 줄였고, 사용자 진입 경로는 `/topics/:gameId`를 기본으로 전환하면서 기존 `/games/:gameId`는 alias로 유지했다.
## 2026-04-02 v1.4.6
- 관리자 내부 리네이밍 2단계로 `AdminView`와 관련 composable/component의 핵심 상태명을 `selectedTemplate / templates / loadTemplate / refreshTemplates / createTemplate` 기준으로 정리했다.
- 요청 검토, 템플릿 생성 모달, 아이템 추가/정렬, 템플릿 선택 모달 흐름도 같은 기준으로 맞춰, 관리자 화면을 읽을 때 내부 이름과 사용자 노출 용어가 덜 어긋나게 정리했다.
## 2026-04-02 v1.4.5
- 내부 리네이밍 1단계를 시작해 홈, 주제 화면, 티어표 편집기, 앱 셸에서 `games / gameId / gameName` 중심의 로컬 상태명을 `templates / topicId / templateId / templateName` 계열로 먼저 정리했다.
- 경로와 API는 그대로 둔 채 프런트 내부에서 자주 읽는 상태명부터 바꿔, 이후 `/games` 라우트와 관리자 상태를 손볼 때 의미 충돌이 덜 나도록 기반을 만들었다.
## 2026-04-02 v1.4.4
- 홈 화면 `Topic Library`와 일부 영어 헤더를 `주제 선택 / 티어표 / 관리자 작업실 / 티어표 만들기 / 작업 공간`으로 정리해, 화면 타이틀과 상단 레이블까지 한국어 기준으로 거의 통일했다.
## 2026-04-02 v1.4.3
- 관리자 토스트, 확인창, 요청 처리 안내처럼 실제로 자주 보이는 운영 문구까지 `주제 / 템플릿` 기준으로 한 번 더 정리해, 화면 제목뿐 아니라 작업 피드백도 더 일관되게 맞췄다.
## 2026-04-02 v1.4.2
- 관리자 화면과 보조 모달에 남아 있던 사용자 노출 `게임` 문구를 추가로 걷어내고, `템플릿 / 주제` 기준 표현으로 더 통일했다.
## 2026-04-02 v1.4.1
- 왼쪽 사이드 메뉴를 `주제 선택 / 나의 티어표 / 즐겨찾기 / 설정` 한글 문구로 통일하고, 해당 화면 진입 시 헤더 타이틀도 같은 이름 기준으로 맞췄다.
## 2026-04-02 v1.4.0
- 사용자 노출 용어 1차 정리를 시작해 홈/좌측 레일/가이드/주제 화면에서는 `게임` 대신 `주제`, 관리자 핵심 화면에서는 `게임 관리` 대신 `템플릿 관리` 중심 표현으로 바꿨다.
- 내부 데이터 모델과 API의 `gameId`, `/games` 구조는 아직 유지하고, 이번 단계는 화면 문구와 안내 텍스트를 먼저 정리하는 안전한 1차 리네이밍 범위로 제한했다.
## 2026-04-02 v1.3.93
- 게임 목록, 티어표 리스트, 사용자 아바타 버튼 등 목록성 썸네일 이미지에 `draggable=\"false\"`를 적용해 브라우저 기본 이미지 드래그 프리뷰가 뜨지 않도록 정리함.
## 2026-04-02 v1.3.92
- 왼쪽 네비게이션의 활성 메뉴 배경은 개별 항목에 즉시 붙는 방식에서, 공용 인디케이터가 현재 메뉴 위치로 미끄러져 이동하는 토글형 인터랙션으로 정리함.
## 2026-04-02 v1.3.91
- 로그인 화면 상단의 `로그인 / 회원가입` 전환은 선택된 버튼 배경이 즉시 바뀌던 방식에서, 뒤쪽 하이라이트가 토글처럼 좌우로 미끄러져 이동하는 인터랙션으로 정리함.
## 2026-04-02 v1.3.90
- 관리자 화면 CSS 경고를 줄이기 위해 `display: block` 요소에 의미 없던 `vertical-align`을 제거하고, `line-clamp` 표준 속성을 함께 선언해 VS Code 진단을 정리함.
## 2026-04-02 v1.3.89
- 현재 코드에서 참조되지 않던 `frontend/public/icons.svg`, `frontend/src/assets/hero.png`, `frontend/src/assets/vite.svg`, `frontend/src/assets/vue.svg`를 삭제해 템플릿 잔재 자산을 정리함.
- 홈페이지 공유용 `og-card.svg`, `og-card.png`는 이번 워크트리에서 직접 수정된 최신 이미지 상태를 그대로 반영해 함께 정리함.
## 2026-04-02 v1.3.88
- 중앙 워크스페이스 헤더의 `by zenn` 링크는 공통 카피라이트 footer가 이미 역할을 대신하므로 제거하고, 기본 서브타이틀도 서비스 설명 문구로 정리함.
- 홈페이지 공유용 메타를 정리해 `title`, `description`, `canonical`, Open Graph, Twitter 카드 정보를 `tmaker.sori.studio` 기준으로 연결함.
- 외부 공유용 `og-card.svg`와 실제 썸네일 `og-card.png`, 브라우저/모바일용 `favicon-32x32.png`, `apple-touch-icon.png`를 추가해 링크 공유와 파비콘 노출을 함께 보강함.
## 2026-04-02 v1.3.86
- 티어표 편집의 `아이콘 크기`는 이제 임시 화면 상태가 아니라 저장 데이터에 함께 포함되며, 저장 후 다시 열기와 프리뷰 화면에서도 같은 크기로 복원되도록 정리함.
- 이를 위해 티어표 저장 payload, 서버 검증, DB 저장/조회에 `iconSize`를 추가하고 기존 데이터는 기본값 `80`으로 안전하게 보정되게 맞춤.
- 이후 공유 프리뷰 화면이 여전히 80으로 고정되던 문제는 `previewOnly` 레이아웃에서 `--thumb-size` 스타일 바인딩이 빠져 있던 탓이었고, 프리뷰 루트에도 같은 값을 전달해 저장된 크기가 그대로 반영되게 보정함.
## 2026-04-02 v1.3.83
- 티어표 편집/프리뷰 화면에서 열을 여러 개 쓰는 경우, 모바일처럼 좁은 화면에서는 기존 상단 열 헤더만으로 각 칸의 의미를 읽기 어려웠으므로 각 칸 상단에 작은 열 이름 배지를 추가함.
- 이 배지는 모바일 구간에서만 보이고 데스크톱 레이아웃은 그대로 유지되므로, 작은 화면에서는 `메인 / 밸런스 / 서포트` 같은 열 맥락을 스크롤 중에도 잃지 않게 정리함.
- 이후 배지가 칸 기준이 아니라 화면 한쪽에 겹치던 문제를 바로잡기 위해 각 칸을 기준점으로 다시 잡았고, 배지가 보이는 구간에서는 기존 상단 열 제목을 함께 숨겨 중복 표기를 제거함.
- 추가로 같은 미디어 구간 안에서 행/열 모바일 레이아웃을 다시 `140px/150px`로 덮어쓰던 중복 규칙을 제거해, 모바일에서는 행 라벨이 화면 절반을 차지하지 않고 실제로 한 줄 전체 폭 기준 레이아웃으로 정리되게 맞춤.
## 2026-04-02 v1.3.82
- 프리뷰 전용 완성본 화면에도 이미지 다운로드 결과와 같은 하단 메타를 붙여, 작성자 이름과 마지막 저장 시각을 바로 확인할 수 있게 정리함.
- 관리자 `티어표 관리 > 템플릿 요청 관리`에서는 더 이상 썸네일 클릭으로 요청 미리보기 모달을 열지 않고, 썸네일 자체가 `요청 티어표 보기` 새창 링크 역할을 하도록 바꿨으며, 하단의 중복 `요청 티어표 보기` 버튼은 제거함.
## 2026-04-02 v1.3.81
- 티어표 만들기 화면에는 저장된 티어표에서만 보이는 `공유하기` 액션을 추가하고, 누르면 현재 티어표의 완성본 링크(`preview=1`)를 클립보드에 복사한 뒤 토스트로 안내하도록 정리함.
- 공유 링크는 관리자가 새 창에서 보던 완성본 주소와 같은 문법을 사용하므로, 저장된 티어표를 그대로 외부에 전달하거나 다시 열람하는 흐름으로 바로 이어짐.
## 2026-04-02 v1.3.79
- 우측 카피라이트는 관리자 전용 레이아웃에서 분리해 앱 공통 `rightRail` footer로 올렸고, 이제 관리자 페이지뿐 아니라 오른쪽 사이드가 보이는 모든 화면에서 같은 최하단 위치에 표시됨.
- 따라서 관리자 패널 길이나 페이지별 로컬 사이드바 내용과 무관하게, 카피라이트는 항상 오른쪽 레일 전체 기준 바닥에 고정되는 공통 footer 역할로 정리됨.
## 2026-04-02 v1.3.78
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 문맥에 따라 구분되도록 바꿔, 홈의 `커스텀 티어표 만들기``dashboard_customize` 아이콘을 쓰고 게임 허브의 일반 `티어표 만들기``add_notes` 아이콘을 유지하도록 정리함.
- 관리자 우측 카피라이트 문구는 사이드바 바깥 형제로 밀려 보이지 않을 수 있었으므로, 다시 관리자 사이드바 `aside` 내부 최하단으로 옮겨 레이아웃 안에서 안정적으로 보이게 정리함.
## 2026-04-02 v1.3.77
- 왼쪽 사이드 레일을 축소했을 때도 홈과 게임 허브에서 바로 새 티어표를 만들 수 있도록, 최하단 액션 영역에 `add_notes` 아이콘 기반의 축소 전용 `티어표 만들기` 버튼을 추가함.
- 펼친 상태에서는 기존 텍스트 버튼을 그대로 유지하고, 축소 상태에서는 같은 위치에 아이콘 버튼만 남기도록 분기해 하단 액션 위치 감각은 유지하면서도 좁은 레일 폭에 맞게 정리함.
## 2026-04-02 v1.3.76
- 앱 왼쪽 사이드 레일은 축소 상태에서 아바타, 검색 버튼, 네비게이션 아이콘 버튼 높이를 모두 50px 기준으로 맞추고 검색 아래 여백도 정리해, 아이콘만 보이는 상태에서도 각 줄 높이가 제각각처럼 보이지 않게 정리함.
- 왼쪽 사이드 검색은 라우트에 따라 의미가 바뀌지 않도록 `게임 템플릿 검색`으로 고정하고, 축소 검색 모달 역시 같은 플레이스홀더와 같은 동작으로 홈 게임 목록 검색을 수행하도록 통일함.
## 2026-04-02 v1.3.75
- 관리자 공용 모달 카드의 기본 `padding: 20px`는 그대로 두되, 아이템 상세처럼 내부 레이아웃이 이미 큰 셸을 가진 모달은 `modalCard--customItem`에서 다시 덮어쓰지 않도록 분리해 상세 모달 크기와 내부 배치가 무너지지 않게 정리함.
- 관리자 우측 사이드바 최하단의 카피라이트 문구는 이제 별도 상수 URL을 참조하는 외부 링크로 바꿔 새 창에서 열리게 했고, 추후 주소를 바꿔야 할 때 한 곳만 수정하면 되도록 정리함.
- 앱 왼쪽 사이드 레일의 접힘 상태는 메타 텍스트를 단순히 투명하게 남겨두는 대신 실제로 숨기고, 아바타/검색/내비 아이콘을 다시 중앙 정렬해 접었을 때 높이가 비정상적으로 늘어나거나 간격이 남아 보이던 레이아웃을 정리함.
## 2026-04-02 v1.3.74
- 아이템 관리 상세에서 템플릿 추가 대상 게임을 고를 때, 이미 해당 이미지가 연결된 게임은 공용 게임 선택 모달에서 `이미 추가됨`으로 표시하고 비활성화해 중복 추가 실수를 미리 막도록 정리함.
- 관리자 우측 사이드바 최하단에는 작은 카피라이트 문구를 추가해, 헤더에 관리 정보만 남기고 프로젝트 표기는 하단에서 조용히 보이도록 정리함.
## 2026-04-02 v1.3.73
- 전체 티어표 관리 카드 썸네일은 `draggable="false"`로 바꿔, 미리보기 진입 시 브라우저 기본 이미지 드래그가 클릭을 방해하지 않도록 정리함.
- 관리자 사이드바의 검색 입력과 검색 버튼은 한 줄로 묶어, 입력/선택/실행 버튼이 모두 같은 크기의 세로 스택처럼 보이던 답답함을 조금 줄이고 역할 구분을 더 분명하게 함.
- 아이템 관리 상세 모달의 템플릿 추가 대상 선택도 내부 전용 게임 리스트 대신 공용 `게임 선택` 검색 모달을 쓰도록 바꿔, 향후 게임 수가 많아져도 같은 선택 문법으로 이어지게 정리함.
## 2026-04-02 v1.3.72
- 관리자 화면 초기화 중 `/admin/games?gameId=...` 경로를 즉시 처리하는 watcher가 `loadGame` 초기화보다 먼저 실행되어 브라우저 콘솔에 `Cannot access 'loadGame' before initialization` 오류가 나던 문제를 수정함.
- 게임 라우트 진입 시 실제 게임 로딩 호출은 컴포넌트 초기화가 끝난 뒤 microtask로 미뤄 실행하도록 바꿔, 첫 진입/새로고침에서도 게임 선택 복원 흐름이 안전하게 이어지게 정리함.
## 2026-04-02 v1.3.71
- 관리자 아이템 모달은 최근 추가했던 선택 요약 카드를 다시 걷어내고, 더 단순한 `게임 선택 패널 + 상세 작업 영역` 구조로 되돌려 이전 흐름에 가깝게 정리함.
- 관리자 `게임 관리``전체 티어표 관리`의 게임 선택은 긴 셀렉트/목록 대신 공용 `게임 선택` 검색 모달로 바꿔, 게임 수가 많아져도 이름·ID 검색으로 바로 찾아 선택할 수 있게 함.
- 전체 티어표 관리의 게임 필터 해제도 같은 모달 흐름에 맞춰 `모든 게임 보기`로 처리하고, 사이드바에는 현재 선택된 게임만 요약 카드로 보여줘 긴 리스트가 계속 쌓이지 않게 정리함.
## 2026-04-02 v1.3.70
- 관리자 `전체 티어표 관리`는 이제 게임별 필터를 지원해, 특정 게임에서 만들어진 티어표만 따로 골라 보며 공개/비공개 분포를 확인할 수 있게 함.
- 전체 티어표 카드는 공개 여부를 텍스트 대신 다른 관리자 화면과 같은 배지 형식으로 표시하고, 카드의 `관리` 액션에서 제목·설명 수정, 공개/비공개 전환, 삭제를 바로 처리할 수 있게 보강함.
- 이를 위해 관리자 전용 티어표 수정/삭제 API와 게임 기준/검색 기준 공개 집계 로직을 함께 추가해, 관리자 화면에서 비공개 개입과 운영성 검수가 한 흐름으로 이어지게 정리함.
## 2026-04-02 v1.3.69
- 관리자 아이템 라이브러리는 참조 수/공유 기록 UI가 실제 운영 판단에 비해 노이즈가 커 보여 카드 수치, 상세 모달의 같은 이미지 참조 섹션, 삭제 확인 문구의 참조 강조를 걷어내고 다시 항목 자체 관리 흐름 위주로 정리함.
- 관리자 게임 관리 상단 요약은 더 이상 `선택 상태`처럼 추상적인 문구를 보여주지 않고, 선택된 게임 기준으로 만들어진 티어표의 `전체 / 공개 / 비공개` 개수를 바로 보여주도록 바꿈.
- 전체 티어표 관리 상단에도 검색 결과 기준 `전체 / 공개 / 비공개` 수치를 함께 노출하고, 이를 위해 관리자 티어표 집계 API를 별도로 추가해 페이지 단위가 아니라 실제 전체 결과 기준 숫자를 안정적으로 표시함.
## 2026-04-02 v1.3.68
- 관리자 아이템 상세 모달은 같은 이미지를 왼쪽 선택 카드와 오른쪽 본문에서 두 번 보여주던 중복 미리보기를 제거해, 한 모달 안에서 정보가 겹쳐 보이던 문제를 정리함.
- 왼쪽 게임 선택 패널과 오른쪽 상세 정보 패널은 각각 독립 스크롤이 되도록 바꾸고, 스크롤바도 다시 보이게 조정해 긴 목록이나 긴 참조 정보가 있어도 레이아웃이 깨지지 않고 탐색할 수 있게 함.
- 현재 선택한 이미지 요약 카드에는 별도 배경과 테두리를 추가해, 기존 클릭 모달의 “선택 대상”과 오른쪽 작업 영역이 한눈에 구분되도록 시각 계층을 정리함.
## 2026-04-02 v1.3.67
- 관리자 아이템 관리 카드에는 이제 같은 `src`를 공유하는 참조 수와 연결 게임 수를 함께 표시해, 같은 이미지가 얼마나 넓게 쓰이는지 목록 단계에서 바로 파악할 수 있게 함.
- 아이템 상세 모달은 왼쪽 패널 상단에 현재 선택한 이미지와 `총 참조 / 사용자 업로드 / 템플릿 항목 / 보관 자산` 요약을 보여주고, 오른쪽에는 같은 이미지를 가리키는 다른 기록 목록을 함께 표시해 실제로 어떤 참조들이 묶여 있는지 모달 안에서 바로 확인할 수 있게 함.
- 삭제 확인 문구도 이제 단순 타입 설명만 하지 않고 `같은 이미지 참조 n건 중 현재 항목만 다룬다`는 영향을 함께 보여, 삭제 전에 범위를 더 명확히 이해할 수 있게 정리함.
## 2026-04-02 v1.3.66
- `누락 참조 정리`는 처음엔 누락 커스텀 아이템이 있을 때만 티어표/요청 썸네일까지 함께 보던 조건 때문에, 누락 썸네일만 남아 있으면 수치가 줄지 않던 문제가 있었으므로 분기를 풀어 티어표 썸네일과 요청 썸네일도 항상 실제 파일 존재 여부를 확인해 정리하도록 수정함.
- 정리 완료 메시지에도 `티어표 썸네일`, `요청 썸네일` 항목을 추가해 어떤 종류의 누락 참조가 실제로 정리됐는지 바로 알 수 있게 함.
## 2026-04-02 v1.3.65
- 관리자 이미지 최적화 패널에 `누락 참조 정리` 액션을 추가해, 실제 파일이 없는 `/uploads/...` 참조만 대상으로 썸네일/아바타는 비우고 누락된 게임 아이템·커스텀 아이템은 관련 티어표/템플릿 요청 참조와 함께 정리할 수 있게 함.
- 따라서 예전 수동 파일 정리나 레거시 데이터로 인해 쌓인 `누락 파일`은 단순 통계로만 남지 않고, 관리자 화면에서 실제로 줄일 수 있는 관리 도구를 갖게 됨.
## 2026-04-02 v1.3.64
- 관리자 이미지 최적화 최근 작업에서 `기존 최적화 파일 재사용` 건은 이제 `이번 업로드 용량 · 재사용 자산 용량` 형태로 표시하고, 동일한 최적화 결과가 이미 있어 새 파일을 다시 만들지 않았다는 설명을 함께 보여 혼동을 줄임.
- 관리자 아이템 관리는 기존의 `미사용 사용자 업로드만 보기` 체크박스 대신 `전체 / 사용자 업로드 / 템플릿 사용 이미지 / 관리자 보관 자산 / 미사용 사용자 / 미사용 관리자` 필터로 바꿔, 관리자 업로드 자산도 별도로 확인할 수 있게 정리함.
- 게임 관리의 선택된 게임 설정은 더 이상 우측 사이드바 아래쪽에 쌓지 않고, 본문 상단에 썸네일과 공개 상태·썸네일 적용·게임 삭제 액션을 함께 둔 카드로 옮겨 게임 목록이 많아져도 작업 영역을 더 안정적으로 읽을 수 있게 조정함.
## 2026-04-02 v1.3.63
- 관리자 이미지 최적화 최근 작업 목록은 더 이상 내부 카테고리 문자열 `custom / tierlists / games / avatars`를 그대로 노출하지 않고, 각각 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타`처럼 사람이 이해할 수 있는 이름으로 표시함.
- 같은 이미지 해시를 다시 업로드해 기존 최적화 파일을 재사용한 경우에는 최근 작업 목록에 `기존 최적화 파일 재사용` 문구를 함께 보여, 새로 압축된 건지 중복 자산이 재사용된 건지 운영자가 바로 구분할 수 있게 함.
## 2026-04-02 v1.3.62
- 티어표 저장과 템플릿 요청 전 커스텀 이미지 업로드에서는 더 이상 `itemsById` 객체 키 순서에 기대지 않고, 실제 화면에 보이는 `아이템 영역 + 보드 배치 순서` 기준으로 아이템 배열을 만들도록 바꿔 저장 중 이미지 목록이 흔들리던 현상을 줄임.
- 따라서 커스텀 아이템 이름 정리 목록, 저장 payload, 템플릿 요청 payload 모두 같은 순서 기준을 공유하게 되어, 이미지를 여러 장 올린 뒤 저장해도 사용자가 보고 있던 흐름이 덜 흔들리도록 정리함.
- 티어표 삭제 버튼은 이제 템플릿 요청과 같은 기준으로 `저장된 티어표`에서만 노출되며, 실제 삭제도 저장본 ID가 있을 때만 동작하도록 맞춰 저장 전 초안 상태의 어색한 삭제 액션을 제거함.
## 2026-04-02 v1.3.61
- 관리자 게임 관리의 썸네일 드롭존, 관리자 기본 아이템 추가 드롭존, 티어표 에디터의 커스텀 이미지 드롭존에 `add_photo_alternate` 아이콘을 넣어 업로드 영역임을 더 빠르게 인식할 수 있게 정리함.
- 관리자와 에디터 드롭존은 점선 보더 굵기, 라운드, 밝은 배경 톤, 활성화 상태 색 변화, 파일 선택 버튼 크기를 같은 계열로 맞춰 서로 다른 화면에서도 같은 업로드 컴포넌트처럼 읽히도록 통일함.
- 썸네일 드롭존 역시 배경을 일반 입력 필드보다 더 밝고 넓은 업로드 박스처럼 보이게 조정해, 일반 폼 필드와 대표 이미지 교체 영역을 시각적으로 더 분명하게 구분함.
## 2026-04-02 v1.3.60
- 관리자 게임 관리의 대표 썸네일 드롭존은 이제 썸네일이 없을 때는 `클릭 & 드래그`, 이미 등록된 썸네일이 있을 때는 `썸네일 변경`으로 문구가 바뀌어 현재 동작을 더 바로 읽을 수 있게 함.
- 관리자 인증 상태는 라우터 가드와 앱 셸이 동시에 `/api/auth/me`를 호출할 때, 가드가 아직 끝나지 않은 요청을 기다리지 못해 새로고침 직후 홈으로 튕기던 흐름이 있었으므로 인증 스토어에서 진행 중인 `refresh` Promise를 재사용하도록 정리함.
- 따라서 관리자 계정으로 로그인된 상태에서는 `/admin/...` 경로를 새로고침해도 세션 확인이 끝날 때까지 같은 요청을 기다린 뒤 관리자 화면에 남도록 안정성을 보강함.
- 티어표 만들기 화면의 보드 드롭존은 점선 테두리, 더 높은 박스, 중앙 정렬된 안내 문구와 버튼을 적용해 커스텀 이미지 추가 영역임을 더 즉시 인식할 수 있게 조정함.
## 2026-04-02 v1.3.59
- 관리자 템플릿 요청의 `promote-items` 처리에서는 잘못된 `z.record` 스키마 때문에 500이 나던 서버 파싱 버그를 수정하고, 요청 아이템 `src`까지 함께 받아 실제 요청 데이터와 더 안정적으로 매칭하도록 보강함.
- 요청 아이템을 게임에 반영할 때는 이제 같은 게임 안에 동일한 `src`가 이미 있으면 새 기본 아이템을 다시 만들지 않도록 막고, 관리자 화면에서도 이미 반영된 요청 아이템은 드래프트에 다시 올리지 않게 정리함.

View File

@@ -1,13 +1,42 @@
<!doctype html>
<html lang="en">
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%230b1220'/%3E%3Cpath d='M18 18h28v8H36v20h-8V26H18z' fill='%23f8fafc'/%3E%3C/svg%3E"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tier Maker</title>
<title>Tier Maker | 게임 템플릿으로 만드는 티어표</title>
<meta
name="description"
content="게임 템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요. 관리자 요청과 템플릿 업데이트 흐름까지 한 번에 관리할 수 있습니다."
/>
<meta name="theme-color" content="#090d16" />
<meta name="application-name" content="Tier Maker" />
<link rel="canonical" href="https://tmaker.sori.studio/" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta property="og:site_name" content="Tier Maker" />
<meta property="og:locale" content="ko_KR" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://tmaker.sori.studio/" />
<meta property="og:title" content="Tier Maker | 게임 템플릿으로 만드는 티어표" />
<meta
property="og:description"
content="게임 템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요. 관리자 요청과 템플릿 업데이트 흐름까지 한 번에 관리할 수 있습니다."
/>
<meta property="og:image" content="https://tmaker.sori.studio/og-card.png" />
<meta property="og:image:alt" content="Tier Maker 공유 썸네일" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Tier Maker | 게임 템플릿으로 만드는 티어표" />
<meta
name="twitter:description"
content="게임 템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요."
/>
<meta name="twitter:image" content="https://tmaker.sori.studio/og-card.png" />
</head>
<body>
<div id="app"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

BIN
frontend/public/og-card.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { editorNewPath, favoritesPath, homePath, loginPath, mePath } from './lib/paths'
import { toApiUrl } from './lib/runtime'
import { useToast } from './composables/useToast'
import iconDockToLeft from './assets/icons/dock_to_left.svg'
@@ -9,6 +10,8 @@ import iconDockToRight from './assets/icons/dock_to_right.svg'
import iconGridView from './assets/icons/grid_view.svg'
import iconFavorite from './assets/icons/favorite.svg'
import iconLists from './assets/icons/lists.svg'
import iconAddNotes from './assets/icons/add_notes.svg'
import iconDashboardCustomize from './assets/icons/dashboard_customize.svg'
import iconSearch from './assets/icons/search.svg'
import iconSettings from './assets/icons/settings.svg'
import iconMenuBook from './assets/icons/menu_book.svg'
@@ -19,16 +22,20 @@ const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const { toasts, dismissToast } = useToast()
const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
const currentTopicId = computed(() => route.params.topicId || '')
const leftRailCollapsed = ref(false)
const rightRailOpen = ref(true)
const searchQuery = ref('')
const searchPlaceholder = computed(() => (route.name === 'home' ? '게임 템플릿 검색' : '전체 티어표 검색'))
const leftRailSearchPlaceholder = '주제 템플릿 검색'
const isCollapsedSearchOpen = ref(false)
const isGuideModalOpen = ref(false)
const themeMode = ref('dark')
const guideStepIndex = ref(0)
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
const backendState = ref('online')
const backendMessage = ref('')
provide('rightRailOpen', rightRailOpen)
provide('localRightRailTarget', '#local-right-rail-root')
@@ -57,22 +64,23 @@ const shellStyle = computed(() => ({
}))
const leftNavItems = computed(() => {
const items = [
{ key: 'home', label: 'Games', path: '/', iconSrc: iconGridView },
{ key: 'me', label: 'My Lists', path: '/me', iconSrc: iconLists, requiresAuth: true },
{ key: 'favorites', label: 'Favorites', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
{ key: 'profile', label: 'Settings', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
{ key: 'home', label: '주제 선택', path: '/', iconSrc: iconGridView },
{ key: 'me', label: '나의 티어표', path: '/me', iconSrc: iconLists, requiresAuth: true },
{ key: 'favorites', label: '즐겨찾기', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
{ key: 'profile', label: '설정', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
]
return items.filter((item) => !item.requiresAuth || (authReady.value && auth.user))
})
const activeLeftNavIndex = computed(() => leftNavItems.value.findIndex((item) => isRouteActive(item.path)))
const showRightRailAction = computed(() => false)
const showSettingsGuideButton = computed(() => route.name === 'profile')
const guideSteps = [
{
id: 'select-game',
title: '게임 또는 양식 선택',
summary: '게임 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
id: 'select-topic',
title: '주제 또는 양식 선택',
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
description:
'홈 화면에서는 게임 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 게임을 먼저 고르면 해당 게임의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
'홈 화면에서는 주제 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 원하는 주제를 먼저 고르면 해당 주제의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
},
{
id: 'arrange-board',
@@ -86,7 +94,7 @@ const guideSteps = [
title: '아이템 배치와 커스텀 추가',
summary: '프리셋 아이템과 직접 올린 이미지를 드래그로 배치합니다.',
description:
'오른쪽 아이템 영역의 이미지를 원하는 칸으로 끌어다 놓으면 바로 배치됩니다. 게임 템플릿에 없는 이미지는 커스텀 이미지로 추가해 같이 쓸 수 있고, 이름 표시 옵션을 켜면 결과 이미지를 더 설명적으로 정리할 수 있어요.',
'오른쪽 아이템 영역의 이미지를 원하는 칸으로 끌어다 놓으면 바로 배치됩니다. 주제 템플릿에 없는 이미지는 커스텀 이미지로 추가해 같이 쓸 수 있고, 이름 표시 옵션을 켜면 결과 이미지를 더 설명적으로 정리할 수 있어요.',
},
{
id: 'save-share',
@@ -105,23 +113,23 @@ const guideSteps = [
{
id: 'request-template-update',
title: '템플릿 업그레이드 요청',
summary: '현재 게임 템플릿에 공통 아이템을 추가해 달라고 관리자에게 요청합니다.',
summary: '현재 템플릿에 공통 아이템을 추가해 달라고 관리자에게 요청합니다.',
description:
'직접 추가한 아이템 중 여러 사람이 함께 써도 좋을 것 같은 항목이 있다면 템플릿 업데이트 요청을 보낼 수 있어요. 요청 모달에서는 현재 티어표 제목과 설명을 기본값으로 가져오고, 필요하면 요청 제목과 설명을 더 다듬어 공통 템플릿에 왜 필요한지 설명할 수 있습니다.',
},
{
id: 'request-new-template',
title: '새 템플릿 추가 요청',
summary: '아직 없는 게임이나 새로운 양식을 관리자에게 제안합니다.',
summary: '아직 없는 주제나 새로운 양식을 관리자에게 제안합니다.',
description:
'원하는 게임 템플릿이 아직 없다면 새 템플릿 추가 요청으로 관리자에게 직접 제안할 수 있어요. 이때는 제목과 설명에 어떤 게임인지, 어떤 캐릭터나 항목이 기본으로 필요할지 적어두면 검토 속도가 훨씬 빨라집니다.',
'원하는 주제 템플릿이 아직 없다면 새 템플릿 추가 요청으로 관리자에게 직접 제안할 수 있어요. 이때는 제목과 설명에 어떤 주제인지, 어떤 캐릭터나 항목이 기본으로 필요할지 적어두면 검토 속도가 훨씬 빨라집니다.',
},
{
id: 'manage-library',
title: '즐겨찾기와 내 티어표 관리',
summary: '마음에 드는 템플릿과 저장한 결과물을 나중에 다시 쉽게 찾습니다.',
description:
'게임 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
'주제 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
},
]
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
@@ -130,16 +138,17 @@ const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.le
const isLightTheme = computed(() => themeMode.value === 'light')
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
const showSettingsThemePanel = computed(() => false && route.name === 'profile')
const showGameHubViewToggle = computed(() => route.name === 'gameHub')
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
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 leftBottomPrimaryAction = computed(() => {
if (!authReady.value) return null
if (route.name === 'home' && auth.user) {
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new' }
return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
}
if (route.name === 'gameHub') {
const target = `/editor/${route.params.gameId}/new`
return { label: '새 티어표 만들기', to: auth.user ? target : `/login?redirect=${target}` }
if (route.name === 'topicHub') {
const target = editorNewPath(currentTopicId.value)
return { label: '새 티어표 만들기', to: auth.user ? target : loginPath(target), iconSrc: iconAddNotes }
}
return null
})
@@ -147,96 +156,96 @@ const leftBottomPrimaryAction = computed(() => {
const routeMeta = computed(() => {
if (route.name === 'home') {
return {
title: 'Tier Maker',
subtitle: '게임 템플릿 선택과 커스텀 보드 시작',
title: '주제 선택',
subtitle: '주제 템플릿 선택과 커스텀 보드 시작',
contextTitle: '빠른 시작',
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 게임을 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 주제를 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
action: () => {
router.push(auth.user ? '/editor/freeform/new' : '/login')
router.push(auth.user ? editorNewPath('freeform') : loginPath())
},
}
}
if (route.name === 'gameHub') {
if (route.name === 'topicHub') {
return {
title: 'Game Boards',
subtitle: '게임별 공개 티어표 탐색',
title: '주제 티어표',
subtitle: '주제별 공개 티어표 탐색',
contextTitle: '작성 작업',
contextText: auth.user ? '이 게임의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.',
contextText: auth.user ? '이 주제의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.',
actionLabel: auth.user ? '새 티어표 만들기' : '로그인하러 가기',
action: () => {
const target = `/editor/${route.params.gameId}/new`
router.push(auth.user ? target : `/login?redirect=${target}`)
const target = editorNewPath(currentTopicId.value)
router.push(auth.user ? target : loginPath(target))
},
}
}
if (route.name === 'editEditor' || route.name === 'newEditor') {
return {
title: 'Deck Builder',
title: '티어표 만들기',
subtitle: '티어표 편집 및 공유',
contextTitle: '편집 패널',
contextText: '현재 편집 옵션은 중앙 화면 안에 유지되어 있습니다. 다음 단계에서 우측 패널로 정리해갈게요.',
actionLabel: '게임 목록으로',
action: () => router.push('/'),
actionLabel: '주제 목록으로',
action: () => router.push(homePath()),
}
}
if (isAdminRoute.value) {
return {
title: 'Admin Workspace',
subtitle: '게임·아이템·회원 관리',
title: '관리자 작업실',
subtitle: '템플릿·아이템·회원 관리',
contextTitle: '운영 노트',
contextText: '관리자 화면은 기능이 많아 우선 공통 셸 톤을 맞췄고, 세부 패널은 다음 단계에서 시안 방식으로 더 세밀하게 나눌 예정입니다.',
actionLabel: '게임 목록으로',
action: () => router.push('/'),
actionLabel: '주제 목록으로',
action: () => router.push(homePath()),
}
}
if (route.name === 'me') {
return {
title: 'My Lists',
subtitle: '내가 저장한 티어표',
title: '나의 티어표',
subtitle: '저장한 티어표 모아보기',
contextTitle: '작성 이력',
contextText: '최근 저장한 티어표를 열람하고 정리할 수 있어요.',
actionLabel: '즐겨찾기 보기',
action: () => router.push('/favorites'),
action: () => router.push(favoritesPath()),
}
}
if (route.name === 'favorites') {
return {
title: 'Favorites',
title: '즐겨찾기',
subtitle: '마음에 드는 티어표 모음',
contextTitle: '정리 도구',
contextText: '정렬과 검색으로 원하는 티어표를 빠르게 다시 찾을 수 있어요.',
actionLabel: ' 티어표 보기',
action: () => router.push('/me'),
actionLabel: '나의 티어표 보기',
action: () => router.push(mePath()),
}
}
if (route.name === 'profile') {
return {
title: 'Profile',
title: '설정',
subtitle: '프로필 및 계정 설정',
contextTitle: '계정 관리',
contextText: '아바타와 닉네임을 관리하고 표시 정보를 확인할 수 있어요.',
actionLabel: ' 티어표 보기',
action: () => router.push('/me'),
actionLabel: '나의 티어표 보기',
action: () => router.push(mePath()),
}
}
if (route.name === 'search') {
return {
title: 'Search',
title: '검색',
subtitle: '전체 공개 티어표 검색 결과',
contextTitle: '검색',
contextText: '제목, 작성자 기준으로 공개 티어표를 전체 검색할 수 있어요.',
actionLabel: '홈으로',
action: () => router.push('/'),
action: () => router.push(homePath()),
}
}
return {
title: 'Tier Maker',
subtitle: 'by zenn',
contextTitle: 'Workspace',
subtitle: '주제 템플릿으로 만드는 티어표',
contextTitle: '작업 공간',
contextText: '현재 화면에 맞는 도구와 안내를 여기에 배치할 수 있습니다.',
actionLabel: '홈으로',
action: () => router.push('/'),
action: () => router.push(homePath()),
}
})
@@ -245,6 +254,13 @@ function syncViewportWidth() {
viewportWidth.value = window.innerWidth
}
function handleBackendStatus(event) {
const state = event?.detail?.state
if (!state) return
backendState.value = state
backendMessage.value = typeof event?.detail?.message === 'string' ? event.detail.message : ''
}
function applyTheme(mode) {
themeMode.value = mode === 'light' ? 'light' : 'dark'
if (typeof document !== 'undefined') document.documentElement.dataset.theme = themeMode.value
@@ -264,6 +280,7 @@ onMounted(async () => {
await auth.refresh()
if (typeof window !== 'undefined') {
syncViewportWidth()
window.addEventListener('tier-maker:backend-status', handleBackendStatus)
window.addEventListener('resize', syncViewportWidth)
window.addEventListener('keydown', handleGlobalKeydown)
const leftSaved = window.localStorage.getItem('tier-maker:left-rail-collapsed')
@@ -286,6 +303,7 @@ function handleGlobalKeydown(event) {
onBeforeUnmount(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('tier-maker:backend-status', handleBackendStatus)
window.removeEventListener('resize', syncViewportWidth)
window.removeEventListener('keydown', handleGlobalKeydown)
}
@@ -340,8 +358,8 @@ function toggleRightRail() {
}
}
function setGameHubViewMode(mode) {
if (route.name !== 'gameHub') return
function setTopicViewMode(mode) {
if (route.name !== 'topicHub') return
const nextQuery = { ...route.query }
if (mode === 'list') nextQuery.view = 'list'
else delete nextQuery.view
@@ -391,11 +409,12 @@ function handleLeftRailSearch() {
function submitGlobalSearch() {
const query = (searchQuery.value || '').trim()
isCollapsedSearchOpen.value = false
if (route.name === 'home') {
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
return
}
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search')
router.push(homePath(query))
}
function reloadApp() {
if (typeof window === 'undefined') return
window.location.reload()
}
@@ -412,7 +431,26 @@ function submitGlobalSearch() {
}"
:style="shellStyle"
>
<template v-if="isPreviewMode">
<template v-if="showBackendFallback">
<main class="backendFallback">
<section class="backendFallback__card">
<div class="backendFallback__eyebrow">{{ backendState === 'maintenance' ? 'Maintenance' : 'Connection' }}</div>
<h1 class="backendFallback__title">{{ backendState === 'maintenance' ? '서비스 점검 중' : '서버 연결 확인 중' }}</h1>
<p class="backendFallback__desc">
{{
backendMessage ||
(backendState === 'maintenance'
? '백엔드 또는 데이터베이스 작업으로 인해 잠시 이용이 어렵습니다. 잠시 후 다시 시도해주세요.'
: '네트워크 또는 서버 연결 상태를 확인한 뒤 다시 시도해주세요.')
}}
</p>
<div class="backendFallback__actions">
<button class="backendFallback__button" type="button" @click="reloadApp">다시 시도</button>
</div>
</section>
</main>
</template>
<template v-else-if="isPreviewMode">
<main class="appMain appMain--preview">
<RouterView />
</main>
@@ -429,7 +467,7 @@ function submitGlobalSearch() {
<div class="leftRail__content">
<div v-if="authReady && auth.user" class="appUserCard">
<div class="appUserCard__button">
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
<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 class="appUserCard__meta">
<div class="appUserCard__name">{{ accountName }}</div>
@@ -444,10 +482,15 @@ function submitGlobalSearch() {
<SvgIcon :src="iconSearch" :size="24" />
</span>
</button>
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : searchPlaceholder" />
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
</form>
<nav class="leftNav">
<nav
class="leftNav"
:class="{ 'leftNav--hasActive': activeLeftNavIndex >= 0 }"
:style="{ '--left-nav-active-index': String(Math.max(activeLeftNavIndex, 0)) }"
>
<span class="leftNav__indicator" aria-hidden="true"></span>
<RouterLink
v-for="item in leftNavItems"
:key="item.key"
@@ -468,6 +511,15 @@ function submitGlobalSearch() {
</div>
<div class="leftRail__bottom">
<RouterLink v-if="leftBottomPrimaryAction" :to="leftBottomPrimaryAction.to" class="adminButton">{{ leftBottomPrimaryAction.label }}</RouterLink>
<RouterLink
v-if="leftBottomPrimaryAction"
:to="leftBottomPrimaryAction.to"
class="leftRail__collapsedAction"
:title="leftBottomPrimaryAction.label"
:aria-label="leftBottomPrimaryAction.label"
>
<SvgIcon :src="leftBottomPrimaryAction.iconSrc || iconAddNotes" :size="24" />
</RouterLink>
<button v-if="showSettingsGuideButton" class="adminButton adminButton--icon" type="button" @click="openGuideModal()">
<SvgIcon :src="iconMenuBook" :size="18" class="adminButton__icon" />
<span>가이드 보기</span>
@@ -483,22 +535,13 @@ function submitGlobalSearch() {
<header class="workspaceHead railHeader">
<div class="workspaceHead__brand" @click="$router.push('/')">
<span class="workspaceHead__brandTitle">Tier Maker</span>
<a
class="workspaceHead__brandSub"
href="https://zenn.town/@murabito"
target="_blank"
rel="noreferrer"
@click.stop
>
by zenn
</a>
</div>
<div class="workspaceHead__actions">
<div v-if="showGameHubViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': gameHubViewMode === 'grid' }" type="button" aria-label="그리드 보기" @click="setGameHubViewMode('grid')">
<div v-if="showTopicViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': topicViewMode === 'grid' }" type="button" aria-label="그리드 보기" @click="setTopicViewMode('grid')">
<SvgIcon :src="iconGridView" :size="24" />
</button>
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': gameHubViewMode === 'list' }" type="button" aria-label="리스트 보기" @click="setGameHubViewMode('list')">
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': topicViewMode === 'list' }" type="button" aria-label="리스트 보기" @click="setTopicViewMode('list')">
<SvgIcon :src="iconLists" :size="24" />
</button>
</div>
@@ -513,12 +556,12 @@ function submitGlobalSearch() {
</section>
</main>
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="searchPlaceholder" @click.self="closeCollapsedSearch">
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="leftRailSearchPlaceholder" @click.self="closeCollapsedSearch">
<form class="collapsedSearchBar" @submit.prevent="submitGlobalSearch">
<span class="collapsedSearchBar__icon">
<SvgIcon :src="iconSearch" :size="24" />
</span>
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="searchPlaceholder" autofocus />
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="leftRailSearchPlaceholder" autofocus />
</form>
</div>
@@ -621,6 +664,11 @@ function submitGlobalSearch() {
</button>
</section>
</template>
<div class="rightRail__footer">
<span>Copyright © 2026 </span>
<a :href="RIGHT_RAIL_COPYRIGHT_URL" target="_blank" rel="noreferrer">zenn</a>
<span>. All rights reserved.</span>
</div>
</div>
</div>
</aside>
@@ -648,6 +696,65 @@ function submitGlobalSearch() {
transition: grid-template-columns 220ms ease;
}
.backendFallback {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 32px;
background:
radial-gradient(circle at top, rgba(86, 153, 255, 0.14), transparent 38%),
var(--theme-shell-bg);
}
.backendFallback__card {
width: min(100%, 560px);
display: grid;
gap: 18px;
padding: 28px;
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);
}
.backendFallback__eyebrow {
color: var(--theme-accent-strong);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.backendFallback__title {
margin: 0;
font-size: clamp(28px, 4vw, 42px);
line-height: 1.05;
letter-spacing: -0.04em;
}
.backendFallback__desc {
margin: 0;
color: var(--theme-text-muted);
font-size: 15px;
line-height: 1.7;
}
.backendFallback__actions {
display: flex;
justify-content: flex-start;
}
.backendFallback__button {
min-width: 128px;
padding: 12px 18px;
border-radius: 999px;
border: 1px solid rgba(98, 170, 255, 0.32);
background: rgba(98, 170, 255, 0.18);
color: var(--theme-text-strong);
font-weight: 700;
cursor: pointer;
}
.appShell--preview {
display: block;
}
@@ -741,8 +848,11 @@ function submitGlobalSearch() {
}
.rightRail__content {
flex: 0 0 auto;
flex: 1 1 auto;
min-height: 0;
overflow: visible;
display: flex;
flex-direction: column;
}
.ghostIcon {
@@ -904,19 +1014,45 @@ function submitGlobalSearch() {
}
.leftNav {
--left-nav-gap: 8px;
--left-nav-item-height: 50px;
position: relative;
display: grid;
gap: 8px;
gap: var(--left-nav-gap);
isolation: isolate;
}
.leftNav__indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
height: var(--left-nav-item-height);
border-radius: 14px;
background: var(--theme-surface-soft-3);
transform: translateY(calc(var(--left-nav-active-index, 0) * (var(--left-nav-item-height) + var(--left-nav-gap))));
transition: transform 240ms ease, opacity 200ms ease;
opacity: 0;
z-index: 0;
pointer-events: none;
}
.leftNav--hasActive .leftNav__indicator {
opacity: 1;
}
.leftNav__item {
position: relative;
display: flex;
align-items: center;
min-height: var(--left-nav-item-height);
gap: 12px;
padding: 11px 12px;
border-radius: 14px;
color: var(--theme-text-muted);
text-decoration: none;
transition: background 180ms ease, color 180ms ease, transform 180ms ease;
z-index: 1;
}
.leftNav__label {
@@ -929,7 +1065,6 @@ function submitGlobalSearch() {
.leftNav__item--active,
.leftNav__item.router-link-active {
background: var(--theme-surface-soft-3);
color: var(--theme-text-strong);
}
@@ -953,21 +1088,24 @@ function submitGlobalSearch() {
}
.appShell--leftCollapsed .appUserCard {
margin-bottom: 10px;
min-height: 50px;
margin-bottom: 0;
}
.appShell--leftCollapsed .appUserCard__button,
.appShell--leftCollapsed .appUserCard__guest {
width: 100%;
height: 50px;
min-height: 44px;
padding: 0;
gap: 0;
justify-content: center;
}
.appShell--leftCollapsed .appUserCard__meta,
.appShell--leftCollapsed .leftNav__label,
.appShell--leftCollapsed .searchStub__input {
opacity: 0;
max-width: 0;
transform: translateX(-4px);
pointer-events: none;
display: none;
}
.appShell--leftCollapsed .appUserCard__avatar {
@@ -976,26 +1114,41 @@ function submitGlobalSearch() {
}
.appShell--leftCollapsed .searchStub {
height: 50px;
margin-bottom: 0;
padding: 11px 0;
gap: 0;
justify-content: center;
}
.appShell--leftCollapsed .searchStub__iconButton {
width: auto;
width: 100%;
height: 100%;
}
.appShell--leftCollapsed .leftNav {
gap: 10px;
--left-nav-gap: 10px;
}
.appShell--leftCollapsed .leftNav__item {
width: 100%;
min-height: 50px;
height: 50px;
padding: 11px 0;
gap: 0;
justify-content: center;
}
.appShell--leftCollapsed .leftRail__bottom {
display: none;
.appShell--leftCollapsed .leftNav__glyph {
width: 28px;
height: 28px;
}
.appShell--leftCollapsed .leftRail__content {
display: grid;
align-content: start;
justify-items: stretch;
gap: 10px;
overflow: hidden;
}
@@ -1007,6 +1160,10 @@ function submitGlobalSearch() {
padding-top: 12px;
}
.leftRail__collapsedAction {
display: none;
}
.adminButton {
width: 100%;
display: inline-flex;
@@ -1031,6 +1188,29 @@ function submitGlobalSearch() {
flex: 0 0 auto;
}
.appShell--leftCollapsed .leftRail__bottom {
display: grid;
gap: 10px;
}
.appShell--leftCollapsed .leftRail__bottom .adminButton {
display: none;
}
.appShell--leftCollapsed .leftRail__bottom .leftRail__collapsedAction {
width: 100%;
min-height: 50px;
height: 50px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
color: var(--theme-text);
text-decoration: none;
}
.appMain {
min-width: 0;
min-height: 0;
@@ -1076,18 +1256,6 @@ function submitGlobalSearch() {
color: var(--theme-text-strong);
}
.workspaceHead__brandSub {
font-size: 13px;
font-weight: 700;
color: var(--theme-text-muted);
text-decoration: none;
transition: color 180ms ease, opacity 180ms ease;
}
.workspaceHead__brandSub:hover {
color: var(--theme-text);
}
.workspaceHead__actions {
display: flex;
gap: 10px;
@@ -1145,13 +1313,31 @@ function submitGlobalSearch() {
}
.rightRail__bottom {
display: flex;
align-items: flex-end;
justify-content: flex-end;
margin-top: auto;
display: grid;
gap: 10px;
padding-top: 12px;
}
.rightRail__footer {
padding: 0 4px 2px;
font-size: 9px;
line-height: 1.4;
text-align: center;
color: var(--theme-text-faint);
opacity: 0.72;
}
.rightRail__footer a {
color: #00ffff;
text-decoration: none;
}
.rightRail__footer a:hover {
color: #00ffff;
text-decoration: underline;
}
.settingsThemePanel {
display: grid;
gap: 10px;
@@ -1575,9 +1761,10 @@ function submitGlobalSearch() {
}
.localRightRailRoot {
min-height: auto;
display: grid;
align-content: start;
flex: 1 1 auto;
min-height: 100%;
display: flex;
flex-direction: column;
gap: 14px;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v268q-19-9-39-15.5t-41-9.5v-243H200v560h242q3 22 9.5 42t15.5 38H200Zm0-120v40-560 243-3 280Zm80-40h163q3-21 9.5-41t14.5-39H280v80Zm0-160h244q32-30 71.5-50t84.5-27v-3H280v80Zm0-160h400v-80H280v80ZM720-40q-83 0-141.5-58.5T520-240q0-83 58.5-141.5T720-440q83 0 141.5 58.5T920-240q0 83-58.5 141.5T720-40Zm-20-80h40v-100h100v-40H740v-100h-40v100H600v40h100v100Z"/></svg>

After

Width:  |  Height:  |  Size: 566 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M480-480ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h320v80H200v560h560v-320h80v320q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Zm440-320v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 347 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M120-840h320v320H120v-320Zm80 80v160-160Zm320-80h320v320H520v-320Zm80 80v160-160ZM120-440h320v320H120v-320Zm80 80v160-160Zm440-80h80v120h120v80H720v120h-80v-120H520v-80h120v-120Zm-40-320v160h160v-160H600Zm-400 0v160h160v-160H200Zm0 400v160h160v-160H200Z"/></svg>

After

Width:  |  Height:  |  Size: 377 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M680-80q-50 0-85-35t-35-85q0-6 3-28L282-392q-16 15-37 23.5t-45 8.5q-50 0-85-35t-35-85q0-50 35-85t85-35q24 0 45 8.5t37 23.5l281-164q-2-7-2.5-13.5T560-760q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-24 0-45-8.5T598-672L317-508q2 7 2.5 13.5t.5 14.5q0 8-.5 14.5T317-452l281 164q16-15 37-23.5t45-8.5q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T720-200q0-17-11.5-28.5T680-240q-17 0-28.5 11.5T640-200q0 17 11.5 28.5T680-160ZM200-440q17 0 28.5-11.5T240-480q0-17-11.5-28.5T200-520q-17 0-28.5 11.5T160-480q0 17 11.5 28.5T200-440Zm508.5-291.5Q720-743 720-760t-11.5-28.5Q697-800 680-800t-28.5 11.5Q640-777 640-760t11.5 28.5Q663-720 680-720t28.5-11.5ZM680-200ZM200-480Zm480-280Z"/></svg>

After

Width:  |  Height:  |  Size: 810 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

View File

@@ -1,13 +1,13 @@
<script setup>
const props = defineProps({
featuredGames: { type: Array, required: true },
availableGamesForFeatured: { type: Array, required: true },
featuredGameIds: { type: Array, required: true },
featuredTemplates: { type: Array, required: true },
availableTemplatesForFeatured: { type: Array, required: true },
featuredTemplateIds: { type: Array, required: true },
featuredListRef: { type: Function, required: true },
saveFeaturedOrder: { type: Function, required: true },
moveFeaturedGame: { type: Function, required: true },
removeFeaturedGame: { type: Function, required: true },
addFeaturedGame: { type: Function, required: true },
moveFeaturedTemplate: { type: Function, required: true },
removeFeaturedTemplate: { type: Function, required: true },
addFeaturedTemplate: { type: Function, required: true },
})
</script>
@@ -16,7 +16,7 @@ const props = defineProps({
<div class="sectionHeader">
<div>
<div class="panel__title"> 화면 상단 고정 순서</div>
<div class="hint hint--tight">여기에 넣은 게임 지정한 순서대로 먼저 노출되고, 나머지 게임 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 있어요.</div>
<div class="hint hint--tight">여기에 넣은 템플릿 지정한 순서대로 먼저 노출되고, 나머지 템플릿 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 있어요.</div>
</div>
<button class="btn btn--primary" @click="props.saveFeaturedOrder">순서 저장</button>
</div>
@@ -24,38 +24,38 @@ const props = defineProps({
<div class="featuredOrderPanel">
<div class="featuredOrderPanel__list">
<div class="section__title">상단 고정 목록</div>
<div v-if="!props.featuredGames.length" class="hint">아직 상단 고정 게임 없어요.</div>
<div v-if="!props.featuredTemplates.length" class="hint">아직 상단 고정 템플릿 없어요.</div>
<div v-else :ref="props.featuredListRef" class="featuredList">
<article v-for="(game, index) in props.featuredGames" :key="game.id" class="featuredCard" :data-featured-id="game.id">
<article v-for="(template, index) in props.featuredTemplates" :key="template.id" class="featuredCard" :data-featured-id="template.id">
<div class="featuredCard__meta">
<span class="featuredCard__rank">{{ index + 1 }}</span>
<div>
<div class="featuredCard__title">{{ game.name }}</div>
<div class="featuredCard__id">{{ game.id }}</div>
<div class="featuredCard__title">{{ template.name }}</div>
<div class="featuredCard__id">{{ template.id }}</div>
</div>
</div>
<div class="featuredCard__actions">
<button class="btn btn--ghost btn--small" data-featured-handle>드래그</button>
<button class="btn btn--ghost btn--small" :disabled="index === 0" @click="props.moveFeaturedGame(game.id, -1)">위로</button>
<button class="btn btn--ghost btn--small" :disabled="index === props.featuredGames.length - 1" @click="props.moveFeaturedGame(game.id, 1)">아래로</button>
<button class="btn btn--danger btn--small" @click="props.removeFeaturedGame(game.id)">제외</button>
<button class="btn btn--ghost btn--small" :disabled="index === 0" @click="props.moveFeaturedTemplate(template.id, -1)">위로</button>
<button class="btn btn--ghost btn--small" :disabled="index === props.featuredTemplates.length - 1" @click="props.moveFeaturedTemplate(template.id, 1)">아래로</button>
<button class="btn btn--danger btn--small" @click="props.removeFeaturedTemplate(template.id)">제외</button>
</div>
</article>
</div>
</div>
<div class="featuredOrderPanel__picker">
<div class="section__title">게임 추가</div>
<div class="section__title">템플릿 추가</div>
<div class="featuredPickerList">
<button
v-for="game in props.availableGamesForFeatured"
:key="game.id"
v-for="template in props.availableTemplatesForFeatured"
:key="template.id"
class="featuredPickerItem"
:disabled="props.featuredGameIds.length >= 50"
@click="props.addFeaturedGame(game.id)"
:disabled="props.featuredTemplateIds.length >= 50"
@click="props.addFeaturedTemplate(template.id)"
>
<span>{{ game.name }}</span>
<span class="featuredPickerItem__id">{{ game.id }}</span>
<span>{{ template.name }}</span>
<span class="featuredPickerItem__id">{{ template.id }}</span>
</button>
</div>
</div>

View File

@@ -1,15 +1,31 @@
<script setup>
import { toApiUrl } from '../../lib/runtime'
import SvgIcon from '../SvgIcon.vue'
import addPhotoAlternateIcon from '../../assets/icons/add_photo_alternate.svg'
const props = defineProps({
activeTemplateRequest: { type: Object, default: null },
templateRequestSourceUrl: { type: Function, required: true },
stagedRequestDraftCount: { type: Number, required: true },
appliedRequestItemCount: { type: Number, required: true },
openGameCreateModal: { type: Function, required: true },
isGameLoading: { type: Boolean, required: true },
hasSelectedGame: { type: Boolean, required: true },
selectedGame: { type: Object, default: null },
openTemplateCreateModal: { type: Function, required: true },
isTemplateLoading: { type: Boolean, required: true },
hasSelectedTemplate: { type: Boolean, required: true },
selectedTemplate: { type: Object, default: null },
displayThumbnailUrl: { type: String, default: '' },
canApplyThumbnail: { type: Boolean, required: true },
templateVisibilitySaving: { type: Boolean, required: true },
thumbFileInputRef: { type: Function, required: true },
openThumbFilePicker: { type: Function, required: true },
onThumb: { type: Function, required: true },
onThumbDragEnter: { type: Function, required: true },
onThumbDragOver: { type: Function, required: true },
onThumbDragLeave: { type: Function, required: true },
onThumbDrop: { type: Function, required: true },
isThumbDragOver: { type: Boolean, required: true },
uploadThumbnail: { type: Function, required: true },
removeTemplate: { type: Function, required: true },
toggleSelectedTemplateVisibility: { type: Function, required: true },
itemFileInputRef: { type: Function, required: true },
onFile: { type: Function, required: true },
isItemDragOver: { type: Boolean, required: true },
@@ -23,16 +39,20 @@ const props = defineProps({
canAddItem: { type: Boolean, required: true },
uploadItem: { type: Function, required: true },
removeUploadDraft: { type: Function, required: true },
hasGameItemOrderChanges: { type: Boolean, required: true },
saveGameItemOrder: { type: Function, required: true },
gameItemListRef: { type: Function, required: true },
saveGameItemLabel: { type: Function, required: true },
removeGameItem: { type: Function, required: true },
selectedGameId: { type: String, default: '' },
hasTemplateItemOrderChanges: { type: Boolean, required: true },
saveTemplateItemOrder: { type: Function, required: true },
templateItemListRef: { type: Function, required: true },
saveTemplateItemLabel: { type: Function, required: true },
removeTemplateItem: { type: Function, required: true },
selectedTemplateId: { type: String, default: '' },
})
function setGameItemListElement(el) {
props.gameItemListRef(el)
props.templateItemListRef(el)
}
function setThumbFileElement(el) {
props.thumbFileInputRef(el)
}
</script>
@@ -45,19 +65,19 @@ function setGameItemListElement(el) {
<div class="hint hint--tight">
{{
props.activeTemplateRequest.type === 'create'
? (props.activeTemplateRequest.targetGameId
? (props.activeTemplateRequest.targetTopicId
? '이미 연결된 신규 템플릿이 있어요. 필요한 아이템만 골라 저장하세요.'
: '새 게임을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.')
: '새 템플릿을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.')
: '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.'
}}
</div>
</div>
<div class="requestWorkspace__stats">
<span class="pill pill--accent">{{ props.activeTemplateRequest.type === 'create' ? '신규 게임 요청' : '기존 게임 업데이트' }}</span>
<span class="pill pill--accent">{{ props.activeTemplateRequest.type === 'create' ? '신규 템플릿 요청' : '기존 템플릿 업데이트' }}</span>
<span class="pill">요청 아이템 {{ props.stagedRequestDraftCount }}</span>
<span v-if="props.appliedRequestItemCount" class="pill pill--soft">이미 반영 {{ props.appliedRequestItemCount }}</span>
<span v-if="props.activeTemplateRequest.type === 'create' && (props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId)" class="pill pill--soft">
연결된 게임 · {{ props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId }}
<span v-if="props.activeTemplateRequest.type === 'create' && (props.activeTemplateRequest.targetTopicName || props.activeTemplateRequest.targetTopicId)" class="pill pill--soft">
연결된 템플릿 · {{ props.activeTemplateRequest.targetTopicName || props.activeTemplateRequest.targetTopicId }}
</span>
</div>
</div>
@@ -72,30 +92,60 @@ function setGameItemListElement(el) {
요청 티어표 보기
</a>
<button
v-if="props.activeTemplateRequest.type === 'create' && !props.activeTemplateRequest.targetGameId"
v-if="props.activeTemplateRequest.type === 'create' && !props.activeTemplateRequest.targetTopicId"
class="btn btn--ghost btn--small"
type="button"
@click="props.openGameCreateModal"
@click="props.openTemplateCreateModal"
>
게임 만들기
템플릿 만들기
</button>
</div>
</div>
<div v-if="props.isGameLoading" class="panel panel--empty">
<div v-if="props.isTemplateLoading" class="panel panel--empty">
<div class="emptyState">
<div class="emptyState__title">게임 정보를 불러오는 중이에요.</div>
<div class="emptyState__desc">선택한 게임 썸네일과 기본 아이템을 표시합니다.</div>
<div class="emptyState__title">템플릿 정보를 불러오는 중이에요.</div>
<div class="emptyState__desc">선택한 템플릿 썸네일과 기본 아이템을 표시합니다.</div>
</div>
</div>
<div v-else-if="props.hasSelectedGame" class="panel">
<div class="detailHead">
<div>
<div class="panel__title">선택된 게임 정보</div>
<div class="selectedGame__name">{{ props.selectedGame.game.name }}</div>
<div class="selectedGame__id">{{ props.selectedGame.game.id }}</div>
<div v-else-if="props.hasSelectedTemplate" class="panel">
<section class="adminCard gameSettingsCard">
<div class="gameSettingsCard__media">
<input :ref="setThumbFileElement" type="file" accept="image/*" class="srOnlyInput" @change="props.onThumb" />
<button
class="thumbDropZone"
:class="{ 'thumbDropZone--active': props.isThumbDragOver }"
type="button"
@click="props.openThumbFilePicker"
@dragenter="props.onThumbDragEnter"
@dragover="props.onThumbDragOver"
@dragleave="props.onThumbDragLeave"
@drop="props.onThumbDrop"
>
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.template.name" />
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
<div class="thumbDropZone__copy">
<div v-if="!props.displayThumbnailUrl" class="thumbDropZone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" />
</div>
<div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
</div>
</button>
</div>
</div>
<div class="gameSettingsCard__body">
<div class="panel__title">템플릿 설정</div>
<div class="gameSettingsCard__meta">{{ props.selectedTemplate.template.name }} · {{ props.selectedTemplate.template.id }}</div>
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.templateVisibilitySaving }">
<input :checked="!!props.selectedTemplate.template.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" />
<span class="toggleSwitch__label">{{ props.selectedTemplate.template.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="gameSettingsCard__actions">
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
<button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button>
</div>
</div>
</section>
<div class="section">
<section class="adminCard">
@@ -112,13 +162,16 @@ function setGameItemListElement(el) {
@dragleave="props.onItemDragLeave"
@drop="props.onItemDrop"
>
<div class="dropZone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="dropZone__icon" />
</div>
<div class="dropZone__title">이미지를 드래그해서 기본 아이템으로 추가</div>
<div class="dropZone__desc">
여러 파일을 번에 올릴 있고, 저장 라벨은 파일명으로 자동 생성됩니다.
<span v-if="props.stagedRequestDraftCount"> 현재 요청에서 가져온 아이템 {{ props.stagedRequestDraftCount }}개도 함께 검토 중이에요.</span>
</div>
<div class="dropZone__actions">
<button class="btn btn--ghost btn--small" type="button" @click.stop="props.openItemFilePicker">파일 선택</button>
<button class="btn btn--ghost btn--small dropZone__button" type="button" @click.stop="props.openItemFilePicker">파일 선택</button>
</div>
</div>
</div>
@@ -159,23 +212,23 @@ function setGameItemListElement(el) {
<div class="section__title">현재 기본 아이템 목록</div>
<div class="hint hint--tight">드래그해서 기본 노출 순서를 바꿀 있어요. 저장 전까지는 화면에서만 순서가 바뀝니다.</div>
</div>
<button class="btn btn--primary btn--small" :disabled="!props.hasGameItemOrderChanges" @click="props.saveGameItemOrder">순서 저장</button>
<button class="btn btn--primary btn--small" :disabled="!props.hasTemplateItemOrderChanges" @click="props.saveTemplateItemOrder">순서 저장</button>
</div>
<div v-if="!props.selectedGame?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
<div v-else :ref="setGameItemListElement" class="thumbGrid">
<div v-for="item in props.selectedGame.items" :key="item.id" class="thumbCard" :data-game-item-id="item.id">
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
<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.saveGameItemLabel(item)"
@click="props.saveTemplateItemLabel(item)"
>
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
</button>
<button class="btn btn--danger btn--small" data-no-drag @click="props.removeGameItem(item.id)">아이템 삭제</button>
<button class="btn btn--danger btn--small" data-no-drag @click="props.removeTemplateItem(item.id)">아이템 삭제</button>
</div>
</div>
</div>
@@ -183,9 +236,9 @@ function setGameItemListElement(el) {
</div>
<div v-else class="panel panel--empty">
<div class="emptyState">
<div class="emptyState__title">게임 선택해 주세요.</div>
<div v-if="props.activeTemplateRequest?.type === 'create'" class="hint hint--tight">진행 중인 신규 게임 요청이 있어요. 위의 ` 게임 만들기` 게임 만든 아이템을 추가할 있습니다.</div>
<div v-if="props.selectedGameId" class="hint hint--tight">선택한 게임 찾지 못했거나 로딩 오류가 발생했어요. 다시 선택해보세요.</div>
<div class="emptyState__title">템플릿 선택해 주세요.</div>
<div v-if="props.activeTemplateRequest?.type === 'create'" class="hint hint--tight">진행 중인 신규 템플릿 요청이 있어요. 위의 ` 템플릿 만들기` 템플릿 만든 아이템을 추가할 있습니다.</div>
<div v-if="props.selectedTemplateId" class="hint hint--tight">선택한 템플릿 찾지 못했거나 로딩 오류가 발생했어요. 다시 선택해보세요.</div>
</div>
</div>
</template>

View File

@@ -21,6 +21,8 @@ const props = defineProps({
adminTierListPage: { type: Number, required: true },
adminTierListPageCount: { type: Number, required: true },
adminTierListTotal: { type: Number, required: true },
adminTierListStats: { type: Object, required: true },
openAdminTierListManageModal: { type: Function, required: true },
moveAdminTierListPage: { type: Function, required: true },
})
</script>
@@ -37,26 +39,32 @@ const props = defineProps({
<div v-else class="templateRequestList">
<article v-for="request in props.templateRequests" :key="request.id" class="tierAdminCard templateRequestCard templateRequestCard--aligned">
<div class="templateRequestCard__side">
<button class="tierAdminCard__preview templateRequestCard__preview" type="button" @click="props.openTemplateRequestPreview(request)">
<img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" />
<a
class="tierAdminCard__preview templateRequestCard__preview"
:href="props.templateRequestSourceUrl(request) || undefined"
:target="props.templateRequestSourceUrl(request) ? '_blank' : undefined"
:rel="props.templateRequestSourceUrl(request) ? 'noreferrer' : undefined"
:aria-disabled="!props.templateRequestSourceUrl(request)"
>
<img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" draggable="false" />
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
</button>
</a>
<div class="templateRequestCard__thumbMeta">
<template v-if="request.type === 'create'">
<label class="templateRequestField">
<span class="templateRequestField__label">게임 이름</span>
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
<span class="templateRequestField__label">템플릿 이름</span>
<input v-model="request.draftTopicName" class="input" placeholder="새 템플릿 이름" />
</label>
<label class="templateRequestField">
<span class="templateRequestField__label">게임 ID</span>
<input v-model="request.draftGameId" class="input" placeholder="임시 게임 ID" />
<span class="templateRequestField__label">템플릿 ID</span>
<input v-model="request.draftTopicId" class="input" placeholder="임시 템플릿 ID" />
</label>
</template>
<template v-else>
<div class="templateRequestCard__thumbLabel">게임 이름</div>
<div class="templateRequestCard__thumbValue">{{ request.draftGameName || request.sourceGameName || '-' }}</div>
<div class="templateRequestCard__thumbLabel">게임 ID</div>
<div class="templateRequestCard__thumbValue">{{ request.draftGameId || request.sourceGameId || '-' }}</div>
<div class="templateRequestCard__thumbLabel">템플릿 이름</div>
<div class="templateRequestCard__thumbValue">{{ request.draftTopicName || request.sourceTopicName || '-' }}</div>
<div class="templateRequestCard__thumbLabel">템플릿 ID</div>
<div class="templateRequestCard__thumbValue">{{ request.draftTopicId || request.sourceTopicId || '-' }}</div>
</template>
</div>
</div>
@@ -81,8 +89,8 @@ const props = defineProps({
<div class="tierAdminCard__stats">
<span class="pill">추가 아이템 {{ request.items?.length || 0 }}</span>
<span v-if="request.type === 'create' && (request.targetGameName || request.targetGameId)" class="pill pill--soft">
연결됨 · {{ request.targetGameName || request.targetGameId }}
<span v-if="request.type === 'create' && (request.targetTopicName || request.targetTopicId)" class="pill pill--soft">
연결됨 · {{ request.targetTopicName || request.targetTopicId }}
</span>
<span class="pill" :class="{ 'pill--accent': request.status === 'reviewing' }">{{ props.templateRequestStatusLabel(request) }}</span>
</div>
@@ -95,24 +103,14 @@ const props = defineProps({
</div>
<div class="templateRequestCard__footer">
<div class="templateRequestCard__footerLeft">
<a
v-if="props.templateRequestSourceUrl(request)"
class="btn btn--ghost btn--small"
:href="props.templateRequestSourceUrl(request)"
target="_blank"
rel="noreferrer"
>
요청 티어표 보기
</a>
</div>
<div class="templateRequestCard__footerLeft"></div>
<div class="templateRequestCard__actions">
<button class="btn btn--primary" :disabled="request.isHandling" @click="props.startTemplateRequestReview(request)">
{{
request.isHandling
? '이동중...'
: request.type === 'create' && (request.targetGameName || request.targetGameId)
? '연결된 게임 열기'
: request.type === 'create' && (request.targetTopicName || request.targetTopicId)
? '연결된 템플릿 열기'
: '확인하기'
}}
</button>
@@ -128,6 +126,11 @@ const props = defineProps({
<div class="sectionHeader">
<div>
<div class="panel__title">전체 티어표 관리</div>
<div class="tierAdminHeaderStats">
<span class="pill">전체 {{ props.adminTierListStats.total || 0 }}</span>
<span class="pill pill--soft">공개 {{ props.adminTierListStats.publicCount || 0 }}</span>
<span class="pill pill--soft">비공개 {{ props.adminTierListStats.privateCount || 0 }}</span>
</div>
</div>
</div>
@@ -135,7 +138,7 @@ const props = defineProps({
<div v-else class="tierAdminList">
<article v-for="tierList in props.adminTierLists" :key="tierList.id" class="tierAdminCard">
<button class="tierAdminCard__preview" type="button" @click="props.openAdminTierList(tierList)">
<img v-if="props.tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="props.tierListThumbUrl(tierList)" :alt="tierList.title" />
<img v-if="props.tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="props.tierListThumbUrl(tierList)" :alt="tierList.title" draggable="false" />
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
</button>
@@ -145,13 +148,14 @@ const props = defineProps({
<div class="tierAdminCard__title">{{ tierList.title }}</div>
<div v-if="tierList.description" class="tierAdminCard__desc">{{ tierList.description }}</div>
<div class="tierAdminCard__meta">
{{ tierList.gameName || tierList.gameId }} · {{ props.tierListAuthorDisplayName(tierList) }} · {{ props.tierListVisibilityLabel(tierList) }}
{{ tierList.topicName || tierList.topicId }} · {{ props.tierListAuthorDisplayName(tierList) }}
</div>
<div class="tierAdminCard__meta">{{ props.fmt(tierList.updatedAt) }}</div>
</div>
</div>
<div class="tierAdminCard__stats">
<span class="pill" :class="tierList.isPublic ? 'pill--public' : 'pill--private'">{{ props.tierListVisibilityLabel(tierList) }}</span>
<span class="pill">전체 아이템 {{ tierList.itemCount }}</span>
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}</span>
</div>
@@ -166,11 +170,15 @@ const props = defineProps({
</div>
<div class="tierAdminSection__actions">
<button class="btn btn--ghost btn--small" @click="props.openTierListImportModal(tierList, tierList.extraItems)">추가 아이템 전체 가져오기</button>
<button v-if="tierList.gameId === 'freeform'" class="btn btn--primary btn--small" @click="props.openTierListImportModal(tierList, tierList.extraItems)">
<button v-if="tierList.topicId === 'freeform'" class="btn btn--primary btn--small" @click="props.openTierListImportModal(tierList, tierList.extraItems)">
템플릿으로 가져오기
</button>
</div>
</div>
<div class="tierAdminSection__actions">
<button class="btn btn--ghost btn--small" @click="props.openAdminTierListManageModal(tierList)">관리</button>
</div>
</div>
</article>
</div>

View File

@@ -8,22 +8,20 @@ export function useAdminCustomItems({
customItemLimit,
customItemPageCount,
customItemQuery,
customItemOrphanOnly,
customItemFilter,
customItemModalOpen,
customItemDeleteModalOpen,
customItemModalHistoryActive,
modalTargetCustomItem,
customItemModalDraftLabel,
customItemModalLabelSaving,
customItemModalTargetGameId,
customItemModalGameQuery,
customItemModalGameSort,
games,
selectedGameId,
customItemModalTargetTemplateId,
templates,
selectedTemplateId,
refreshCustomItems,
loadGame,
loadTemplate,
setTab,
selectAdminGame,
selectAdminTemplate,
resetMessages,
success,
error,
@@ -33,7 +31,8 @@ export function useAdminCustomItems({
refreshCustomItems()
}
function toggleCustomItemOrphanOnly() {
function changeCustomItemFilter(filter) {
customItemFilter.value = filter
customItemPage.value = 1
refreshCustomItems()
}
@@ -60,9 +59,7 @@ export function useAdminCustomItems({
function openCustomItemModal(item) {
modalTargetCustomItem.value = item || null
customItemModalDraftLabel.value = item?.label || ''
customItemModalTargetGameId.value = ''
customItemModalGameQuery.value = ''
customItemModalGameSort.value = 'recent'
customItemModalTargetTemplateId.value = ''
customItemModalOpen.value = true
pushCustomItemModalHistoryState()
}
@@ -73,9 +70,7 @@ export function useAdminCustomItems({
modalTargetCustomItem.value = null
customItemModalDraftLabel.value = ''
customItemModalLabelSaving.value = false
customItemModalTargetGameId.value = ''
customItemModalGameQuery.value = ''
customItemModalGameSort.value = 'recent'
customItemModalTargetTemplateId.value = ''
if (fromPopState) {
customItemModalHistoryActive.value = false
@@ -90,7 +85,7 @@ export function useAdminCustomItems({
function openCustomItemDeleteModal(item) {
if (!item) return
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
@@ -102,19 +97,19 @@ export function useAdminCustomItems({
customItemDeleteModalOpen.value = false
}
function jumpToGameAdmin(gameId) {
if (!gameId) return
function jumpToTemplateAdmin(templateId) {
if (!templateId) return
closeCustomItemModal()
setTab('game-admin')
setTab('template-admin')
nextTick(() => {
selectAdminGame(gameId)
selectAdminTemplate(templateId)
})
}
async function removeCustomItem(item = modalTargetCustomItem.value) {
resetMessages()
if (!item) return
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
@@ -165,18 +160,19 @@ export function useAdminCustomItems({
async function promoteCustomItem(item) {
resetMessages()
if (!customItemModalTargetGameId.value) {
error.value = '추가할 게임을 먼저 선택해주세요.'
if (!customItemModalTargetTemplateId.value) {
error.value = '추가할 템플릿을 먼저 선택해주세요.'
return
}
try {
item.isPromoting = true
await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetGameId.value })
const targetGameName = games.value.find((game) => game.id === customItemModalTargetGameId.value)?.name || customItemModalTargetGameId.value
if (selectedGameId.value === customItemModalTargetGameId.value) await loadGame()
await api.promoteAdminTemplateItem(item.id, { topicId: customItemModalTargetTemplateId.value })
const targetTemplateName =
templates.value.find((template) => template.id === customItemModalTargetTemplateId.value)?.name || customItemModalTargetTemplateId.value
if (selectedTemplateId.value === customItemModalTargetTemplateId.value) await loadTemplate()
closeCustomItemModal()
success.value = `"${item.label}" 이미지를 ${targetGameName} 템플릿으로 추가했어요.`
success.value = `"${item.label}" 이미지를 ${targetTemplateName} 템플릿으로 추가했어요.`
} catch (e) {
error.value = '선택한 이미지를 템플릿으로 추가하지 못했어요.'
} finally {
@@ -186,7 +182,7 @@ export function useAdminCustomItems({
return {
submitCustomItemSearch,
toggleCustomItemOrphanOnly,
changeCustomItemFilter,
changeCustomItemLimit,
moveCustomItemPage,
pushCustomItemModalHistoryState,
@@ -194,7 +190,7 @@ export function useAdminCustomItems({
closeCustomItemModal,
openCustomItemDeleteModal,
closeCustomItemDeleteModal,
jumpToGameAdmin,
jumpToTemplateAdmin,
removeCustomItem,
removeUnusedCustomItems,
saveCustomItemModalLabel,

View File

@@ -5,8 +5,8 @@ export function useAdminFeaturedGames({
api,
featuredListEl,
featuredSortable,
featuredGameIds,
games,
featuredTemplateIds,
templates,
resetMessages,
success,
error,
@@ -31,63 +31,63 @@ export function useAdminFeaturedGames({
chosenClass: 'chosen',
onEnd: (evt) => {
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
const nextIds = [...featuredGameIds.value]
const nextIds = [...featuredTemplateIds.value]
const [moved] = nextIds.splice(evt.oldIndex, 1)
nextIds.splice(evt.newIndex, 0, moved)
featuredGameIds.value = nextIds
featuredTemplateIds.value = nextIds
},
})
}
function addFeaturedGame(gameId) {
function addFeaturedTemplate(templateId) {
resetMessages()
if (!gameId || featuredGameIds.value.includes(gameId)) return
if (featuredGameIds.value.length >= 50) {
error.value = '상단 고정 게임은 최대 50개까지만 설정할 수 있어요.'
if (!templateId || featuredTemplateIds.value.includes(templateId)) return
if (featuredTemplateIds.value.length >= 50) {
error.value = '상단 고정 템플릿은 최대 50개까지만 설정할 수 있어요.'
return
}
featuredGameIds.value = [...featuredGameIds.value, gameId]
featuredTemplateIds.value = [...featuredTemplateIds.value, templateId]
syncFeaturedSortable()
}
function removeFeaturedGame(gameId) {
function removeFeaturedTemplate(templateId) {
resetMessages()
featuredGameIds.value = featuredGameIds.value.filter((id) => id !== gameId)
featuredTemplateIds.value = featuredTemplateIds.value.filter((id) => id !== templateId)
syncFeaturedSortable()
}
function moveFeaturedGame(gameId, direction) {
const currentIndex = featuredGameIds.value.indexOf(gameId)
function moveFeaturedTemplate(templateId, direction) {
const currentIndex = featuredTemplateIds.value.indexOf(templateId)
const nextIndex = currentIndex + direction
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredGameIds.value.length) return
const nextIds = [...featuredGameIds.value]
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredTemplateIds.value.length) return
const nextIds = [...featuredTemplateIds.value]
const [moved] = nextIds.splice(currentIndex, 1)
nextIds.splice(nextIndex, 0, moved)
featuredGameIds.value = nextIds
featuredTemplateIds.value = nextIds
syncFeaturedSortable()
}
async function saveFeaturedOrder() {
resetMessages()
try {
const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredGameIds.value })
games.value = data.games || []
featuredGameIds.value = games.value
.filter((game) => game.displayRank != null)
const data = await api.updateAdminTemplateDisplayOrder({ topicIds: featuredTemplateIds.value })
templates.value = data.templates || []
featuredTemplateIds.value = templates.value
.filter((template) => template.displayRank != null)
.sort((a, b) => a.displayRank - b.displayRank)
.map((game) => game.id)
success.value = '홈 화면 게임 순서를 저장했어요.'
.map((template) => template.id)
success.value = '홈 화면 템플릿 순서를 저장했어요.'
} catch (e) {
error.value = '게임 순서 저장에 실패했어요.'
error.value = '템플릿 순서 저장에 실패했어요.'
}
}
return {
destroyFeaturedSortable,
syncFeaturedSortable,
addFeaturedGame,
removeFeaturedGame,
moveFeaturedGame,
addFeaturedTemplate,
removeFeaturedTemplate,
moveFeaturedTemplate,
saveFeaturedOrder,
}
}

View File

@@ -4,28 +4,29 @@ import Sortable from 'sortablejs'
export function useAdminGameManager({
api,
toApiUrl,
selectedGameId,
selectedGame,
selectedTemplateId,
selectedTemplate,
uploadFiles,
uploadItemDrafts,
thumbFile,
itemPreviewUrls,
itemFileInput,
gameItemListEl,
gameItemSortable,
savedGameItemOrderIds,
isGameLoading,
templateItemListEl,
templateItemSortable,
savedTemplateItemOrderIds,
isTemplateLoading,
activeTemplateRequest,
templateRequests,
customItemModalOpen,
customItemModalTargetGameId,
newGameId,
newGameName,
customItemModalTargetTemplateId,
newTemplateId,
newTemplateName,
newTemplateIsPublic,
clearPreviewUrl,
resetFileInput,
resetUploadState,
refreshGames,
closeGameCreateModal,
refreshTemplates,
closeTemplateCreateModal,
resetMessages,
success,
error,
@@ -48,21 +49,21 @@ export function useAdminGameManager({
return src.split('/').pop() || item.file?.name || 'item'
}
function destroyGameItemSortable() {
if (gameItemSortable.value) {
gameItemSortable.value.destroy()
gameItemSortable.value = null
function destroyTemplateItemSortable() {
if (templateItemSortable.value) {
templateItemSortable.value.destroy()
templateItemSortable.value = null
}
}
async function syncGameItemSortable() {
async function syncTemplateItemSortable() {
await nextTick()
destroyGameItemSortable()
if (!gameItemListEl.value || !selectedGame.value?.items?.length) return
destroyTemplateItemSortable()
if (!templateItemListEl.value || !selectedTemplate.value?.items?.length) return
gameItemSortable.value = Sortable.create(gameItemListEl.value, {
templateItemSortable.value = Sortable.create(templateItemListEl.value, {
animation: 160,
draggable: '[data-game-item-id]',
draggable: '[data-template-item-id]',
forceFallback: true,
fallbackOnBody: false,
filter: '[data-no-drag]',
@@ -72,11 +73,11 @@ export function useAdminGameManager({
chosenClass: 'chosen',
onEnd: (evt) => {
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
const nextItems = [...(selectedGame.value?.items || [])]
const nextItems = [...(selectedTemplate.value?.items || [])]
const [moved] = nextItems.splice(evt.oldIndex, 1)
nextItems.splice(evt.newIndex, 0, moved)
selectedGame.value = {
...selectedGame.value,
selectedTemplate.value = {
...selectedTemplate.value,
items: nextItems,
}
},
@@ -86,7 +87,7 @@ export function useAdminGameManager({
function mergeRequestItemsIntoDrafts(request) {
const requestId = request?.id
if (!requestId) return
const existingGameSrcs = new Set((selectedGame.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean))
const existingTemplateSrcs = new Set((selectedTemplate.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean))
const existingKeys = new Set(uploadItemDrafts.value.map((draft) => `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}`))
const nextRequestDrafts = (request.items || [])
.filter((item) => item?.src)
@@ -99,7 +100,7 @@ export function useAdminGameManager({
sourceName: requestItemFilename(item),
src: item.src,
}))
.filter((draft) => !existingGameSrcs.has(normalizeDraftSrc(draft.src)))
.filter((draft) => !existingTemplateSrcs.has(normalizeDraftSrc(draft.src)))
.filter((draft) => !existingKeys.has(`${draft.kind}:${draft.requestId}:${draft.itemId}`))
if (nextRequestDrafts.length) {
@@ -116,84 +117,91 @@ export function useAdminGameManager({
uploadFiles.value = uploadItemDrafts.value.filter((draft) => draft.kind === 'file').map((draft) => draft.file).filter(Boolean)
}
async function loadGame() {
async function loadTemplate(options = {}) {
const preserveUploadState = !!options.preserveUploadState
resetMessages()
resetUploadState()
if (!preserveUploadState) resetUploadState()
if (!selectedGameId.value) {
selectedGame.value = null
savedGameItemOrderIds.value = []
destroyGameItemSortable()
if (!selectedTemplateId.value) {
selectedTemplate.value = null
savedTemplateItemOrderIds.value = []
destroyTemplateItemSortable()
return
}
try {
isGameLoading.value = true
const data = await api.getGame(selectedGameId.value)
selectedGame.value = {
isTemplateLoading.value = true
const data = await api.getTopic(selectedTemplateId.value)
const loadedTemplate = data.template || data.topic || null
selectedTemplate.value = {
...data,
template: loadedTemplate,
items: (data.items || []).map((item) => ({
...item,
draftLabel: item.label,
})),
}
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncGameItemSortable()
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncTemplateItemSortable()
} catch (e) {
console.error('[AdminView] loadGame failed', selectedGameId.value, e)
selectedGame.value = null
error.value = '게임 정보를 불러오지 못했어요.'
selectedTemplate.value = null
error.value = '템플릿 정보를 불러오지 못했어요.'
} finally {
isGameLoading.value = false
isTemplateLoading.value = false
}
}
async function createGame() {
async function createTemplate(options = {}) {
const nextTopicId = typeof options.topicId === 'string' ? options.topicId.trim() : newTemplateId.value.trim()
const nextTopicName = typeof options.topicName === 'string' ? options.topicName.trim() : newTemplateName.value.trim()
const preserveUploadState = !!options.preserveUploadState
resetMessages()
try {
const res = await fetch(toApiUrl('/api/admin/games'), {
const res = await fetch(toApiUrl('/api/admin/templates'), {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: newGameId.value.trim(),
name: newGameName.value.trim(),
id: nextTopicId,
name: nextTopicName,
isPublic: !!newTemplateIsPublic.value,
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
}),
})
if (!res.ok) throw new Error('failed')
const data = await res.json()
const createdTemplate = data.template || {}
if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) {
const linkData = await api.linkAdminTemplateRequestGame(activeTemplateRequest.value.id, {
gameId: data.game.id,
const linkData = await api.linkAdminTemplateRequestTemplate(activeTemplateRequest.value.id, {
topicId: createdTemplate.id,
})
activeTemplateRequest.value = {
...activeTemplateRequest.value,
targetGameId: linkData.request?.targetGameId || data.game.id,
targetGameName: linkData.request?.targetGameName || data.game.name || newGameName.value.trim(),
targetTopicId: linkData.request?.targetTopicId || createdTemplate.id,
targetTopicName: linkData.request?.targetTopicName || createdTemplate.name || nextTopicName,
}
const requestIndex = templateRequests.value.findIndex((entry) => entry.id === activeTemplateRequest.value.id)
if (requestIndex >= 0) {
templateRequests.value.splice(requestIndex, 1, {
...templateRequests.value[requestIndex],
targetGameId: linkData.request?.targetGameId || data.game.id,
targetGameName: linkData.request?.targetGameName || data.game.name || newGameName.value.trim(),
targetTopicId: linkData.request?.targetTopicId || createdTemplate.id,
targetTopicName: linkData.request?.targetTopicName || createdTemplate.name || nextTopicName,
})
}
}
await refreshGames()
selectedGameId.value = data.game.id
if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id
closeGameCreateModal()
await loadGame()
if (activeTemplateRequest.value?.id) {
await refreshTemplates()
selectedTemplateId.value = createdTemplate.id
if (customItemModalOpen.value) customItemModalTargetTemplateId.value = createdTemplate.id
closeTemplateCreateModal()
await loadTemplate({ preserveUploadState })
if (!preserveUploadState && activeTemplateRequest.value?.id) {
const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value
mergeRequestItemsIntoDrafts(sourceRequest)
}
success.value = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
success.value = '템플릿이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
} catch (e) {
error.value = '게임 생성 실패(관리자 권한/중복 ID 확인)'
error.value = '템플릿 생성 실패(관리자 권한/중복 ID 확인)'
}
}
@@ -243,12 +251,31 @@ export function useAdminGameManager({
async function uploadItem() {
resetMessages()
if (!uploadItemDrafts.value.length || !selectedGameId.value) {
if (!uploadItemDrafts.value.length) {
error.value = '아이템 파일을 선택해주세요.'
return
}
try {
if (!selectedTemplateId.value && activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetTopicId) {
const draftTopicId = (activeTemplateRequest.value?.draftTopicId || '').trim()
const draftTopicName = (activeTemplateRequest.value?.draftTopicName || '').trim()
if (!draftTopicId || !draftTopicName) {
error.value = '먼저 신규 템플릿의 이름과 템플릿 ID를 저장해주세요.'
return
}
await createTemplate({
topicId: draftTopicId,
topicName: draftTopicName,
preserveUploadState: true,
})
}
if (!selectedTemplateId.value) {
error.value = '템플릿을 먼저 선택해주세요.'
return
}
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
let uploadCount = 0
@@ -259,7 +286,7 @@ export function useAdminGameManager({
fd.append('images', entry.file)
fd.append('labels', entry.label.trim())
})
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/images`), {
const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/images`), {
method: 'POST',
credentials: 'include',
body: fd,
@@ -273,7 +300,7 @@ export function useAdminGameManager({
for (const requestId of requestIds) {
const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId)
const result = await api.promoteAdminTemplateRequestItems(requestId, {
gameId: selectedGameId.value,
topicId: selectedTemplateId.value,
itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean),
itemSrcs: draftsForRequest.map((entry) => entry.src).filter(Boolean),
itemLabels: draftsForRequest.reduce((acc, entry) => {
@@ -286,8 +313,8 @@ export function useAdminGameManager({
}
resetUploadState()
await loadGame()
success.value = `게임 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
await loadTemplate()
success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
} catch (e) {
const apiError = e?.data?.error || ''
if (apiError === 'no_items_selected') {
@@ -296,34 +323,34 @@ export function useAdminGameManager({
}
if (apiError === 'promote_items_failed') {
const detail = e?.data?.detail ? ` (${e.data.detail})` : ''
error.value = `요청 아이템을 게임 기본 아이템으로 옮기지 못했어요.${detail}`
error.value = `요청 아이템을 템플릿 기본 아이템으로 옮기지 못했어요.${detail}`
return
}
if (apiError === 'game_not_found') {
error.value = '선택한 게임을 찾지 못했어요.'
if (apiError === 'topic_not_found') {
error.value = '선택한 템플릿을 찾지 못했어요.'
return
}
error.value = '아이템 추가에 실패했어요.'
}
}
async function saveGameItemOrder() {
async function saveTemplateItemOrder() {
resetMessages()
if (!selectedGameId.value || !selectedGame.value?.items?.length) return
if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) return
try {
const data = await api.updateAdminGameItemDisplayOrder(selectedGameId.value, {
itemIds: selectedGame.value.items.map((item) => item.id),
const data = await api.updateAdminTemplateItemDisplayOrder(selectedTemplateId.value, {
itemIds: selectedTemplate.value.items.map((item) => item.id),
})
selectedGame.value = {
...selectedGame.value,
selectedTemplate.value = {
...selectedTemplate.value,
items: (data.items || []).map((item) => ({
...item,
draftLabel: item.label,
})),
}
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncGameItemSortable()
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncTemplateItemSortable()
success.value = '기본 아이템 순서를 저장했어요.'
} catch (e) {
error.value = '기본 아이템 순서 저장에 실패했어요.'
@@ -332,17 +359,17 @@ export function useAdminGameManager({
return {
requestItemFilename,
destroyGameItemSortable,
syncGameItemSortable,
destroyTemplateItemSortable,
syncTemplateItemSortable,
mergeRequestItemsIntoDrafts,
removeUploadDraft,
loadGame,
createGame,
loadTemplate,
createTemplate,
handleItemFiles,
onFile,
openItemFilePicker,
clearItemFiles,
uploadItem,
saveGameItemOrder,
saveTemplateItemOrder,
}
}

View File

@@ -1,12 +1,14 @@
import { editorPath } from '../lib/paths'
export function useAdminTemplateRequests({
api,
activeTemplateRequest,
refreshTemplateRequests,
setTab,
openGameCreateModal,
newGameId,
newGameName,
selectAdminGame,
openTemplateCreateModal,
newTemplateId,
newTemplateName,
selectAdminTemplate,
mergeRequestItemsIntoDrafts,
resetMessages,
success,
@@ -19,13 +21,14 @@ export function useAdminTemplateRequests({
type: request.type,
status: request.status,
thumbnailSrc: request.thumbnailSrc || '',
draftGameId: request.draftGameId || '',
draftGameName: request.draftGameName || '',
draftTopicId: request.draftTopicId || '',
draftTopicName: request.draftTopicName || '',
draftTopicIsPublic: !!request.draftTopicIsPublic,
sourceTierListId: request.sourceTierListId || '',
sourceGameId: request.sourceGameId || '',
sourceTopicId: request.sourceTopicId || '',
sourceTierListTitle: request.sourceTierListTitle || '',
targetGameId: request.targetGameId || '',
targetGameName: request.targetGameName || '',
targetTopicId: request.targetTopicId || '',
targetTopicName: request.targetTopicName || '',
requesterName: request.requesterName || '',
}
}
@@ -35,13 +38,13 @@ export function useAdminTemplateRequests({
}
function templateRequestSourceUrl(request) {
if (!request?.sourceGameId || !request?.sourceTierListId) return ''
return `/editor/${request.sourceGameId}/${request.sourceTierListId}?preview=1`
if (!request?.sourceTopicId || !request?.sourceTierListId) return ''
return editorPath(request.sourceTopicId, request.sourceTierListId, { preview: true })
}
function templateRequestReviewHint(request) {
if (request.type === 'create') return '게임 생성 후 필요한 아이템만 골라 추가하고, 끝나면 여기서 처리 완료하세요.'
return '확인하기를 누르면 게임 관리 화면에 요청 아이템이 임시 추가되어 필요한 것만 선별 저장할 수 있어요.'
if (request.type === 'create') return '템플릿 생성 후 필요한 아이템만 골라 추가하고, 끝나면 여기서 처리 완료하세요.'
return '확인하기를 누르면 템플릿 관리 화면에 요청 아이템이 임시 추가되어 필요한 것만 선별 저장할 수 있어요.'
}
async function startTemplateRequestReview(request) {
@@ -49,26 +52,34 @@ export function useAdminTemplateRequests({
try {
request.isHandling = true
const data = await api.startAdminTemplateRequestReview(request.id)
request.status = data.request?.status || 'reviewing'
updateActiveTemplateRequest(request)
setTab('game-admin')
const syncedRequest = {
...request,
...(data.request || {}),
draftTopicId: request.draftTopicId || '',
draftTopicName: request.draftTopicName || '',
draftTopicIsPublic: !!request.draftTopicIsPublic,
}
Object.assign(request, syncedRequest)
request.status = syncedRequest.status || 'reviewing'
updateActiveTemplateRequest(syncedRequest)
setTab('template-admin')
if (request.type === 'create') {
const linkedGameId = request.targetGameId || ''
if (linkedGameId) {
await selectAdminGame(linkedGameId)
const linkedTopicId = syncedRequest.targetTopicId || ''
if (linkedTopicId) {
await selectAdminTemplate(linkedTopicId)
} else {
openGameCreateModal()
newGameId.value = (request.draftGameId || '').trim()
newGameName.value = (request.draftGameName || '').trim()
openTemplateCreateModal()
newTemplateId.value = (syncedRequest.draftTopicId || '').trim()
newTemplateName.value = (syncedRequest.draftTopicName || '').trim()
}
mergeRequestItemsIntoDrafts(request)
mergeRequestItemsIntoDrafts(syncedRequest)
} else {
const nextGameId = request.targetGameId || request.sourceGameId || ''
if (nextGameId) await selectAdminGame(nextGameId)
mergeRequestItemsIntoDrafts(request)
const nextTopicId = syncedRequest.targetTopicId || syncedRequest.sourceTopicId || ''
if (nextTopicId) await selectAdminTemplate(nextTopicId)
mergeRequestItemsIntoDrafts(syncedRequest)
}
success.value = '요청 아이템을 게임 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
success.value = '요청 아이템을 템플릿 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
} catch (e) {
error.value = '요청 확인 단계로 이동하지 못했어요.'
} finally {

View File

@@ -1,25 +1,54 @@
import { toApiUrl } from './runtime'
function emitBackendStatus(detail) {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent('tier-maker:backend-status', { detail }))
}
async function request(path, { method = 'GET', body, headers } = {}) {
const res = await fetch(toApiUrl(path), {
method,
credentials: 'include',
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(headers || {}),
},
body: body ? JSON.stringify(body) : undefined,
})
let res
try {
res = await fetch(toApiUrl(path), {
method,
credentials: 'include',
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(headers || {}),
},
body: body ? JSON.stringify(body) : undefined,
})
} catch (error) {
emitBackendStatus({
state: 'offline',
message: '서버 연결을 확인할 수 없어 잠시 후 다시 시도해주세요.',
path,
})
throw error
}
const contentType = res.headers.get('content-type') || ''
const data = contentType.includes('application/json') ? await res.json() : await res.text()
if (!res.ok) {
if (res.status >= 500 && data?.error === 'db_init_failed') {
emitBackendStatus({
state: 'maintenance',
message: '서비스 점검 중이거나 데이터베이스 초기화 중입니다. 잠시 후 다시 이용해주세요.',
path,
})
} else if (res.status >= 500) {
emitBackendStatus({
state: 'maintenance',
message: '서비스 내부 점검이 필요합니다. 잠시 후 다시 이용해주세요.',
path,
})
}
const err = new Error('request_failed')
err.status = res.status
err.data = data
throw err
}
emitBackendStatus({ state: 'online', path })
return data
}
@@ -30,21 +59,29 @@ export const api = {
login: ({ email, password }) => request('/api/auth/login', { method: 'POST', body: { email, password } }),
logout: () => request('/api/auth/logout', { method: 'POST' }),
listGames: () => request('/api/games'),
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }),
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }),
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
updateAdminGameItemDisplayOrder: (gameId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { method: 'PATCH', body: payload }),
updateAdminGameItem: (gameId, itemId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) =>
listTopics: () => request('/api/topics'),
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' }),
updateAdminTemplateDisplayOrder: (payload) => request('/api/admin/templates/display-order', { method: 'PATCH', body: payload }),
updateAdminTemplateItemDisplayOrder: (templateId, payload) =>
request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/display-order`, { method: 'PATCH', body: payload }),
updateAdminTemplate: (templateId, payload) =>
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' } = {}) =>
request(
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
),
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
listAdminTierLists: ({ q = '', topicId = '', page = 1, limit = 50 } = {}) =>
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
getAdminTierListStats: ({ q = '', topicId = '' } = {}) =>
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}`),
updateAdminTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
deleteAdminTierList: (tierListId) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'DELETE' }),
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
getAdminImageAssetStats: ({ month = '', limit = 12 } = {}) => {
const query = new URLSearchParams()
@@ -53,20 +90,21 @@ export const api = {
return request(`/api/admin/image-assets/stats?${query.toString()}`)
},
resetAdminImageAssetStats: (payload) => request('/api/admin/image-assets/stats/reset', { method: 'POST', body: payload || {} }),
cleanupAdminMissingImageReferences: () => request('/api/admin/image-assets/missing/cleanup', { method: 'POST', body: {} }),
listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`),
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
promoteAdminCustomItem: (itemId, payload) =>
promoteAdminTemplateItem: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
updateAdminCustomItemLabel: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
promoteAdminTierListItems: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
createAdminGameTemplateFromTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
createAdminTemplateFromTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-template`, { method: 'POST', body: payload }),
startAdminTemplateRequestReview: (requestId) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/review`, { method: 'POST', body: {} }),
linkAdminTemplateRequestGame: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/link-game`, { method: 'POST', body: payload }),
linkAdminTemplateRequestTemplate: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/link-template`, { method: 'POST', body: payload }),
promoteAdminTemplateRequestItems: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/promote-items`, { method: 'POST', body: payload }),
completeAdminTemplateRequest: (requestId) =>
@@ -100,10 +138,10 @@ export const api = {
},
deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
listPublicTierLists: (gameId) =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
searchPublicTierLists: (gameId, q = '') =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
listPublicTierListsByTopic: (topicId) =>
request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}`),
searchPublicTierListsByTopic: (topicId, q = '') =>
request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}&q=${encodeURIComponent(q || '')}`),
searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`),
listMyTierLists: () => request('/api/tierlists/me'),
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>

38
frontend/src/lib/paths.js Normal file
View File

@@ -0,0 +1,38 @@
function encodeSegment(value) {
return encodeURIComponent(String(value || '').trim())
}
export function homePath(query = '') {
const normalized = String(query || '').trim()
return normalized ? `/?q=${encodeURIComponent(normalized)}` : '/'
}
export function loginPath(redirect = '') {
const normalized = String(redirect || '').trim()
return normalized ? `/login?redirect=${encodeURIComponent(normalized)}` : '/login'
}
export function topicPath(topicId) {
return `/topics/${encodeSegment(topicId)}`
}
export function editorNewPath(topicId) {
return `/editor/${encodeSegment(topicId)}/new`
}
export function editorPath(topicId, tierListId, { preview = false } = {}) {
const base = `/editor/${encodeSegment(topicId)}/${encodeSegment(tierListId)}`
return preview ? `${base}?preview=1` : base
}
export function mePath() {
return '/me'
}
export function favoritesPath() {
return '/favorites'
}
export function profilePath() {
return '/profile'
}

View File

@@ -16,16 +16,16 @@ export function createRouter() {
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomeView },
{ path: '/games/:gameId', name: 'gameHub', component: GameHubView },
{ path: '/editor/:gameId/new', name: 'newEditor', component: TierEditorView },
{ path: '/editor/:gameId/:tierListId', name: 'editEditor', component: TierEditorView },
{ path: '/topics/:topicId', name: 'topicHub', component: GameHubView },
{ path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView },
{ path: '/editor/:topicId/:tierListId', name: 'editEditor', component: TierEditorView },
{ path: '/login', name: 'login', component: LoginView },
{ path: '/me', name: 'me', component: MyTierListsView },
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
{ path: '/search', name: 'search', component: SearchResultsView },
{ path: '/admin', redirect: '/admin/featured' },
{ path: '/admin/featured', name: 'adminFeatured', component: AdminView },
{ path: '/admin/games', name: 'adminGames', component: AdminView },
{ path: '/admin/templates', name: 'adminTemplates', component: AdminView },
{ path: '/admin/items', name: 'adminItems', component: AdminView },
{ path: '/admin/tierlists', name: 'adminTierlists', component: AdminView },
{ path: '/admin/users', name: 'adminUsers', component: AdminView },

View File

@@ -1,6 +1,8 @@
import { defineStore } from 'pinia'
import { api } from '../lib/api'
let refreshPromise = null
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
@@ -9,19 +11,23 @@ export const useAuthStore = defineStore('auth', {
}),
actions: {
async refresh() {
if (this.status === 'loading') return this.user
if (refreshPromise) return refreshPromise
this.status = 'loading'
try {
const data = await api.me()
this.user = data.user
return this.user
} catch (error) {
this.user = null
return null
} finally {
this.status = 'idle'
this.hydrated = true
}
refreshPromise = (async () => {
try {
const data = await api.me()
this.user = data.user
return this.user
} catch (error) {
this.user = null
return null
} finally {
this.status = 'idle'
this.hydrated = true
refreshPromise = null
}
})()
return refreshPromise
},
async signup(email, password) {
const user = await api.signup({ email, password })
@@ -42,4 +48,3 @@ export const useAuthStore = defineStore('auth', {
},
},
})

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast'
import { editorPath, loginPath } from '../lib/paths'
const router = useRouter()
const toast = useToast()
@@ -42,12 +43,12 @@ async function loadFavorites() {
favorites.value = data.tierLists || []
} catch (e) {
toast.error('로그인이 필요해요.')
router.push('/login?redirect=/favorites')
router.push(loginPath('/favorites'))
}
}
function openTierList(tierList) {
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
router.push(editorPath(tierList.topicId, tierList.id))
}
onMounted(loadFavorites)
@@ -58,11 +59,11 @@ onMounted(loadFavorites)
<div class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Collection</div>
<h2 class="pageHead__title"> 즐겨찾기</h2>
<h2 class="pageHead__title">즐겨찾기</h2>
<div class="pageHead__desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 있어요.</div>
</div>
<div class="pageHead__aside toolbar">
<input v-model="query" class="input" placeholder="제목, 게임, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
<input v-model="query" class="input" placeholder="제목, 주제, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
<select v-model="sort" class="select" @change="loadFavorites">
<option value="favorited">즐겨찾기한 </option>
<option value="updated">최신 업데이트순</option>
@@ -77,7 +78,7 @@ onMounted(loadFavorites)
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
<button class="boardCard__body" @click="openTierList(tierList)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" draggable="false" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
@@ -87,7 +88,7 @@ onMounted(loadFavorites)
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
<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>

View File

@@ -1,22 +1,24 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { editorNewPath, editorPath, loginPath } from '../lib/paths'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const gameId = computed(() => route.params.gameId)
const gameName = ref('')
const topicId = computed(() => route.params.topicId)
const topicName = ref('')
const tierLists = ref([])
const error = ref('')
const query = ref('')
const brokenThumbnailIds = ref({})
const isTopicLoading = ref(false)
const isListView = computed(() => route.query.view === 'list')
const topicTitle = computed(() => topicName.value || (isTopicLoading.value ? '주제 불러오는 중...' : ''))
function fmt(ts) {
return new Date(ts).toLocaleDateString(undefined, {
@@ -48,68 +50,72 @@ function handleThumbnailError(tierListId) {
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
}
onMounted(async () => {
await loadTierLists()
})
async function loadTierLists() {
isTopicLoading.value = true
try {
const [gameRes, listRes] = await Promise.all([
api.getGame(gameId.value),
api.searchPublicTierLists(gameId.value, query.value),
const [topicRes, listRes] = await Promise.all([
api.getTopic(topicId.value),
api.searchPublicTierListsByTopic(topicId.value, query.value),
])
gameName.value = gameRes.game?.name || gameId.value
topicName.value = topicRes.topic?.name || ''
brokenThumbnailIds.value = {}
tierLists.value = listRes.tierLists || []
} catch (e) {
error.value = '게임 정보를 불러오지 못했어요.'
error.value = '주제 정보를 불러오지 못했어요.'
} finally {
isTopicLoading.value = false
}
}
function createNew() {
const target = editorNewPath(topicId.value)
if (!auth.user) {
router.push(`/login?redirect=/editor/${gameId.value}/new`)
router.push(loginPath(target))
return
}
router.push(`/editor/${gameId.value}/new`)
router.push(target)
}
function openTierList(id) {
router.push(`/editor/${gameId.value}/${id}`)
router.push(editorPath(topicId.value, id))
}
function submitSearch() {
loadTierLists()
}
watch(
topicId,
() => {
topicName.value = ''
error.value = ''
loadTierLists()
},
{ immediate: true }
)
</script>
<template>
<section class="dashboardHero">
<div class="dashboardHero__left">
<div class="dashboardHero__eyebrow">Collection</div>
<h2 class="dashboardHero__title">{{ gameName || gameId }}</h2>
<p class="dashboardHero__desc"> 게임 공개 티어표를 탐색하고, 바로 보드를 만들어 같은 흐름으로 이어갈 있어요.</p>
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Collection</div>
<h2 class="pageHead__title">{{ topicTitle }}</h2>
<div class="pageHead__desc"> 주제 공개 티어표를 같은 카드 레이아웃으로 살펴보고 이어서 티어표를 만들 있어요.</div>
</div>
<div class="pageHead__aside toolbar">
<input v-model="query" class="input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
<button class="btn" @click="submitSearch">검색</button>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<section class="panel">
<div class="panel__head">
<div>
<div class="panel__title">공개 티어표</div>
<div class="panel__sub">제목이나 작성자로 빠르게 좁혀볼 있어요.</div>
</div>
<div class="searchBar">
<input v-model="query" class="searchBar__input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
<button class="searchBar__button" @click="submitSearch">검색</button>
</div>
</div>
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
<div v-else class="list" :class="{ 'list--table': isListView }">
<article v-for="t in tierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(t.id)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" alt="" @error="handleThumbnailError(t.id)" />
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" alt="" draggable="false" @error="handleThumbnailError(t.id)" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
@@ -121,7 +127,7 @@ function submitSearch() {
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" draggable="false" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
</div>
@@ -135,72 +141,17 @@ function submitSearch() {
</template>
<style scoped>
.dashboardHero {
display: flex;
gap: 18px;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
padding: 6px 2px 18px;
}
.dashboardHero__left {
display: grid;
gap: 8px;
}
.dashboardHero__eyebrow {
font-size: 12px;
color: var(--theme-text-soft);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dashboardHero__title {
margin: 4px 0 6px;
font-size: 32px;
letter-spacing: -0.04em;
color: var(--theme-text-strong);
}
.dashboardHero__desc {
margin: 0;
color: var(--theme-text-muted);
max-width: 720px;
}
.panel {
/* border: 1px solid var(--theme-border); */
background: transparent;
border-radius: 0;
padding: 0;
}
.error {
margin: 10px 0 14px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
}
.panel__title {
font-weight: 800;
font-size: 18px;
}
.panel__sub {
margin-top: 6px;
color: var(--theme-text-muted);
font-size: 13px;
}
.panel__head {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 18px;
}
.searchBar {
.toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.searchBar__input {
.input {
min-width: 240px;
padding: 11px 13px;
border-radius: 14px;
@@ -208,8 +159,8 @@ function submitSearch() {
background: var(--theme-surface-soft);
color: var(--theme-text);
}
.searchBar__button {
padding: 11px 14px;
.btn {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft-2);
@@ -217,6 +168,13 @@ function submitSearch() {
font-weight: 800;
cursor: pointer;
}
.error {
margin: 10px 0 14px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
}
.empty {
opacity: 0.75;
}
@@ -405,7 +363,11 @@ function submitSearch() {
grid-template-columns: 1fr;
}
.searchBar__input {
.toolbar {
width: 100%;
}
.input {
min-width: 0;
width: 100%;
}

View File

@@ -5,18 +5,19 @@ 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 items = ref([])
const templateRecords = ref([])
const error = ref('')
const loadingFavoriteId = ref('')
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
const games = computed(() => {
const filtered = items.value
const templates = computed(() => {
const filtered = templateRecords.value
.filter((item) => item.id !== 'freeform')
.filter((item) => {
if (!query.value) return true
@@ -33,34 +34,34 @@ const games = computed(() => {
})
})
async function loadGames() {
async function loadTemplates() {
try {
const data = await api.listGames()
items.value = data.games || []
const data = await api.listTopics()
templateRecords.value = data.topics || []
} catch (e) {
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
}
}
onMounted(loadGames)
watch(() => auth.user?.id, loadGames)
onMounted(loadTemplates)
watch(() => auth.user?.id, loadTemplates)
function goGame(gameId) {
router.push(`/games/${gameId}`)
function openTopic(templateId) {
router.push(topicPath(templateId))
}
async function toggleFavorite(game, event) {
async function toggleFavorite(template, event) {
event?.stopPropagation()
if (!auth.user) {
router.push(`/login?redirect=${encodeURIComponent(route.fullPath || '/')}`)
router.push(loginPath(route.fullPath || '/'))
return
}
if (!game?.id || loadingFavoriteId.value === game.id) return
if (!template?.id || loadingFavoriteId.value === template.id) return
try {
loadingFavoriteId.value = game.id
const res = game.isFavorited ? await api.unfavoriteGame(game.id) : await api.favoriteGame(game.id)
items.value = items.value.map((entry) => (entry.id === game.id ? { ...entry, ...res.game } : entry))
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 {
@@ -68,46 +69,46 @@ async function toggleFavorite(game, event) {
}
}
function thumbUrl(g) {
return g.thumbnailSrc ? toApiUrl(g.thumbnailSrc) : ''
function templateThumbUrl(template) {
return template.thumbnailSrc ? toApiUrl(template.thumbnailSrc) : ''
}
</script>
<template>
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Workspace</div>
<h1 class="pageHead__title">Game Library</h1>
<p class="pageHead__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 있어요.</p>
<p v-if="query" class="pageHead__searchState">"{{ query }}" 맞는 게임 템플릿만 보고 있어요.</p>
<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="games.length" name="libraryCard" tag="section" class="libraryGrid">
<article v-for="g in games" :key="g.id" class="libraryCard">
<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': g.isFavorited }"
:disabled="loadingFavoriteId === g.id"
@click.stop="toggleFavorite(g, $event)"
: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="goGame(g.id)">
<button class="libraryCard__main" type="button" @click="openTopic(template.id)">
<div class="libraryCard__thumbWrap">
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
<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">{{ g.name }}</div>
<div class="libraryCard__meta">{{ g.id }}</div>
<div class="libraryCard__title">{{ template.name }}</div>
<div class="libraryCard__meta">{{ template.id }}</div>
</div>
</button>
</article>
</TransitionGroup>
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 게임 템플릿이 없어요.' : '표시할 게임 템플릿이 없어요.' }}</div>
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 주제 템플릿이 없어요.' : '표시할 주제 템플릿이 없어요.' }}</div>
</template>
<style scoped>

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { api } from '../lib/api'
import { homePath, mePath } from '../lib/paths'
import { useToast } from '../composables/useToast'
const router = useRouter()
@@ -36,7 +37,7 @@ const checkingSession = computed(() => !authReady.value || auth.status === 'load
onMounted(async () => {
if (!auth.hydrated) await auth.refresh()
if (auth.user) {
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
return
}
try {
@@ -51,7 +52,7 @@ watch(
() => [auth.hydrated, auth.user],
([hydrated, user]) => {
if (!hydrated || !user) return
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
},
{ immediate: true }
)
@@ -65,7 +66,7 @@ async function submit() {
try {
if (mode.value === 'signup') await auth.signup(email.value, password.value)
else await auth.login(email.value, password.value)
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
} catch (e) {
error.value = '로그인/회원가입에 실패했어요.'
}
@@ -87,7 +88,8 @@ async function submit() {
</section>
<section v-else class="authScreen">
<div class="authTabs" role="tablist" aria-label="로그인 또는 회원가입">
<div class="authTabs" :class="{ 'authTabs--signup': mode === 'signup' }" role="tablist" aria-label="로그인 또는 회원가입">
<span class="authTabs__indicator" aria-hidden="true"></span>
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
로그인
</button>
@@ -132,7 +134,7 @@ async function submit() {
<div v-if="!hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
<div class="authActions">
<button class="secondaryAction" type="button" @click="router.push('/')">취소</button>
<button class="secondaryAction" type="button" @click="router.push(homePath())">취소</button>
<button class="primaryAction" type="submit">{{ submitLabel }}</button>
</div>
</form>
@@ -159,16 +161,37 @@ async function submit() {
}
.authTabs {
position: relative;
display: inline-flex;
gap: 8px;
gap: 0;
width: fit-content;
padding: 6px;
border-radius: 999px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
isolation: isolate;
}
.authTabs__indicator {
position: absolute;
top: 6px;
left: 6px;
width: calc(50% - 6px);
height: calc(100% - 12px);
border-radius: 999px;
background: rgba(76, 133, 245, 0.22);
box-shadow: inset 0 0 0 1px rgba(120, 169, 255, 0.1);
transform: translateX(0);
transition: transform 220ms ease, background-color 220ms ease, box-shadow 220ms ease;
z-index: 0;
}
.authTabs--signup .authTabs__indicator {
transform: translateX(100%);
}
.authTabs__button {
position: relative;
min-width: 112px;
padding: 10px 16px;
border: 0;
@@ -177,10 +200,11 @@ async function submit() {
color: var(--theme-text-muted);
font-weight: 700;
cursor: pointer;
transition: color 180ms ease;
z-index: 1;
}
.authTabs__button--active {
background: rgba(76, 133, 245, 0.22);
color: var(--theme-text-strong);
}

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast'
import { editorPath, loginPath } from '../lib/paths'
const router = useRouter()
const toast = useToast()
@@ -54,22 +55,20 @@ onMounted(async () => {
myLists.value = data.tierLists || []
} catch (e) {
toast.error('로그인이 필요해요.')
router.push('/login?redirect=/me')
router.push(loginPath('/me'))
}
})
function openList(t) {
router.push(
"/editor/" + t.gameId + "/" + t.id,
)
router.push(editorPath(t.topicId, t.id))
}
</script>
<template>
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Library</div>
<h2 class="pageHead__title"> 티어표</h2>
<div class="pageHead__eyebrow">Tier Lists</div>
<h2 class="pageHead__title">나의 티어표</h2>
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</div>
</div>
</section>
@@ -85,6 +84,7 @@ function openList(t) {
class="boardCard__thumb"
:src="tierListThumbnailUrl(t)"
alt=""
draggable="false"
@error="handleThumbnailError(t.id)"
/>
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
@@ -96,7 +96,7 @@ function openList(t) {
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" draggable="false" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
</div>

View File

@@ -2,6 +2,7 @@
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { homePath, loginPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast'
@@ -40,7 +41,7 @@ const displayInitial = computed(() => {
onMounted(async () => {
if (!auth.hydrated) await auth.refresh()
if (!auth.user) {
router.replace('/login')
router.replace(loginPath())
return
}
nickname.value = auth.user?.nickname || ''
@@ -112,7 +113,7 @@ async function saveProfile() {
async function logout() {
await auth.logout()
toast.success('로그아웃했어요.')
router.push('/')
router.push(homePath())
}
</script>
@@ -121,7 +122,7 @@ async function logout() {
<header class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Account</div>
<h2 class="pageHead__title">Settings</h2>
<h2 class="pageHead__title">설정</h2>
<div class="pageHead__desc">계정 정보를 간결하게 정리하고, 프로필 이미지를 클릭해서 바로 변경할 있어요.</div>
</div>
</header>
@@ -134,7 +135,7 @@ async function logout() {
<div class="settingsIdentity">
<div class="avatarButtonWrap">
<button class="avatarButton" type="button" @click="openAvatarPicker">
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" />
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" draggable="false" />
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
<div class="avatarButton__overlay">
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>

View File

@@ -3,6 +3,7 @@ import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { editorPath } from '../lib/paths'
const route = useRoute()
const router = useRouter()
@@ -37,7 +38,7 @@ function tierListThumbnailUrl(tierList) {
}
function openTierList(tierList) {
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
router.push(editorPath(tierList.topicId, tierList.id))
}
async function loadResults() {
@@ -65,13 +66,13 @@ watch(
<template>
<section class="wrap">
<div class="head">
<div>
<div class="head__eyebrow">Search</div>
<h2 class="title">전체 티어표 검색</h2>
<div class="desc">공개된 모든 티어표를 제목과 작성자 기준으로 찾아볼 있어요.</div>
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Search</div>
<h2 class="pageHead__title">전체 티어표 검색</h2>
<div class="pageHead__desc">공개된 티어표를 제목과 작성자 기준으로 다시 찾아볼 있어요.</div>
</div>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<div v-else-if="loading" class="empty">검색 중이에요.</div>
@@ -80,7 +81,7 @@ watch(
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
<button class="boardCard__body" @click="openTierList(tierList)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" draggable="false" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
@@ -92,7 +93,7 @@ watch(
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
<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>
@@ -110,30 +111,6 @@ watch(
display: grid;
gap: 18px;
}
.head {
display: flex;
gap: 14px;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
padding: 6px 2px 8px;
}
.head__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--theme-text-soft);
}
.title {
margin: 4px 0 0;
font-size: 32px;
color: var(--theme-text-strong);
letter-spacing: -0.04em;
}
.desc {
margin-top: 6px;
color: var(--theme-text-muted);
}
.error {
margin: 0 0 8px;
padding: 10px 12px;

View File

@@ -6,7 +6,10 @@ import * as htmlToImage from 'html-to-image'
import SvgIcon from '../components/SvgIcon.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'
import shareIcon from '../assets/icons/share.svg'
import { api } from '../lib/api'
import { editorNewPath, editorPath, loginPath, mePath, topicPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
import { useToast } from '../composables/useToast'
@@ -17,10 +20,10 @@ const auth = useAuthStore()
const toast = useToast()
const globalRightRailOpen = inject('rightRailOpen', ref(true))
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
const gameId = computed(() => route.params.gameId)
const templateId = computed(() => route.params.topicId)
const tierListId = computed(() => route.params.tierListId)
const previewMode = computed(() => route.query.preview === '1')
const gameName = ref('')
const templateName = ref('')
const columns = ref([{ id: 'col-1', name: '' }])
const groups = ref([
@@ -118,20 +121,23 @@ const copiedFromLabel = computed(() => {
if (sourceSnapshotAuthor.value) parts.push(sourceSnapshotAuthor.value)
return parts.join(' · ') || '복사해 온 티어표'
})
const customItems = computed(() =>
Object.values(itemsById.value)
.filter((item) => item?.origin === 'custom')
)
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
const canRequestTemplateCreate = computed(
() => canEdit.value && hasSavedTierList.value && gameId.value === 'freeform' && customItems.value.length > 0
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
)
const canRequestTemplateUpdate = computed(
() => canEdit.value && hasSavedTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
() => canEdit.value && hasSavedTierList.value && templateId.value !== 'freeform' && customItems.value.length > 0
)
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
const templateRequestTargetLabel = computed(() => (templateId.value === 'freeform' ? '새로운 템플릿' : (templateName.value || templateId.value || '선택한 주제')))
const shareTierListUrl = computed(() => {
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
if (!savedTierListId) return ''
if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true })
return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString()
})
watch(error, (message) => {
if (!message) return
@@ -165,6 +171,29 @@ function formatExportDate(ts) {
})
}
function getOrderedItemIds() {
const orderedIds = []
const seen = new Set()
const pushId = (itemId) => {
if (!itemId || seen.has(itemId) || !itemsById.value[itemId]) return
seen.add(itemId)
orderedIds.push(itemId)
}
pool.value.forEach(pushId)
groups.value.forEach((group) => {
;(group.cells || []).forEach((cell) => {
;(cell || []).forEach(pushId)
})
})
Object.keys(itemsById.value).forEach(pushId)
return orderedIds
}
function getOrderedItems() {
return getOrderedItemIds().map((itemId) => itemsById.value[itemId]).filter(Boolean)
}
function setIconSize(nextSize) {
iconSize.value = nextSize
}
@@ -644,17 +673,18 @@ function buildPayload(existingId) {
const finalTitle = effectiveTitle.value
return {
id: existingId || undefined,
gameId: gameId.value,
topicId: templateId.value,
title: finalTitle,
thumbnailSrc: thumbnailSrc.value || '',
description: (description.value || '').trim(),
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
iconSize: Number(iconSize.value || 80),
sourceTierListId: sourceTierListId.value || '',
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
groups: buildGroupPayload(),
pool: Object.values(itemsById.value),
pool: getOrderedItems(),
}
}
@@ -668,7 +698,7 @@ async function persistTierList({ showModal = false } = {}) {
persistedTierListId.value = savedTierListId || ''
title.value = res.tierList?.title || payload.title
if (tierListId.value === 'new' && res.tierList?.id) {
await router.replace(`/editor/${gameId.value}/${res.tierList.id}`)
await router.replace(editorPath(templateId.value, res.tierList.id))
}
updatedAt.value = Number(res.tierList?.updatedAt || Date.now())
authorName.value = res.tierList?.authorName || effectiveAuthorName.value
@@ -691,6 +721,32 @@ async function save() {
}
}
async function copyShareUrl() {
if (!shareTierListUrl.value) {
toast.error('먼저 티어표를 저장한 뒤 공유할 수 있어요.')
return
}
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(shareTierListUrl.value)
} else {
const helper = document.createElement('textarea')
helper.value = shareTierListUrl.value
helper.setAttribute('readonly', '')
helper.style.position = 'absolute'
helper.style.left = '-9999px'
document.body.appendChild(helper)
helper.select()
document.execCommand('copy')
helper.remove()
}
toast.success('공유 링크를 클립보드에 복사했어요.')
} catch (e) {
toast.error('공유 링크를 복사하지 못했어요.')
}
}
function closeSaveModal() {
isSaveModalOpen.value = false
}
@@ -721,6 +777,7 @@ function closeTemplateUpdateModal() {
}
function openDeleteModal() {
if (!hasSavedTierList.value) return
isDeleteModalOpen.value = true
}
@@ -729,14 +786,15 @@ function closeDeleteModal() {
}
async function confirmDeleteTierList() {
if (!canEdit.value || isNewTierList.value || isDeleting.value) return
const currentTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
if (!canEdit.value || !currentTierListId || isDeleting.value) return
error.value = ''
try {
isDeleting.value = true
await api.deleteTierList(tierListId.value)
await api.deleteTierList(currentTierListId)
closeDeleteModal()
toast.success('티어표를 삭제했어요.')
router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`)
router.push(templateId.value === 'freeform' ? mePath() : topicPath(templateId.value))
} catch (e) {
error.value = '티어표 삭제에 실패했어요.'
} finally {
@@ -751,7 +809,7 @@ async function duplicateCurrentTierList() {
const duplicatedId = data.tierList?.id
if (!duplicatedId) throw new Error('duplicate_failed')
toast.success('티어표를 복사해 내 작업으로 가져왔어요.')
router.push(`/editor/${gameId.value}/${duplicatedId}`)
router.push(editorPath(templateId.value, duplicatedId))
} catch (e) {
error.value = '티어표 복사에 실패했어요.'
}
@@ -784,14 +842,14 @@ async function requestTemplate(type) {
await api.requestTierListTemplate({
type,
sourceTierListId: sourceId,
gameId: gameId.value,
topicId: templateId.value,
requestTitle: templateRequestDraftTitle.value.trim(),
requestDescription: templateRequestDraftDescription.value.trim(),
thumbnailSrc: saved.tierList?.thumbnailSrc || thumbnailSrc.value || '',
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
groups: buildGroupPayload(),
boardItems: Object.values(itemsById.value),
boardItems: getOrderedItems(),
})
if (type === 'create') closeTemplateRequestModal()
@@ -835,25 +893,25 @@ onMounted(() => {
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
if (isNewTierList.value && !auth.user) {
router.replace(`/login?redirect=/editor/${gameId.value}/new`)
router.replace(loginPath(editorNewPath(templateId.value)))
return
}
try {
const gameRes = await api.getGame(gameId.value)
gameName.value = gameRes.game?.name || gameId.value
const base = (gameRes.items || []).map((img) => ({
const topicRes = await api.getTopic(templateId.value)
templateName.value = topicRes.topic?.name || templateId.value
const base = (topicRes.items || []).map((img) => ({
id: img.id,
src: img.src,
label: img.label,
origin: 'game',
origin: 'template',
}))
const map = {}
base.forEach((it) => (map[it.id] = it))
itemsById.value = map
pool.value = base.map((it) => it.id)
} catch (e) {
error.value = '게임 기본 이미지를 불러오지 못했어요.'
error.value = '기본 템플릿 이미지를 불러오지 못했어요.'
}
if (tierListId.value && tierListId.value !== 'new') {
@@ -867,6 +925,7 @@ onMounted(() => {
description.value = t.description || ''
isPublic.value = !!t.isPublic
showCharacterNames.value = !!t.showCharacterNames
iconSize.value = Number(t.iconSize || 80)
authorName.value = t.authorName || ''
authorAccountName.value = t.authorAccountName || ''
updatedAt.value = Number(t.updatedAt || 0)
@@ -902,7 +961,7 @@ onUnmounted(() => {
</script>
<template>
<section v-if="previewMode" class="previewOnly">
<section v-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
<div class="previewOnly__sheet">
<div class="previewOnly__title">{{ effectiveTitle }}</div>
<div v-if="description" class="previewOnly__description">{{ description }}</div>
@@ -918,6 +977,7 @@ onUnmounted(() => {
<div class="previewOnly__dropGrid" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__dropColumn">
<div class="previewOnly__drop">
<div v-if="columns.length > 1" class="previewOnly__columnBadge">{{ column.name || ' ' + (columnIndex + 1) }}</div>
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="previewOnly__cell">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
@@ -935,6 +995,10 @@ onUnmounted(() => {
</div>
</div>
</div>
<div class="previewOnly__footer">
<span>{{ effectiveAuthorName }}</span>
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
</div>
</div>
</section>
@@ -943,7 +1007,7 @@ onUnmounted(() => {
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="saveModalTitle">
<div id="saveModalTitle" class="modalCard__title">저장 완료</div>
<div class="modalCard__desc">티어표가 저장되었어요. 이어서 수정한 다시 저장할 수도 있어요.</div>
<div class="modalCard__desc">티어표가 저장되었어요.<br />이어서 수정한 다시 저장할 수도 있어요.</div>
<div class="modalCard__actions">
<button class="btn btn--save" @click="closeSaveModal">확인</button>
</div>
@@ -958,7 +1022,7 @@ onUnmounted(() => {
</div>
<div class="requestChecklist__hint">
제목과 설명을 함께 적어두면 관리자가 어떤 신규 템플릿인지 훨씬 빠르게 파악할 있어요.
예시: 제목 `템플릿 등록 요청`, 설명 `여름 이벤트 한정 캐릭터 중심으로 새 게임 템플릿이 필요합니다.`
예시: 제목 `템플릿 등록 요청`, 설명 `여름 이벤트 한정 캐릭터 중심으로 새 주제 템플릿이 필요합니다.`
</div>
<div class="templateRequestDraft">
<label class="templateRequestDraft__field">
@@ -1016,7 +1080,7 @@ onUnmounted(() => {
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteTierListTitle">
<div id="deleteTierListTitle" class="modalCard__title">티어표 삭제</div>
<div class="modalCard__desc">
"{{ title || gameName || '이 티어표' }}" 삭제할까요? 삭제 후에는 복구할 없어요.
"{{ title || templateName || '이 티어표' }}" 삭제할까요? 삭제 후에는 복구할 없어요.
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeDeleteModal">취소</button>
@@ -1057,7 +1121,7 @@ onUnmounted(() => {
<div class="editorMain">
<section class="head">
<div class="editorMain__headCopy">
<div class="editorMain__title">{{ gameName || gameId }}</div>
<div class="editorMain__title">{{ templateName || templateId }}</div>
<div class="editorMain__subtitle">
<template v-if="canEdit">
/ 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 있어요.
@@ -1068,7 +1132,7 @@ onUnmounted(() => {
</div>
<div v-if="sourceTierListId" class="editorMain__sourceNote">
<span>복사본</span>
<button class="editorMain__sourceLink" type="button" @click="router.push(`/editor/${gameId}/${sourceTierListId}`)">{{ copiedFromLabel }}</button>
<button class="editorMain__sourceLink" type="button" @click="router.push(editorPath(templateId, sourceTierListId))">{{ copiedFromLabel }}</button>
</div>
</div>
</section>
@@ -1142,13 +1206,14 @@ onUnmounted(() => {
</div>
<div class="row__content" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="row__column">
<div
<div
class="row__drop"
:data-list-type="'group'"
:data-group-id="g.id"
:data-column-index="columnIndex"
:ref="(el) => setGroupDropEl(g.id, columnIndex, el)"
>
<div v-if="columns.length > 1" class="row__columnBadge">{{ column.name || ' ' + (columnIndex + 1) }}</div>
<div v-if="!isExporting" class="row__empty" v-show="getGroupCellIds(g, columnIndex).length === 0">여기로 드래그해서 배치</div>
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="cell" :data-item-id="id">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
@@ -1184,13 +1249,16 @@ onUnmounted(() => {
@dragleave="onDragLeave"
@drop.prevent="onDropFiles"
>
<div>
<div>
<div class="dropzone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="dropzone__icon" />
</div>
<div class="dropzone__title">커스텀 이미지 추가</div>
<div class="dropzone__desc">이곳으로 이미지를 드래그하거나 파일 선택으로 번에 추가할 있어요.</div>
</div>
<div class="dropzone__actions">
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
<button class="btn btn--ghost dropzone__button" @click="openFile">파일 선택</button>
<button class="btn btn--ghost btn--small dropzone__button" @click="openFile">파일 선택</button>
</div>
</div>
</div>
@@ -1298,7 +1366,11 @@ onUnmounted(() => {
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
</div>
<div class="editorSidebar__utilityLinks">
<button v-if="canEdit && !isNewTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
<button v-if="hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--share" @click="copyShareUrl">
<SvgIcon :src="shareIcon" :size="16" />
<span>공유하기</span>
</button>
<button v-if="canEdit && hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
<button v-if="canDuplicate" class="editorSidebar__utilityLink" @click="duplicateCurrentTierList">복사해서 티어표로 가져오기</button>
<button
v-if="canRequestTemplateCreate"
@@ -1440,6 +1512,7 @@ onUnmounted(() => {
border: 1px solid var(--theme-border-strong);
}
.previewOnly__drop {
position: relative;
border-radius: 14px;
background: var(--theme-pill-bg);
border: 1px solid var(--theme-border);
@@ -1450,6 +1523,10 @@ onUnmounted(() => {
gap: 8px;
align-content: flex-start;
}
.previewOnly__columnBadge,
.row__columnBadge {
display: none;
}
.previewOnly__cell {
display: inline-flex;
position: relative;
@@ -1476,6 +1553,15 @@ onUnmounted(() => {
opacity: 0.52;
filter: grayscale(0.22) brightness(0.78);
}
.previewOnly__footer {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding-top: 8px;
color: var(--theme-text-soft);
font-size: 13px;
}
.toggleSwitch {
display: inline-flex;
align-items: center;
@@ -2073,9 +2159,26 @@ onUnmounted(() => {
.dropzone--board {
margin-top: 18px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
justify-content: center;
gap: 18px;
min-height: 180px;
padding: 28px 22px;
border-radius: 22px;
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
text-align: center;
}
.dropzone--board.dropzone--active {
border-color: rgba(96, 165, 250, 0.56);
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg-hover) 90%, white 6%), color-mix(in srgb, var(--theme-card-bg) 84%, white 4%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 18%, transparent), transparent 60%);
transform: translateY(-1px);
}
.dropzone__actions {
@@ -2083,10 +2186,39 @@ onUnmounted(() => {
align-items: center;
gap: 12px;
flex: 0 0 auto;
justify-content: center;
}
.dropzone__button {
min-width: 148px;
min-width: 124px;
min-height: 34px;
font-size: 11px;
}
/* .dropzone__iconWrap {
width: 52px;
height: 52px;
display: grid;
place-items: center;
border-radius: 16px;
background: color-mix(in srgb, var(--theme-text) 10%, transparent);
} */
.dropzone__icon {
width: 48px;
height: 48px;
opacity: 0.86;
}
.dropzone--board .dropzone__title {
font-size: 18px;
font-weight: 800;
}
.dropzone--board .dropzone__desc {
max-width: 520px;
color: var(--theme-text-soft);
line-height: 1.6;
}
.editorSidebar__section {
display: grid;
@@ -2210,6 +2342,9 @@ onUnmounted(() => {
background: transparent;
color: var(--theme-text-muted);
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
@@ -2221,6 +2356,10 @@ onUnmounted(() => {
.editorSidebar__utilityLink--danger {
color: rgba(248, 113, 113, 0.96);
}
.editorSidebar__utilityLink--share {
color: var(--theme-text-soft);
}
.sidebar__title {
font-weight: 900;
margin-bottom: 8px;
@@ -2285,10 +2424,11 @@ onUnmounted(() => {
}
.dropzone {
margin-top: 12px;
padding: 14px;
padding: 28px 22px;
border-radius: 16px;
border: 1px dashed rgba(255, 255, 255, 0.18);
background: var(--theme-surface-soft);
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
}
.dropzone--active {
border-color: rgba(110, 231, 183, 0.6);
@@ -2361,8 +2501,45 @@ onUnmounted(() => {
border-radius: 14px;
}
@media (max-width: 980px) {
.previewOnly__row {
grid-template-columns: 140px 1fr;
.previewOnly__row,
.row {
grid-template-columns: 1fr;
}
.previewOnly__columns,
.boardColumnsHeader {
display: none;
}
.previewOnly__columnsSpacer,
.boardColumnsHeader__spacer {
display: none;
}
.previewOnly__dropGrid,
.boardColumnsHeader__grid {
grid-template-columns: 1fr;
}
.previewOnly__drop,
.row__drop {
padding-top: 40px;
}
.previewOnly__columnBadge,
.row__columnBadge {
position: absolute;
top: 10px;
left: 10px;
display: inline-flex;
align-items: center;
max-width: calc(100% - 20px);
padding: 5px 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
color: var(--theme-text-soft);
font-size: 11px;
font-weight: 800;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.heroCard {
grid-template-columns: 1fr;
@@ -2373,9 +2550,6 @@ onUnmounted(() => {
.row__content {
grid-template-columns: 1fr;
}
.row {
grid-template-columns: 150px 1fr;
}
.sidebar {
position: static;
}
@@ -2404,20 +2578,6 @@ onUnmounted(() => {
.previewOnly {
padding: 14px;
}
.previewOnly__columns,
.previewOnly__row,
.boardColumnsHeader,
.row {
grid-template-columns: 1fr;
}
.previewOnly__columnsSpacer,
.boardColumnsHeader__spacer {
display: none;
}
.previewOnly__dropGrid,
.boardColumnsHeader__grid {
grid-template-columns: 1fr;
}
.pool {
grid-template-columns: repeat(4, minmax(0, 1fr));
}