Compare commits

...

23 Commits

Author SHA1 Message Date
672d17849b 릴리스: v1.4.33 가입 검증과 테마 기본값 정리 2026-04-02 22:35:14 +09:00
85863b1b36 릴리스: v1.4.32 내부 이름층 topic/template 정리 마감 2026-04-02 21:50:36 +09:00
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
33 changed files with 1496 additions and 866 deletions

View File

@@ -7,7 +7,7 @@ const FileStoreFactory = require('session-file-store')
const { ensureData } = require('./src/db') const { ensureData } = require('./src/db')
const authRoutes = require('./src/routes/auth') 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 tierListsRoutes = require('./src/routes/tierlists')
const adminRoutes = require('./src/routes/admin') const adminRoutes = require('./src/routes/admin')
@@ -24,7 +24,7 @@ const allowedOrigins = (process.env.CORS_ORIGINS || '')
const FileStore = FileStoreFactory(session) const FileStore = FileStoreFactory(session)
;['uploads/avatars', 'uploads/games', 'uploads/custom', 'uploads/tierlists', '.sessions'].forEach((relativePath) => { ;['uploads/avatars', 'uploads/topics', 'uploads/custom', 'uploads/tierlists', '.sessions'].forEach((relativePath) => {
fs.mkdirSync(path.join(__dirname, relativePath), { recursive: true }) fs.mkdirSync(path.join(__dirname, relativePath), { recursive: true })
}) })
@@ -74,12 +74,13 @@ app.use(async (req, res, next) => {
await ensureData() await ensureData()
next() next()
} catch (e) { } catch (e) {
console.error('[backend] db init failed', e)
res.status(500).json({ error: 'db_init_failed' }) res.status(500).json({ error: 'db_init_failed' })
} }
}) })
app.use('/api/auth', authRoutes) app.use('/api/auth', authRoutes)
app.use('/api/games', gamesRoutes) app.use('/api/topics', topicsRoutes)
app.use('/api/tierlists', tierListsRoutes) app.use('/api/tierlists', tierListsRoutes)
app.use('/api/admin', adminRoutes) app.use('/api/admin', adminRoutes)

View File

@@ -7,7 +7,7 @@ const {
} = require('../src/db') } = require('../src/db')
const BACKEND_ROOT = path.join(__dirname, '..') const BACKEND_ROOT = path.join(__dirname, '..')
const TARGET_DIRS = ['avatars', 'custom', 'games', 'tierlists'] const TARGET_DIRS = ['avatars', 'custom', 'topics', 'tierlists']
async function main() { async function main() {
await ensureData() await ensureData()

View File

@@ -35,7 +35,7 @@ function getOptimizationConfig(roles) {
if (roleSet.has('avatar')) { if (roleSet.has('avatar')) {
return { directory: 'avatars', width: 512, height: 512, fit: 'cover', quality: 82 } return { directory: 'avatars', width: 512, height: 512, fit: 'cover', quality: 82 }
} }
if (roleSet.has('game-thumbnail') || roleSet.has('tierlist-thumbnail') || roleSet.has('template-thumbnail')) { if (roleSet.has('topic-thumbnail') || roleSet.has('tierlist-thumbnail') || roleSet.has('template-thumbnail')) {
return { directory: 'legacy-thumbnails', width: 1280, height: 1280, fit: 'inside', quality: 84 } return { directory: 'legacy-thumbnails', width: 1280, height: 1280, fit: 'inside', quality: 84 }
} }
return { directory: 'legacy-items', width: 512, height: 512, fit: 'inside', quality: 84 } return { directory: 'legacy-items', width: 512, height: 512, fit: 'inside', quality: 84 }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
const RESERVED_NICKNAME_KEYWORDS = [
'admin',
'administrator',
'operator',
'owner',
'master',
'staff',
'system',
'root',
'support',
'manager',
'mod',
'moderator',
'official',
'service',
'team',
'zenn',
'운영자',
'관리자',
'오너',
'마스터',
'스태프',
'시스템',
'루트',
'서포트',
'매니저',
'모더레이터',
'공식',
]
function normalizeNickname(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/\s+/g, '')
}
function isReservedNickname(value) {
const normalized = normalizeNickname(value)
if (!normalized) return false
return RESERVED_NICKNAME_KEYWORDS.some((keyword) => normalized.includes(normalizeNickname(keyword)))
}
module.exports = {
RESERVED_NICKNAME_KEYWORDS,
normalizeNickname,
isReservedNickname,
}

View File

@@ -7,23 +7,26 @@ const { z } = require('zod')
const { nanoid } = require('nanoid') const { nanoid } = require('nanoid')
const { const {
findUserById, findUserById,
findGameById, findUserByEmail,
findGameItemById, findUserByNickname,
listGameItems, findTopicById,
findTopicItemById,
listTopicItems,
findImageAssetById, findImageAssetById,
createGame, createTopic,
listGames, listTopics,
updateGameThumbnail, updateTopicThumbnail,
updateGameVisibility, updateTopicVisibility,
createGameItem, createTopicItem,
updateGameItemLabel, updateTopicItemLabel,
updateGameItemDisplayOrder, updateTopicItemDisplayOrder,
countTierListsUsingTopicItem,
updateCustomItemLabel, updateCustomItemLabel,
updateImageAssetLabel, updateImageAssetLabel,
deleteGameItem, deleteTopicItem,
deleteGame, deleteTopic,
deleteTierList, deleteTierList,
updateGameDisplayOrder, updateTopicDisplayOrder,
listCustomItems, listCustomItems,
findCustomItemById, findCustomItemById,
findUnusedCustomItems, findUnusedCustomItems,
@@ -38,7 +41,7 @@ const {
listAdminTemplateRequests, listAdminTemplateRequests,
findTemplateRequestById, findTemplateRequestById,
updateTemplateRequestStatus, updateTemplateRequestStatus,
updateTemplateRequestTargetGame, updateTemplateRequestTargetTopic,
adminUpdateUser, adminUpdateUser,
adminUpdateUserPassword, adminUpdateUserPassword,
adminDeleteUser, adminDeleteUser,
@@ -51,9 +54,14 @@ const {
} = require('../db') } = require('../db')
const { requireAdmin } = require('../middleware/auth') const { requireAdmin } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage') const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
const { isReservedNickname } = require('../lib/user-validation')
const router = express.Router() const router = express.Router()
function getTemplateIdFromParams(req) {
return req.params.templateId || ''
}
function buildUploadFilename(file) { function buildUploadFilename(file) {
const ext = path.extname(file.originalname || '').toLowerCase() const ext = path.extname(file.originalname || '').toLowerCase()
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : '' const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
@@ -110,7 +118,7 @@ function canManageAdminRole(actingUser, primaryAdmin) {
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id 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({ const schema = z.object({
id: z.string().min(1), id: z.string().min(1),
name: z.string().min(1).max(60), name: z.string().min(1).max(60),
@@ -119,81 +127,86 @@ router.post('/games', requireAdmin, async (req, res) => {
}) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findGameById(parsed.data.id) const exists = await findTopicById(parsed.data.id)
if (exists) return res.status(409).json({ error: 'game_id_taken' }) if (exists) return res.status(409).json({ error: 'topic_id_taken' })
const game = await createGame({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic }) const template = await createTopic({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
if (parsed.data.thumbnailSrc) { if (parsed.data.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc) const copiedThumb = await copyUploadIntoTopicAsset(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/:gameId', requireAdmin, async (req, res) => { router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
isPublic: z.boolean(), isPublic: z.boolean(),
}) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(req.params.gameId) const templateId = getTemplateIdFromParams(req)
if (!game) return res.status(404).json({ error: 'not_found' }) const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' })
const updated = await updateGameVisibility(game.id, parsed.data.isPublic) const updated = await updateTopicVisibility(template.id, parsed.data.isPublic)
res.json({ game: updated }) res.json({ template: updated })
}) })
router.patch('/games/display-order', requireAdmin, async (req, res) => { router.patch('/templates/display-order', requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
gameIds: z.array(z.string().min(1)).max(50), topicIds: z.array(z.string().min(1)).max(50),
}) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const games = await listGames('', { includePrivate: true }) const templates = await listTopics('', { includePrivate: true })
const validGameIds = new Set(games.map((game) => game.id)) const validTopicIds = new Set(templates.map((template) => template.id))
const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId)) const filteredIds = parsed.data.topicIds.filter((topicId) => validTopicIds.has(topicId))
const updatedGames = await updateGameDisplayOrder(filteredIds) const updatedTemplates = await updateTopicDisplayOrder(filteredIds)
res.json({ games: updatedGames }) res.json({ templates: updatedTemplates })
}) })
router.patch('/games/:gameId/items/display-order', requireAdmin, async (req, res) => { router.patch('/templates/:templateId/items/display-order', requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
itemIds: z.array(z.string().min(1)).min(1), itemIds: z.array(z.string().min(1)).min(1),
}) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(req.params.gameId) const templateId = getTemplateIdFromParams(req)
if (!game) return res.status(404).json({ error: 'not_found' }) 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 }) 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' }) if (!req.file) return res.status(400).json({ error: 'file_required' })
const game = await findGameById(req.params.gameId) const templateId = getTemplateIdFromParams(req)
if (!game) return res.status(404).json({ error: 'not_found' }) const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' })
const optimized = await writeOptimizedImage({ const optimized = await writeOptimizedImage({
file: req.file, file: req.file,
directory: 'games', directory: 'topics',
width: 1280, width: 1280,
height: 1280, height: 1280,
fit: 'inside', fit: 'inside',
quality: 84, quality: 84,
}) })
const updated = await updateGameThumbnail(req.params.gameId, optimized.src) const updated = await updateTopicThumbnail(templateId, optimized.src)
res.json({ game: updated }) 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 : [] const files = Array.isArray(req.files) ? req.files : []
if (!files.length) return res.status(400).json({ error: 'file_required' }) if (!files.length) return res.status(400).json({ error: 'file_required' })
const game = await findGameById(req.params.gameId) const templateId = getTemplateIdFromParams(req)
if (!game) return res.status(404).json({ error: 'not_found' }) const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' })
const labelsRaw = req.body?.labels const labelsRaw = req.body?.labels
const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : [] const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : []
@@ -204,16 +217,16 @@ router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), a
files.map(async (file, index) => { files.map(async (file, index) => {
const optimized = await writeOptimizedImage({ const optimized = await writeOptimizedImage({
file, file,
directory: 'games', directory: 'topics',
width: 512, width: 512,
height: 512, height: 512,
fit: 'inside', fit: 'inside',
quality: 84, quality: 84,
}) })
return createGameItem({ return createTopicItem({
id: nanoid(), id: nanoid(),
gameId: game.id, topicId: template.id,
src: optimized.src, src: optimized.src,
label: normalizedLabels[index] || buildItemLabelFromFilename(file), label: normalizedLabels[index] || buildItemLabelFromFilename(file),
}) })
@@ -223,30 +236,40 @@ router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), a
res.json({ item: items[0], items }) res.json({ item: items[0], items })
}) })
router.delete('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => { router.delete('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
const game = await findGameById(req.params.gameId) const template = await findTopicById(getTemplateIdFromParams(req))
if (!game) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
await deleteGameItem(req.params.itemId) await deleteTopicItem(req.params.itemId)
res.json({ ok: true }) 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 schema = z.object({ label: z.string().trim().min(1).max(60) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(req.params.gameId) const template = await findTopicById(getTemplateIdFromParams(req))
if (!game) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
const updated = await updateGameItemLabel(req.params.itemId, parsed.data.label) const updated = await updateTopicItemLabel(req.params.itemId, parsed.data.label)
if (!updated || updated.gameId !== game.id) return res.status(404).json({ error: 'not_found' }) if (!updated || updated.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
res.json({ item: updated }) res.json({ item: updated })
}) })
router.delete('/games/:gameId', requireAdmin, async (req, res) => { router.delete('/templates/:templateId', requireAdmin, async (req, res) => {
const game = await findGameById(req.params.gameId) const templateId = getTemplateIdFromParams(req)
if (!game) return res.status(404).json({ error: 'not_found' }) const template = await findTopicById(templateId)
await deleteGame(req.params.gameId) if (!template) return res.status(404).json({ error: 'not_found' })
await deleteTopic(templateId)
res.json({ ok: true }) res.json({ ok: true })
}) })
@@ -266,7 +289,7 @@ router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
} }
if (parsed.data.sourceType === 'template') { 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' }) if (!updated) return res.status(404).json({ error: 'not_found' })
return res.json({ item: updated }) return res.json({ item: updated })
} }
@@ -298,7 +321,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
router.get('/tierlists', requireAdmin, async (req, res) => { router.get('/tierlists', requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
q: z.string().trim().max(120).optional().default(''), q: z.string().trim().max(120).optional().default(''),
gameId: 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), page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(200).optional().default(50), limit: z.coerce.number().int().min(1).max(200).optional().default(50),
}) })
@@ -307,7 +330,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
const result = await listAdminTierLists({ const result = await listAdminTierLists({
queryText: parsed.data.q, queryText: parsed.data.q,
gameId: parsed.data.gameId, topicId: parsed.data.topicId,
page: parsed.data.page, page: parsed.data.page,
limit: parsed.data.limit, limit: parsed.data.limit,
currentUserId: req.session?.userId || '', currentUserId: req.session?.userId || '',
@@ -318,14 +341,14 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
router.get('/tierlists/stats', requireAdmin, async (req, res) => { router.get('/tierlists/stats', requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
q: z.string().trim().max(120).optional().default(''), q: z.string().trim().max(120).optional().default(''),
gameId: z.string().trim().max(120).optional().default(''), topicId: z.string().trim().max(120).optional().default(''),
}) })
const parsed = schema.safeParse(req.query) const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const result = await summarizeAdminTierLists({ const result = await summarizeAdminTierLists({
queryText: parsed.data.q, queryText: parsed.data.q,
gameId: parsed.data.gameId, topicId: parsed.data.topicId,
}) })
res.json(result) res.json(result)
}) })
@@ -440,16 +463,16 @@ async function removeCustomItemFiles(items) {
) )
} }
async function promoteLibraryItemToGameItem({ item, gameId }) { async function promoteLibraryItemToTemplateItem({ item, templateId }) {
return createGameItem({ return createTopicItem({
id: nanoid(), id: nanoid(),
gameId, topicId: templateId,
src: item.src || '', src: item.src || '',
label: item.label, label: item.label,
}) })
} }
async function copyUploadIntoGameAsset(src) { async function copyUploadIntoTopicAsset(src) {
if (typeof src !== 'string') return '' if (typeof src !== 'string') return ''
const raw = src.trim() const raw = src.trim()
if (!raw) return '' if (!raw) return ''
@@ -480,18 +503,18 @@ function uniqueTierListPoolItems(tierList) {
}) })
} }
async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) { async function promoteTierListItemsToTemplate({ tierList, templateId, itemIds = [] }) {
const allowedIds = new Set((itemIds || []).filter(Boolean)) const allowedIds = new Set((itemIds || []).filter(Boolean))
const sourceItems = uniqueTierListPoolItems(tierList).filter((item) => item.origin === 'custom') const sourceItems = uniqueTierListPoolItems(tierList).filter((item) => item.origin === 'custom')
const itemsToCopy = allowedIds.size ? sourceItems.filter((item) => allowedIds.has(item.id)) : sourceItems const itemsToCopy = allowedIds.size ? sourceItems.filter((item) => allowedIds.has(item.id)) : sourceItems
const createdItems = [] const createdItems = []
for (const item of itemsToCopy) { for (const item of itemsToCopy) {
const copiedSrc = await copyUploadIntoGameAsset(item.src) const copiedSrc = await copyUploadIntoTopicAsset(item.src)
createdItems.push( createdItems.push(
await createGameItem({ await createTopicItem({
id: nanoid(), id: nanoid(),
gameId, topicId: templateId,
src: copiedSrc, src: copiedSrc,
label: item.label, label: item.label,
}) })
@@ -501,8 +524,8 @@ async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
return createdItems return createdItems
} }
async function promoteSnapshotItemsToGame({ items, gameId }) { async function promoteSnapshotItemsToTemplate({ items, templateId }) {
const existingItems = await listGameItems(gameId) const existingItems = await listTopicItems(templateId)
const existingSrcs = new Set( const existingSrcs = new Set(
existingItems existingItems
.map((item) => (typeof item?.src === 'string' ? item.src.trim() : '')) .map((item) => (typeof item?.src === 'string' ? item.src.trim() : ''))
@@ -511,12 +534,12 @@ async function promoteSnapshotItemsToGame({ items, gameId }) {
const createdItems = [] const createdItems = []
for (const item of items || []) { for (const item of items || []) {
const copiedSrc = await copyUploadIntoGameAsset(item.src) const copiedSrc = await copyUploadIntoTopicAsset(item.src)
if (!copiedSrc || existingSrcs.has(copiedSrc)) continue if (!copiedSrc || existingSrcs.has(copiedSrc)) continue
createdItems.push( createdItems.push(
await createGameItem({ await createTopicItem({
id: nanoid(), id: nanoid(),
gameId, topicId: templateId,
src: copiedSrc, src: copiedSrc,
label: item.label, label: item.label,
}) })
@@ -553,43 +576,43 @@ function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}
}) })
} }
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) { async function createTemplateFromTierList({ tierList, templateId, templateName }) {
await createGame({ id: gameId, name: gameName, isPublic: false }) await createTopic({ id: templateId, name: templateName, isPublic: false })
if (tierList.thumbnailSrc) { if (tierList.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc) const copiedThumb = await copyUploadIntoTopicAsset(tierList.thumbnailSrc)
await updateGameThumbnail(gameId, copiedThumb) await updateTopicThumbnail(templateId, copiedThumb)
} }
const createdItems = [] const createdItems = []
for (const item of uniqueTierListPoolItems(tierList)) { for (const item of uniqueTierListPoolItems(tierList)) {
const copiedSrc = await copyUploadIntoGameAsset(item.src) const copiedSrc = await copyUploadIntoTopicAsset(item.src)
createdItems.push( createdItems.push(
await createGameItem({ await createTopicItem({
id: nanoid(), id: nanoid(),
gameId, topicId: templateId,
src: copiedSrc, src: copiedSrc,
label: item.label, label: item.label,
}) })
) )
} }
return { game: await findGameById(gameId), items: createdItems } return { template: await findTopicById(templateId), items: createdItems }
} }
async function createGameTemplateFromRequest({ templateRequest, gameId, gameName }) { async function createTemplateFromRequest({ templateRequest, templateId, templateName }) {
await createGame({ id: gameId, name: gameName, isPublic: false }) await createTopic({ id: templateId, name: templateName, isPublic: false })
if (templateRequest.thumbnailSrc) { if (templateRequest.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc) const copiedThumb = await copyUploadIntoTopicAsset(templateRequest.thumbnailSrc)
await updateGameThumbnail(gameId, copiedThumb) await updateTopicThumbnail(templateId, copiedThumb)
} }
const items = await promoteSnapshotItemsToGame({ const items = await promoteSnapshotItemsToTemplate({
items: templateRequest.items || [], 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) => { router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
@@ -606,12 +629,12 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
return res.json({ ok: true, sourceType: 'template-asset' }) return res.json({ ok: true, sourceType: 'template-asset' })
} }
await deleteGameItem(target.id) await deleteTopicItem(target.id)
return res.json({ ok: true, sourceType: 'template' }) return res.json({ ok: true, sourceType: 'template' })
} }
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' }) 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' }) if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
const items = await findCustomItemsByIds([target.id]) const items = await findCustomItemsByIds([target.id])
@@ -622,21 +645,21 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => { router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
gameId: z.string().min(1), topicId: z.string().min(1),
}) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(parsed.data.gameId) const template = await findTopicById(parsed.data.topicId)
if (!game) return res.status(404).json({ error: 'game_not_found' }) if (!template) return res.status(404).json({ error: 'topic_not_found' })
const customItem = await findCustomItemById(req.params.itemId) 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 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 = const sourceItem =
customItem || customItem ||
gameItem || templateItem ||
(imageAsset (imageAsset
? { ? {
src: imageAsset.src || '', src: imageAsset.src || '',
@@ -645,54 +668,54 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
: null) : null)
if (!sourceItem) return res.status(404).json({ error: 'not_found' }) 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 }) res.json({ item })
}) })
router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => { router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
gameId: z.string().min(1), topicId: z.string().min(1),
itemIds: z.array(z.string().min(1)).optional().default([]), itemIds: z.array(z.string().min(1)).optional().default([]),
}) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(parsed.data.gameId) const template = await findTopicById(parsed.data.topicId)
if (!game) return res.status(404).json({ error: 'game_not_found' }) if (!template) return res.status(404).json({ error: 'topic_not_found' })
const tierList = await findTierListById(req.params.tierListId) const tierList = await findTierListById(req.params.tierListId)
if (!tierList) return res.status(404).json({ error: 'not_found' }) if (!tierList) return res.status(404).json({ error: 'not_found' })
const items = await promoteTierListItemsToGame({ const items = await promoteTierListItemsToTemplate({
tierList, tierList,
gameId: game.id, templateId: template.id,
itemIds: parsed.data.itemIds, itemIds: parsed.data.itemIds,
}) })
res.json({ items }) 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({ 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), name: z.string().trim().min(1).max(120),
itemIds: z.array(z.string().min(1)).optional().default([]), itemIds: z.array(z.string().min(1)).optional().default([]),
}) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findGameById(parsed.data.gameId) const exists = await findTopicById(parsed.data.topicId)
if (exists) return res.status(409).json({ error: 'game_id_taken' }) if (exists) return res.status(409).json({ error: 'topic_id_taken' })
const tierList = await findTierListById(req.params.tierListId) const tierList = await findTierListById(req.params.tierListId)
if (!tierList) return res.status(404).json({ error: 'not_found' }) if (!tierList) return res.status(404).json({ error: 'not_found' })
const result = await createGameTemplateFromTierList({ const result = await createTemplateFromTierList({
tierList: { tierList: {
...tierList, ...tierList,
pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool, pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool,
}, },
gameId: parsed.data.gameId, templateId: parsed.data.topicId,
gameName: parsed.data.name, templateName: parsed.data.name,
}) })
res.json(result) res.json(result)
}) })
@@ -731,32 +754,32 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r
if (templateRequest.status !== 'pending') return res.status(409).json({ error: 'request_already_handled' }) if (templateRequest.status !== 'pending') return res.status(409).json({ error: 'request_already_handled' })
if (templateRequest.type === 'update') { if (templateRequest.type === 'update') {
const targetGameId = templateRequest.targetGameId || templateRequest.sourceGameId const targetTopicId = templateRequest.targetTopicId || templateRequest.sourceTopicId
const game = await findGameById(targetGameId) const template = await findTopicById(targetTopicId)
if (!game) return res.status(404).json({ error: 'game_not_found' }) if (!template) return res.status(404).json({ error: 'topic_not_found' })
const items = await promoteSnapshotItemsToGame({ const items = await promoteSnapshotItemsToTemplate({
items: templateRequest.items || [], items: templateRequest.items || [],
gameId: game.id, templateId: template.id,
}) })
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' }) const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
return res.json({ request, items }) return res.json({ request, items })
} }
const schema = z.object({ 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), name: z.string().trim().min(1).max(120),
}) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findGameById(parsed.data.gameId) const exists = await findTopicById(parsed.data.topicId)
if (exists) return res.status(409).json({ error: 'game_id_taken' }) if (exists) return res.status(409).json({ error: 'topic_id_taken' })
const result = await createGameTemplateFromRequest({ const result = await createTemplateFromRequest({
templateRequest, templateRequest,
gameId: parsed.data.gameId, templateId: parsed.data.topicId,
gameName: parsed.data.name, templateName: parsed.data.name,
}) })
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' }) const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
res.json({ request, ...result }) res.json({ request, ...result })
@@ -769,10 +792,10 @@ router.post('/template-requests/:requestId/review', requireAdmin, async (req, re
return res.status(409).json({ error: 'request_already_handled' }) return res.status(409).json({ error: 'request_already_handled' })
} }
if (templateRequest.type === 'create' && templateRequest.targetGameId && !templateRequest.targetGameName) { if (templateRequest.type === 'create' && templateRequest.targetTopicId && !templateRequest.targetTopicName) {
templateRequest = await updateTemplateRequestTargetGame({ templateRequest = await updateTemplateRequestTargetTopic({
id: templateRequest.id, id: templateRequest.id,
targetGameId: '', targetTopicId: '',
}) })
} }
@@ -784,9 +807,9 @@ router.post('/template-requests/:requestId/review', requireAdmin, async (req, re
res.json({ request }) 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({ 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) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -798,19 +821,19 @@ router.post('/template-requests/:requestId/link-game', requireAdmin, async (req,
return res.status(409).json({ error: 'request_already_handled' }) return res.status(409).json({ error: 'request_already_handled' })
} }
const game = await findGameById(parsed.data.gameId) const template = await findTopicById(parsed.data.topicId)
if (!game) return res.status(404).json({ error: 'game_not_found' }) if (!template) return res.status(404).json({ error: 'topic_not_found' })
const request = await updateTemplateRequestTargetGame({ const request = await updateTemplateRequestTargetTopic({
id: templateRequest.id, id: templateRequest.id,
targetGameId: game.id, targetTopicId: template.id,
}) })
res.json({ request }) res.json({ request })
}) })
router.post('/template-requests/:requestId/promote-items', requireAdmin, async (req, res) => { router.post('/template-requests/:requestId/promote-items', requireAdmin, async (req, res) => {
const schema = z.object({ 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([]), itemIds: z.array(z.string().min(1)).optional().default([]),
itemSrcs: 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({}), itemLabels: z.record(z.string(), z.string().min(1).max(60)).optional().default({}),
@@ -824,8 +847,8 @@ router.post('/template-requests/:requestId/promote-items', requireAdmin, async (
return res.status(409).json({ error: 'request_already_handled' }) return res.status(409).json({ error: 'request_already_handled' })
} }
const game = await findGameById(parsed.data.gameId) const template = await findTopicById(parsed.data.topicId)
if (!game) return res.status(404).json({ error: 'game_not_found' }) if (!template) return res.status(404).json({ error: 'topic_not_found' })
const promotableItems = pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels, parsed.data.itemSrcs) const promotableItems = pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels, parsed.data.itemSrcs)
if (!promotableItems.length) { if (!promotableItems.length) {
@@ -834,14 +857,14 @@ router.post('/template-requests/:requestId/promote-items', requireAdmin, async (
let items = [] let items = []
try { try {
items = await promoteSnapshotItemsToGame({ items = await promoteSnapshotItemsToTemplate({
items: promotableItems, items: promotableItems,
gameId: game.id, templateId: template.id,
}) })
} catch (error) { } catch (error) {
console.error('[admin] template request promote-items failed', { console.error('[admin] template request promote-items failed', {
requestId: templateRequest.id, requestId: templateRequest.id,
gameId: game.id, topicId: template.id,
itemCount: promotableItems.length, itemCount: promotableItems.length,
message: error?.message || 'unknown_error', message: error?.message || 'unknown_error',
code: error?.code || '', code: error?.code || '',
@@ -942,6 +965,18 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
return res.status(403).json({ error: 'primary_admin_only' }) return res.status(403).json({ error: 'primary_admin_only' })
} }
if (isReservedNickname(parsed.data.nickname)) {
return res.status(400).json({ error: 'nickname_reserved' })
}
const duplicateEmail = await findUserByEmail(parsed.data.email)
if (duplicateEmail && duplicateEmail.id !== targetUser.id) {
return res.status(409).json({ error: 'email_taken' })
}
const duplicateNickname = await findUserByNickname(parsed.data.nickname, targetUser.id)
if (duplicateNickname) {
return res.status(409).json({ error: 'nickname_taken' })
}
try { try {
const updated = await adminUpdateUser({ const updated = await adminUpdateUser({
id: targetUser.id, id: targetUser.id,

View File

@@ -6,6 +6,7 @@ const multer = require('multer')
const { const {
countUsers, countUsers,
findUserByEmail, findUserByEmail,
findUserByNickname,
findUserById, findUserById,
createUser, createUser,
updateUserProfile, updateUserProfile,
@@ -13,11 +14,13 @@ const {
} = require('../db') } = require('../db')
const { requireAuth } = require('../middleware/auth') const { requireAuth } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage') const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
const { isReservedNickname } = require('../lib/user-validation')
const router = express.Router() const router = express.Router()
const signupSchema = z.object({ const signupSchema = z.object({
email: z.string().email(), email: z.string().email(),
nickname: z.string().trim().min(2).max(40),
password: z.string().min(6), password: z.string().min(6),
}) })
@@ -62,13 +65,16 @@ router.post('/signup', async (req, res) => {
const parsed = signupSchema.safeParse(req.body) const parsed = signupSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const { email, password } = parsed.data const { email, nickname, password } = parsed.data
const exists = await findUserByEmail(email) const exists = await findUserByEmail(email)
if (exists) return res.status(409).json({ error: 'email_taken' }) if (exists) return res.status(409).json({ error: 'email_taken' })
if (isReservedNickname(nickname)) return res.status(400).json({ error: 'nickname_reserved' })
const nicknameExists = await findUserByNickname(nickname)
if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' })
const passwordHash = await bcrypt.hash(password, 10) const passwordHash = await bcrypt.hash(password, 10)
const isAdmin = (await countUsers()) === 0 const isAdmin = (await countUsers()) === 0
const user = await createUser({ id: nanoid(), email, nickname: '', passwordHash, isAdmin }) const user = await createUser({ id: nanoid(), email, nickname, passwordHash, isAdmin })
try { try {
await establishSession(req, user) await establishSession(req, user)
@@ -79,7 +85,10 @@ router.post('/signup', async (req, res) => {
}) })
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
const parsed = signupSchema.safeParse(req.body) const parsed = z.object({
email: z.string().email(),
password: z.string().min(6),
}).safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const { email, password } = parsed.data const { email, password } = parsed.data
@@ -121,6 +130,9 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
const user = await findUserById(req.session.userId) const user = await findUserById(req.session.userId)
if (!user) return res.status(404).json({ error: 'not_found' }) if (!user) return res.status(404).json({ error: 'not_found' })
if (isReservedNickname(parsed.data.nickname)) return res.status(400).json({ error: 'nickname_reserved' })
const nicknameExists = await findUserByNickname(parsed.data.nickname, user.id)
if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' })
const optimized = req.file const optimized = req.file
? await writeOptimizedImage({ ? await writeOptimizedImage({

View File

@@ -1,37 +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 || '', { includePrivate: !!req.session?.isAdmin })
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' })
if (!detail.game.isPublic && !req.session?.isAdmin) 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 { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
const router = express.Router() const router = express.Router()
const FREEFORM_GAME_ID = 'freeform' const FREEFORM_TOPIC_ID = 'freeform'
const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기' const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기'
function normalizePoolItem(item) { 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 if (item.src.startsWith('/uploads/')) return item
try { try {
@@ -61,7 +61,7 @@ const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 }
const templateRequestSchema = z.object({ const templateRequestSchema = z.object({
type: z.enum(['create', 'update']), type: z.enum(['create', 'update']),
sourceTierListId: z.string().max(64).optional().default(''), 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), requestTitle: z.string().trim().min(1).max(120),
requestDescription: z.string().trim().min(1).max(1000), requestDescription: z.string().trim().min(1).max(1000),
thumbnailSrc: z.string().max(255).optional().default(''), thumbnailSrc: z.string().max(255).optional().default(''),
@@ -72,21 +72,25 @@ const templateRequestSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
name: z.string().min(1).max(16), name: z.string().min(1).max(16),
itemIds: z.array(z.string()).optional().default([]), 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( boardItems: z.array(
z.object({ z.object({
id: z.string().min(1), id: z.string().min(1),
src: z.string().min(1), src: z.string().min(1),
label: z.string().min(1).max(60), 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({ const tierListUpsertSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
gameId: z.string().min(1), topicId: z.string().min(1).optional(),
title: z.string().min(1).max(120), title: z.string().min(1).max(120),
thumbnailSrc: z.string().max(255).optional().default(''), thumbnailSrc: z.string().max(255).optional().default(''),
description: z.string().max(1000).optional().default(''), description: z.string().max(1000).optional().default(''),
@@ -108,15 +112,19 @@ const tierListUpsertSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
src: z.string().min(1), src: z.string().min(1),
label: z.string().min(1).max(60), 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) => { 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 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 }) res.json({ tierLists: lists })
}) })
@@ -226,14 +234,15 @@ router.post('/template-request', requireAuth, async (req, res) => {
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const payload = parsed.data const payload = parsed.data
const topicId = payload.topicId
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem) const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom') const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' }) if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
if (payload.type === 'create') { if (payload.type === 'create') {
if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' }) if (topicId !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' })
} else if (payload.gameId === FREEFORM_GAME_ID) { } else if (topicId === FREEFORM_TOPIC_ID) {
return res.status(400).json({ error: 'game_template_required' }) return res.status(400).json({ error: 'topic_template_required' })
} }
let sourceTierList = null let sourceTierList = null
@@ -251,8 +260,8 @@ router.post('/template-request', requireAuth, async (req, res) => {
type: payload.type, type: payload.type,
requesterId: req.session.userId, requesterId: req.session.userId,
sourceTierListId: sourceTierList?.id || '', sourceTierListId: sourceTierList?.id || '',
sourceGameId: payload.gameId, sourceTopicId: topicId,
targetGameId: payload.type === 'update' ? payload.gameId : '', targetTopicId: payload.type === 'update' ? topicId : '',
title: payload.requestTitle, title: payload.requestTitle,
description: payload.requestDescription, description: payload.requestDescription,
thumbnailSrc: payload.thumbnailSrc || '', thumbnailSrc: payload.thumbnailSrc || '',
@@ -274,6 +283,7 @@ router.post('/', requireAuth, async (req, res) => {
const parsed = tierListUpsertSchema.safeParse(req.body) const parsed = tierListUpsertSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const payload = parsed.data const payload = parsed.data
const topicId = payload.topicId
const normalizedPool = payload.pool.map(normalizePoolItem) const normalizedPool = payload.pool.map(normalizePoolItem)
let existing = null let existing = null
@@ -284,7 +294,7 @@ router.post('/', requireAuth, async (req, res) => {
const updated = await saveTierList({ const updated = await saveTierList({
id: existing.id, id: existing.id,
authorId: existing.authorId, authorId: existing.authorId,
gameId: existing.gameId, topicId: existing.topicId,
title: payload.title, title: payload.title,
thumbnailSrc: payload.thumbnailSrc || '', thumbnailSrc: payload.thumbnailSrc || '',
description: payload.description || '', description: payload.description || '',
@@ -303,7 +313,7 @@ router.post('/', requireAuth, async (req, res) => {
const created = await saveTierList({ const created = await saveTierList({
id: nanoid(), id: nanoid(),
authorId: req.session.userId, authorId: req.session.userId,
gameId: payload.gameId, topicId,
title: payload.title, title: payload.title,
thumbnailSrc: payload.thumbnailSrc || '', thumbnailSrc: payload.thumbnailSrc || '',
description: payload.description || '', description: payload.description || '',

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,94 @@
# 의사결정 이력 # 의사결정 이력
## 2026-04-02 v1.4.33
- 서비스 공개 전 단계에서는 가입 자체를 열어두는 것보다, 이메일/닉네임 중복과 운영자 사칭성 닉네임을 먼저 막아두는 편이 훨씬 중요하다고 판단했다.
- 닉네임 제한은 회원가입 한 곳에만 두면 이후 프로필 수정이나 관리자 수정으로 쉽게 우회되므로, auth/profile/admin 수정 흐름 전부가 같은 예약어 정책을 공유하도록 정리했다.
- 라이트 모드는 취향상 필요한 사용자가 있을 수 있으므로 완전히 제거하기보다, 기본값만 다크로 고정하고 설정 화면에서만 직접 토글하도록 두는 편이 더 균형 잡힌 선택이라고 정리했다.
## 2026-04-02 v1.4.32
- 서비스 공개 전 마감 단계에서는 사용자 노출 텍스트만이 아니라 파일명·composable 이름·관리자 CSS 클래스·백엔드 헬퍼 함수명까지 같이 정리해 두는 편이 이후 유지보수 비용을 확실히 낮춘다고 판단했다.
- 이 시점부터는 `game`이 데이터 호환층도 아닌 단순 내부 이름으로 남아 있는 것조차 혼란을 만들 수 있으므로, 실제 기능을 바꾸지 않는 선에서 이름층까지 끝까지 정리해 코드 검색 결과 자체를 깨끗하게 만드는 방향으로 마감했다.
## 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 ## 2026-04-02 v1.4.10
- 사용자 주소는 이미 `/topics`로 옮기기 시작했으므로, 라우트 이름과 기본 파라미터도 `topicHub / topicId` 기준으로 맞추고 기존 `gameId`는 호환 fallback으로만 남기는 편이 더 자연스럽다고 판단했다. - 사용자 주소는 이미 `/topics`로 옮기기 시작했으므로, 라우트 이름과 기본 파라미터도 `topicHub / topicId` 기준으로 맞추고 기존 `gameId`는 호환 fallback으로만 남기는 편이 더 자연스럽다고 판단했다.

View File

@@ -2,18 +2,18 @@
## `/` ## `/`
- 화면 파일: `frontend/src/views/HomeView.vue` - 화면 파일: `frontend/src/views/HomeView.vue`
- 역할: 데스크톱 기본 4열 게임 카드 라이브러리 대시보드, 상단 메인 썸네일과 `게임명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입 - 역할: 데스크톱 기본 4열 주제 카드 라이브러리 대시보드, 상단 메인 썸네일과 `주제명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 주제 카드 클릭 이동, `직접 티어표 만들기` 진입
- 연동 API: `GET /api/games` - 연동 API: `GET /api/topics`
## `/games/:gameId` ## `/topics/:topicId`
- 화면 파일: `frontend/src/views/GameHubView.vue` - 화면 파일: `frontend/src/views/GameHubView.vue`
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입 - 역할: 선택한 주제 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite` - 연동 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` - 화면 파일: `frontend/src/views/TierEditorView.vue`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청 - 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, 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` ## `/login`
- 화면 파일: `frontend/src/views/LoginView.vue` - 화면 파일: `frontend/src/views/LoginView.vue`
@@ -37,8 +37,8 @@
## `/admin` ## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue` - 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제 - 역할: 공통 우측 패널 대신 전용 `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` - 연동 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` ## `/profile`
- 화면 파일: `frontend/src/views/ProfileView.vue` - 화면 파일: `frontend/src/views/ProfileView.vue`
@@ -56,6 +56,6 @@
- 로컬 DB 실행 설정: `docker-compose.yml` - 로컬 DB 실행 설정: `docker-compose.yml`
- 로컬 MariaDB 가이드: `docs/local-mariadb.md` - 로컬 MariaDB 가이드: `docs/local-mariadb.md`
- 인증 라우트: `backend/src/routes/auth.js` - 인증 라우트: `backend/src/routes/auth.js`
- 게임 라우트: `backend/src/routes/games.js` - 주제 라우트: `backend/src/routes/topics.js`
- 티어표 라우트: `backend/src/routes/tierlists.js` - 티어표 라우트: `backend/src/routes/tierlists.js`
- 관리자 라우트: `backend/src/routes/admin.js` - 관리자 라우트: `backend/src/routes/admin.js`

View File

@@ -114,12 +114,12 @@
- `GET /api/auth/me` - `GET /api/auth/me`
- `GET /api/auth/meta` - `GET /api/auth/meta`
- `POST /api/auth/profile` - `POST /api/auth/profile`
- 게임 - 주제
- `GET /api/games` - `GET /api/topics`
- `GET /api/games/:gameId` - `GET /api/topics/:topicId`
- 티어표 - 티어표
- `GET /api/tierlists/public` - `GET /api/tierlists/public`
- `gameId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다. - `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
- `GET /api/tierlists/me` - `GET /api/tierlists/me`
- `GET /api/tierlists/favorites/me` - `GET /api/tierlists/favorites/me`
- `GET /api/tierlists/:id` - `GET /api/tierlists/:id`
@@ -131,17 +131,18 @@
- `POST /api/tierlists/custom-items` - `POST /api/tierlists/custom-items`
- `POST /api/tierlists` - `POST /api/tierlists`
- 관리자 - 관리자
- `POST /api/admin/games` - `POST /api/admin/templates`
- `POST /api/admin/games/:gameId/thumbnail` - `POST /api/admin/templates/:templateId/thumbnail`
- `POST /api/admin/games/:gameId/images` - `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/tierlists`
- `GET /api/admin/template-requests` - `GET /api/admin/template-requests`
- `POST /api/admin/template-requests/:requestId/approve` - `POST /api/admin/template-requests/:requestId/approve`
- `POST /api/admin/template-requests/:requestId/reject` - `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/promote-items`
- `POST /api/admin/tierlists/:tierListId/create-game-template` - `POST /api/admin/tierlists/:tierListId/create-template`
- `GET /api/admin/custom-items` - `GET /api/admin/custom-items`
- `POST /api/admin/custom-items/:itemId/promote` - `POST /api/admin/custom-items/:itemId/promote`
- `DELETE /api/admin/custom-items/:itemId` - `DELETE /api/admin/custom-items/:itemId`
@@ -150,8 +151,8 @@
- `PATCH /api/admin/users/:userId` - `PATCH /api/admin/users/:userId`
- `PATCH /api/admin/users/:userId/password` - `PATCH /api/admin/users/:userId/password`
- `DELETE /api/admin/users/:userId` - `DELETE /api/admin/users/:userId`
- `DELETE /api/admin/games/:gameId/items/:itemId` - `DELETE /api/admin/templates/:templateId/items/:itemId`
- `DELETE /api/admin/games/:gameId` - `DELETE /api/admin/templates/:templateId`
## 관리자 화면 메모 ## 관리자 화면 메모
- 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다. - 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다.

View File

@@ -1,8 +1,54 @@
# 할 일 및 이슈 # 할 일 및 이슈
## 단기 확인 ## 단기 확인
- `v1.4.33`에서 회원가입에 닉네임 입력과 중복/예약어 검사를 붙였으므로, 실제 QA에서는 이메일 중복, 닉네임 중복, 예약 닉네임, 프로필 닉네임 변경, 관리자 회원 수정 흐름이 같은 규칙으로 막히는지 확인한다.
- 테마는 저장값이 없을 때 무조건 다크로 시작하게 바꿨고 설정 화면 토글도 다시 열었으므로, 첫 접속/새 브라우저/다른 운영체제에서 기본 다크 시작과 수동 토글 저장이 그대로 정상인지 확인한다.
- 관리자 템플릿 썸네일 드롭존 빈 상태 아이콘 제거와 아이템 상세 모달 썸네일 프리뷰가 들어갔으므로, 관리자 화면에서 썸네일 교체와 아이템 선택 모달 가독성을 한 번 더 QA한다.
- `v1.4.32`에서 파일명·composable·관리자 클래스명·백엔드 헬퍼 함수명까지 `topic/template` 기준으로 끝까지 정리했으므로, 다음 실제 QA는 기능 동작 확인에 집중하고 이름층 회귀는 별도 체크만 하면 된다.
- 현재 `backend/src`, `frontend/src`, `backend/scripts`, `backend/index.js` 기준 `game/Game` 검색은 0건이므로, 이후 남는 확인 작업은 서비스 동작과 배포 환경 쪽에만 집중한다.
- `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한다. - `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다.
- 다음 단계에서는 `api.getGame`, `listGames`, `favoriteGame`처럼 남아 있는 프런트 API 이름을 어느 수준까지 `topic/template` 의미로 감쌀지 정리한다.
- 경로 헬퍼를 사용자 주요 화면에 연결했으므로, 로그인 리다이렉트·공유 프리뷰·복사본 이동·주제 복귀 흐름이 실제 브라우저에서 모두 같은 주소 체계로 자연스럽게 이어지는지 한 번 더 QA한다. - 경로 헬퍼를 사용자 주요 화면에 연결했으므로, 로그인 리다이렉트·공유 프리뷰·복사본 이동·주제 복귀 흐름이 실제 브라우저에서 모두 같은 주소 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
- 다음 단계에서는 `router/index.js``gameHub`, `route.params.gameId`, `api.getGame`처럼 남아 있는 프런트 내부 이름도 어디까지 `topic/template` 의미로 감쌀지 범위를 더 좁힌다. - 다음 단계에서는 `router/index.js``gameHub`, `route.params.gameId`, `api.getGame`처럼 남아 있는 프런트 내부 이름도 어디까지 `topic/template` 의미로 감쌀지 범위를 더 좁힌다.
- 주제 상세 화면 제목은 ID fallback 대신 로딩 문구를 쓰도록 바꿨으므로, 직접 진입·뒤로가기·다른 주제로 연속 이동할 때 헤더가 깜빡이거나 이전 제목을 잠깐 유지하지 않는지 한 번 더 QA한다. - 주제 상세 화면 제목은 ID fallback 대신 로딩 문구를 쓰도록 바꿨으므로, 직접 진입·뒤로가기·다른 주제로 연속 이동할 때 헤더가 깜빡이거나 이전 제목을 잠깐 유지하지 않는지 한 번 더 QA한다.

View File

@@ -1,5 +1,111 @@
# 업데이트 로그 # 업데이트 로그
## 2026-04-02 v1.4.33
- 회원가입 시 닉네임 입력을 함께 받도록 바꾸고, 이메일 중복과 닉네임 중복을 서버에서 명확히 차단하도록 정리했다.
- `admin`, `운영자`, `관리자`, `official`, `zenn`처럼 운영자·공식 계정으로 오해될 수 있는 닉네임은 예약어로 막고, 프로필 수정/관리자 회원 수정에서도 같은 규칙을 공유하도록 맞췄다.
- 로그인·회원가입 화면은 중복된 이메일/닉네임일 때 빨간색 오류 메시지를 바로 보여주도록 보강했고, 테마는 저장값이 없을 때 무조건 다크로 시작하면서 설정 화면에서만 라이트/다크 토글을 다시 노출하도록 정리했다.
- 관리자 템플릿 썸네일 드롭존의 빈 상태 아이콘은 제거했고, 아이템 상세 모달에는 선택한 썸네일 프리뷰를 추가해 현재 선택한 이미지가 더 잘 보이게 했다.
## 2026-04-02 v1.4.32
- 파일명과 내부 심볼 이름까지 `topic/template` 기준으로 마감했다. `GameHubView``TopicHubView`, `AdminGamesSection``AdminTemplatesSection`, `useAdminGameManager``useAdminFeaturedGames`는 각각 `useAdminTemplateManager`, `useAdminFeaturedTemplates`로 정리했다.
- 관리자 화면 내부 상태와 스타일 클래스도 `adminTemplatePicker`, `templateManagerGrid`, `templateSettingsCard` 기준으로 바꿔, 사용자에게는 안 보이지만 코드 검색에서 남던 `Game` 흔적을 더 걷어냈다.
- 백엔드도 `copyUploadIntoTopicAsset`, `mapTopicRow`, `mapTopicItemRow`처럼 내부 함수명을 맞추고, 업로드 디렉터리/정리 스크립트도 `topics` 기준으로 통일해 `backend/src`, `frontend/src`, `backend/scripts`, `backend/index.js` 범위의 `game/Game` 검색 결과를 0건으로 정리했다.
## 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 ## 2026-04-02 v1.4.10
- 주제 상세 라우트 이름을 `topicHub`로, 기본 경로 파라미터를 `topicId`로 바꾸고 기존 `gameId` 주소는 alias로 유지했다. - 주제 상세 라우트 이름을 `topicHub`로, 기본 경로 파라미터를 `topicId`로 바꾸고 기존 `gameId` 주소는 alias로 유지했다.
- 앱 셸, 주제 상세, 티어표 편집기는 이제 내부에서 `topicId`를 우선 읽고, 레거시 주소로 들어온 경우에만 `gameId` fallback을 쓰도록 정리했다. - 앱 셸, 주제 상세, 티어표 편집기는 이제 내부에서 `topicId`를 우선 읽고, 레거시 주소로 들어온 경우에만 `gameId` fallback을 쓰도록 정리했다.

View File

@@ -23,7 +23,7 @@ const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const { toasts, dismissToast } = useToast() const { toasts, dismissToast } = useToast()
const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito' const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
const currentTopicId = computed(() => route.params.topicId || route.params.gameId || '') const currentTopicId = computed(() => route.params.topicId || '')
const leftRailCollapsed = ref(false) const leftRailCollapsed = ref(false)
const rightRailOpen = ref(true) const rightRailOpen = ref(true)
@@ -34,6 +34,8 @@ const isGuideModalOpen = ref(false)
const themeMode = ref('dark') const themeMode = ref('dark')
const guideStepIndex = ref(0) const guideStepIndex = ref(0)
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440) const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
const backendState = ref('online')
const backendMessage = ref('')
provide('rightRailOpen', rightRailOpen) provide('rightRailOpen', rightRailOpen)
provide('localRightRailTarget', '#local-right-rail-root') provide('localRightRailTarget', '#local-right-rail-root')
@@ -74,7 +76,7 @@ const showRightRailAction = computed(() => false)
const showSettingsGuideButton = computed(() => route.name === 'profile') const showSettingsGuideButton = computed(() => route.name === 'profile')
const guideSteps = [ const guideSteps = [
{ {
id: 'select-game', id: 'select-topic',
title: '주제 또는 양식 선택', title: '주제 또는 양식 선택',
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.', summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
description: description:
@@ -135,9 +137,10 @@ const isGuidePrevDisabled = computed(() => guideStepIndex.value <= 0)
const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.length - 1) const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.length - 1)
const isLightTheme = computed(() => themeMode.value === 'light') const isLightTheme = computed(() => themeMode.value === 'light')
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드')) const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
const showSettingsThemePanel = computed(() => false && route.name === 'profile') const showSettingsThemePanel = computed(() => route.name === 'profile')
const showTopicViewToggle = computed(() => route.name === 'topicHub') const showTopicViewToggle = computed(() => route.name === 'topicHub')
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid')) const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
const leftBottomPrimaryAction = computed(() => { const leftBottomPrimaryAction = computed(() => {
if (!authReady.value) return null if (!authReady.value) return null
if (route.name === 'home' && auth.user) { if (route.name === 'home' && auth.user) {
@@ -251,6 +254,13 @@ function syncViewportWidth() {
viewportWidth.value = window.innerWidth 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) { function applyTheme(mode) {
themeMode.value = mode === 'light' ? 'light' : 'dark' themeMode.value = mode === 'light' ? 'light' : 'dark'
if (typeof document !== 'undefined') document.documentElement.dataset.theme = themeMode.value if (typeof document !== 'undefined') document.documentElement.dataset.theme = themeMode.value
@@ -265,11 +275,12 @@ onMounted(async () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const savedTheme = window.localStorage.getItem('tier-maker:theme') const savedTheme = window.localStorage.getItem('tier-maker:theme')
if (savedTheme === 'light' || savedTheme === 'dark') applyTheme(savedTheme) if (savedTheme === 'light' || savedTheme === 'dark') applyTheme(savedTheme)
else applyTheme(window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark') else applyTheme('dark')
} }
await auth.refresh() await auth.refresh()
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
syncViewportWidth() syncViewportWidth()
window.addEventListener('tier-maker:backend-status', handleBackendStatus)
window.addEventListener('resize', syncViewportWidth) window.addEventListener('resize', syncViewportWidth)
window.addEventListener('keydown', handleGlobalKeydown) window.addEventListener('keydown', handleGlobalKeydown)
const leftSaved = window.localStorage.getItem('tier-maker:left-rail-collapsed') const leftSaved = window.localStorage.getItem('tier-maker:left-rail-collapsed')
@@ -292,6 +303,7 @@ function handleGlobalKeydown(event) {
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.removeEventListener('tier-maker:backend-status', handleBackendStatus)
window.removeEventListener('resize', syncViewportWidth) window.removeEventListener('resize', syncViewportWidth)
window.removeEventListener('keydown', handleGlobalKeydown) window.removeEventListener('keydown', handleGlobalKeydown)
} }
@@ -400,6 +412,11 @@ function submitGlobalSearch() {
router.push(homePath(query)) router.push(homePath(query))
} }
function reloadApp() {
if (typeof window === 'undefined') return
window.location.reload()
}
</script> </script>
@@ -414,7 +431,26 @@ function submitGlobalSearch() {
}" }"
:style="shellStyle" :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"> <main class="appMain appMain--preview">
<RouterView /> <RouterView />
</main> </main>
@@ -660,6 +696,65 @@ function submitGlobalSearch() {
transition: grid-template-columns 220ms ease; 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 { .appShell--preview {
display: block; display: block;
} }

View File

@@ -9,12 +9,12 @@ const props = defineProps({
stagedRequestDraftCount: { type: Number, required: true }, stagedRequestDraftCount: { type: Number, required: true },
appliedRequestItemCount: { type: Number, required: true }, appliedRequestItemCount: { type: Number, required: true },
openTemplateCreateModal: { type: Function, required: true }, openTemplateCreateModal: { type: Function, required: true },
isGameLoading: { type: Boolean, required: true }, isTemplateLoading: { type: Boolean, required: true },
hasSelectedTemplate: { type: Boolean, required: true }, hasSelectedTemplate: { type: Boolean, required: true },
selectedTemplate: { type: Object, default: null }, selectedTemplate: { type: Object, default: null },
displayThumbnailUrl: { type: String, default: '' }, displayThumbnailUrl: { type: String, default: '' },
canApplyThumbnail: { type: Boolean, required: true }, canApplyThumbnail: { type: Boolean, required: true },
gameVisibilitySaving: { type: Boolean, required: true }, templateVisibilitySaving: { type: Boolean, required: true },
thumbFileInputRef: { type: Function, required: true }, thumbFileInputRef: { type: Function, required: true },
openThumbFilePicker: { type: Function, required: true }, openThumbFilePicker: { type: Function, required: true },
onThumb: { type: Function, required: true }, onThumb: { type: Function, required: true },
@@ -41,14 +41,14 @@ const props = defineProps({
removeUploadDraft: { type: Function, required: true }, removeUploadDraft: { type: Function, required: true },
hasTemplateItemOrderChanges: { type: Boolean, required: true }, hasTemplateItemOrderChanges: { type: Boolean, required: true },
saveTemplateItemOrder: { type: Function, required: true }, saveTemplateItemOrder: { type: Function, required: true },
gameItemListRef: { type: Function, required: true }, templateItemListRef: { type: Function, required: true },
saveTemplateItemLabel: { type: Function, required: true }, saveTemplateItemLabel: { type: Function, required: true },
removeTemplateItem: { type: Function, required: true }, removeTemplateItem: { type: Function, required: true },
selectedTemplateId: { type: String, default: '' }, selectedTemplateId: { type: String, default: '' },
}) })
function setGameItemListElement(el) { function setTemplateItemListElement(el) {
props.gameItemListRef(el) props.templateItemListRef(el)
} }
function setThumbFileElement(el) { function setThumbFileElement(el) {
@@ -65,7 +65,7 @@ function setThumbFileElement(el) {
<div class="hint hint--tight"> <div class="hint hint--tight">
{{ {{
props.activeTemplateRequest.type === 'create' props.activeTemplateRequest.type === 'create'
? (props.activeTemplateRequest.targetGameId ? (props.activeTemplateRequest.targetTopicId
? '이미 연결된 신규 템플릿이 있어요. 필요한 아이템만 골라 저장하세요.' ? '이미 연결된 신규 템플릿이 있어요. 필요한 아이템만 골라 저장하세요.'
: '새 템플릿을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.') : '새 템플릿을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.')
: '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.' : '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.'
@@ -76,8 +76,8 @@ function setThumbFileElement(el) {
<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 class="pill">요청 아이템 {{ props.stagedRequestDraftCount }}</span>
<span v-if="props.appliedRequestItemCount" class="pill pill--soft">이미 반영 {{ props.appliedRequestItemCount }}</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"> <span v-if="props.activeTemplateRequest.type === 'create' && (props.activeTemplateRequest.targetTopicName || props.activeTemplateRequest.targetTopicId)" class="pill pill--soft">
연결된 템플릿 · {{ props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId }} 연결된 템플릿 · {{ props.activeTemplateRequest.targetTopicName || props.activeTemplateRequest.targetTopicId }}
</span> </span>
</div> </div>
</div> </div>
@@ -92,7 +92,7 @@ function setThumbFileElement(el) {
요청 티어표 보기 요청 티어표 보기
</a> </a>
<button <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" class="btn btn--ghost btn--small"
type="button" type="button"
@click="props.openTemplateCreateModal" @click="props.openTemplateCreateModal"
@@ -102,15 +102,15 @@ function setThumbFileElement(el) {
</div> </div>
</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">
<div class="emptyState__title">템플릿 정보를 불러오는 중이에요.</div> <div class="emptyState__title">템플릿 정보를 불러오는 중이에요.</div>
<div class="emptyState__desc">선택한 템플릿의 썸네일과 기본 아이템을 표시합니다.</div> <div class="emptyState__desc">선택한 템플릿의 썸네일과 기본 아이템을 표시합니다.</div>
</div> </div>
</div> </div>
<div v-else-if="props.hasSelectedTemplate" class="panel"> <div v-else-if="props.hasSelectedTemplate" class="panel">
<section class="adminCard gameSettingsCard"> <section class="adminCard templateSettingsCard">
<div class="gameSettingsCard__media"> <div class="templateSettingsCard__media">
<input :ref="setThumbFileElement" type="file" accept="image/*" class="srOnlyInput" @change="props.onThumb" /> <input :ref="setThumbFileElement" type="file" accept="image/*" class="srOnlyInput" @change="props.onThumb" />
<button <button
class="thumbDropZone" class="thumbDropZone"
@@ -122,25 +122,22 @@ function setThumbFileElement(el) {
@dragleave="props.onThumbDragLeave" @dragleave="props.onThumbDragLeave"
@drop="props.onThumbDrop" @drop="props.onThumbDrop"
> >
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.game.name" /> <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 v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
<div class="thumbDropZone__copy"> <div class="thumbDropZone__copy">
<div class="thumbDropZone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" />
</div>
<div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div> <div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
</div> </div>
</button> </button>
</div> </div>
<div class="gameSettingsCard__body"> <div class="templateSettingsCard__body">
<div class="panel__title">템플릿 설정</div> <div class="panel__title">템플릿 설정</div>
<div class="gameSettingsCard__meta">{{ props.selectedTemplate.game.name }} · {{ props.selectedTemplate.game.id }}</div> <div class="templateSettingsCard__meta">{{ props.selectedTemplate.template.name }} · {{ props.selectedTemplate.template.id }}</div>
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.gameVisibilitySaving }"> <label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.templateVisibilitySaving }">
<input :checked="!!props.selectedTemplate.game.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" /> <input :checked="!!props.selectedTemplate.template.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" />
<span class="toggleSwitch__label">{{ props.selectedTemplate.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span> <span class="toggleSwitch__label">{{ props.selectedTemplate.template.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span> <span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label> </label>
<div class="gameSettingsCard__actions"> <div class="templateSettingsCard__actions">
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button> <button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
<button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button> <button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button>
</div> </div>
@@ -215,9 +212,9 @@ function setThumbFileElement(el) {
<button class="btn btn--primary btn--small" :disabled="!props.hasTemplateItemOrderChanges" @click="props.saveTemplateItemOrder">순서 저장</button> <button class="btn btn--primary btn--small" :disabled="!props.hasTemplateItemOrderChanges" @click="props.saveTemplateItemOrder">순서 저장</button>
</div> </div>
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div> <div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
<div v-else :ref="setGameItemListElement" class="thumbGrid"> <div v-else :ref="setTemplateItemListElement" class="thumbGrid">
<div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-game-item-id="item.id"> <div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-template-item-id="item.id">
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" /> <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 /> <input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
<div class="thumbCard__actions"> <div class="thumbCard__actions">
<button <button

View File

@@ -45,7 +45,6 @@ const props = defineProps({
:target="props.templateRequestSourceUrl(request) ? '_blank' : undefined" :target="props.templateRequestSourceUrl(request) ? '_blank' : undefined"
:rel="props.templateRequestSourceUrl(request) ? 'noreferrer' : undefined" :rel="props.templateRequestSourceUrl(request) ? 'noreferrer' : undefined"
:aria-disabled="!props.templateRequestSourceUrl(request)" :aria-disabled="!props.templateRequestSourceUrl(request)"
@click.prevent="props.templateRequestSourceUrl(request) && window.open(props.templateRequestSourceUrl(request), '_blank', 'noopener,noreferrer')"
> >
<img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" draggable="false" /> <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> <div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
@@ -54,18 +53,18 @@ const props = defineProps({
<template v-if="request.type === 'create'"> <template v-if="request.type === 'create'">
<label class="templateRequestField"> <label class="templateRequestField">
<span class="templateRequestField__label">템플릿 이름</span> <span class="templateRequestField__label">템플릿 이름</span>
<input v-model="request.draftGameName" class="input" placeholder="새 템플릿 이름" /> <input v-model="request.draftTopicName" class="input" placeholder="새 템플릿 이름" />
</label> </label>
<label class="templateRequestField"> <label class="templateRequestField">
<span class="templateRequestField__label">템플릿 ID</span> <span class="templateRequestField__label">템플릿 ID</span>
<input v-model="request.draftGameId" class="input" placeholder="임시 템플릿 ID" /> <input v-model="request.draftTopicId" class="input" placeholder="임시 템플릿 ID" />
</label> </label>
</template> </template>
<template v-else> <template v-else>
<div class="templateRequestCard__thumbLabel">템플릿 이름</div> <div class="templateRequestCard__thumbLabel">템플릿 이름</div>
<div class="templateRequestCard__thumbValue">{{ request.draftGameName || request.sourceGameName || '-' }}</div> <div class="templateRequestCard__thumbValue">{{ request.draftTopicName || request.sourceTopicName || '-' }}</div>
<div class="templateRequestCard__thumbLabel">템플릿 ID</div> <div class="templateRequestCard__thumbLabel">템플릿 ID</div>
<div class="templateRequestCard__thumbValue">{{ request.draftGameId || request.sourceGameId || '-' }}</div> <div class="templateRequestCard__thumbValue">{{ request.draftTopicId || request.sourceTopicId || '-' }}</div>
</template> </template>
</div> </div>
</div> </div>
@@ -90,8 +89,8 @@ const props = defineProps({
<div class="tierAdminCard__stats"> <div class="tierAdminCard__stats">
<span class="pill">추가 아이템 {{ request.items?.length || 0 }}</span> <span class="pill">추가 아이템 {{ request.items?.length || 0 }}</span>
<span v-if="request.type === 'create' && (request.targetGameName || request.targetGameId)" class="pill pill--soft"> <span v-if="request.type === 'create' && (request.targetTopicName || request.targetTopicId)" class="pill pill--soft">
연결됨 · {{ request.targetGameName || request.targetGameId }} 연결됨 · {{ request.targetTopicName || request.targetTopicId }}
</span> </span>
<span class="pill" :class="{ 'pill--accent': request.status === 'reviewing' }">{{ props.templateRequestStatusLabel(request) }}</span> <span class="pill" :class="{ 'pill--accent': request.status === 'reviewing' }">{{ props.templateRequestStatusLabel(request) }}</span>
</div> </div>
@@ -110,7 +109,7 @@ const props = defineProps({
{{ {{
request.isHandling request.isHandling
? '이동중...' ? '이동중...'
: request.type === 'create' && (request.targetGameName || request.targetGameId) : request.type === 'create' && (request.targetTopicName || request.targetTopicId)
? '연결된 템플릿 열기' ? '연결된 템플릿 열기'
: '확인하기' : '확인하기'
}} }}
@@ -149,7 +148,7 @@ const props = defineProps({
<div class="tierAdminCard__title">{{ tierList.title }}</div> <div class="tierAdminCard__title">{{ tierList.title }}</div>
<div v-if="tierList.description" class="tierAdminCard__desc">{{ tierList.description }}</div> <div v-if="tierList.description" class="tierAdminCard__desc">{{ tierList.description }}</div>
<div class="tierAdminCard__meta"> <div class="tierAdminCard__meta">
{{ tierList.gameName || tierList.gameId }} · {{ props.tierListAuthorDisplayName(tierList) }} {{ tierList.topicName || tierList.topicId }} · {{ props.tierListAuthorDisplayName(tierList) }}
</div> </div>
<div class="tierAdminCard__meta">{{ props.fmt(tierList.updatedAt) }}</div> <div class="tierAdminCard__meta">{{ props.fmt(tierList.updatedAt) }}</div>
</div> </div>
@@ -171,7 +170,7 @@ const props = defineProps({
</div> </div>
<div class="tierAdminSection__actions"> <div class="tierAdminSection__actions">
<button class="btn btn--ghost btn--small" @click="props.openTierListImportModal(tierList, tierList.extraItems)">추가 아이템 전체 가져오기</button> <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> </button>
</div> </div>

View File

@@ -85,7 +85,7 @@ export function useAdminCustomItems({
function openCustomItemDeleteModal(item) { function openCustomItemDeleteModal(item) {
if (!item) return 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 = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.' error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return return
} }
@@ -100,7 +100,7 @@ export function useAdminCustomItems({
function jumpToTemplateAdmin(templateId) { function jumpToTemplateAdmin(templateId) {
if (!templateId) return if (!templateId) return
closeCustomItemModal() closeCustomItemModal()
setTab('game-admin') setTab('template-admin')
nextTick(() => { nextTick(() => {
selectAdminTemplate(templateId) selectAdminTemplate(templateId)
}) })
@@ -109,7 +109,7 @@ export function useAdminCustomItems({
async function removeCustomItem(item = modalTargetCustomItem.value) { async function removeCustomItem(item = modalTargetCustomItem.value) {
resetMessages() resetMessages()
if (!item) return 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 = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.' error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return return
} }
@@ -167,7 +167,7 @@ export function useAdminCustomItems({
try { try {
item.isPromoting = true item.isPromoting = true
await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetTemplateId.value }) await api.promoteAdminTemplateItem(item.id, { topicId: customItemModalTargetTemplateId.value })
const targetTemplateName = const targetTemplateName =
templates.value.find((template) => template.id === customItemModalTargetTemplateId.value)?.name || customItemModalTargetTemplateId.value templates.value.find((template) => template.id === customItemModalTargetTemplateId.value)?.name || customItemModalTargetTemplateId.value
if (selectedTemplateId.value === customItemModalTargetTemplateId.value) await loadTemplate() if (selectedTemplateId.value === customItemModalTargetTemplateId.value) await loadTemplate()

View File

@@ -1,7 +1,7 @@
import { nextTick } from 'vue' import { nextTick } from 'vue'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
export function useAdminFeaturedGames({ export function useAdminFeaturedTemplates({
api, api,
featuredListEl, featuredListEl,
featuredSortable, featuredSortable,
@@ -70,8 +70,8 @@ export function useAdminFeaturedGames({
async function saveFeaturedOrder() { async function saveFeaturedOrder() {
resetMessages() resetMessages()
try { try {
const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredTemplateIds.value }) const data = await api.updateAdminTemplateDisplayOrder({ topicIds: featuredTemplateIds.value })
templates.value = data.games || [] templates.value = data.templates || []
featuredTemplateIds.value = templates.value featuredTemplateIds.value = templates.value
.filter((template) => template.displayRank != null) .filter((template) => template.displayRank != null)
.sort((a, b) => a.displayRank - b.displayRank) .sort((a, b) => a.displayRank - b.displayRank)

View File

@@ -1,7 +1,7 @@
import { nextTick } from 'vue' import { nextTick } from 'vue'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
export function useAdminGameManager({ export function useAdminTemplateManager({
api, api,
toApiUrl, toApiUrl,
selectedTemplateId, selectedTemplateId,
@@ -11,10 +11,10 @@ export function useAdminGameManager({
thumbFile, thumbFile,
itemPreviewUrls, itemPreviewUrls,
itemFileInput, itemFileInput,
gameItemListEl, templateItemListEl,
gameItemSortable, templateItemSortable,
savedGameItemOrderIds, savedTemplateItemOrderIds,
isGameLoading, isTemplateLoading,
activeTemplateRequest, activeTemplateRequest,
templateRequests, templateRequests,
customItemModalOpen, customItemModalOpen,
@@ -49,21 +49,21 @@ export function useAdminGameManager({
return src.split('/').pop() || item.file?.name || 'item' return src.split('/').pop() || item.file?.name || 'item'
} }
function destroyGameItemSortable() { function destroyTemplateItemSortable() {
if (gameItemSortable.value) { if (templateItemSortable.value) {
gameItemSortable.value.destroy() templateItemSortable.value.destroy()
gameItemSortable.value = null templateItemSortable.value = null
} }
} }
async function syncGameItemSortable() { async function syncTemplateItemSortable() {
await nextTick() await nextTick()
destroyGameItemSortable() destroyTemplateItemSortable()
if (!gameItemListEl.value || !selectedTemplate.value?.items?.length) return if (!templateItemListEl.value || !selectedTemplate.value?.items?.length) return
gameItemSortable.value = Sortable.create(gameItemListEl.value, { templateItemSortable.value = Sortable.create(templateItemListEl.value, {
animation: 160, animation: 160,
draggable: '[data-game-item-id]', draggable: '[data-template-item-id]',
forceFallback: true, forceFallback: true,
fallbackOnBody: false, fallbackOnBody: false,
filter: '[data-no-drag]', filter: '[data-no-drag]',
@@ -124,44 +124,46 @@ export function useAdminGameManager({
if (!selectedTemplateId.value) { if (!selectedTemplateId.value) {
selectedTemplate.value = null selectedTemplate.value = null
savedGameItemOrderIds.value = [] savedTemplateItemOrderIds.value = []
destroyGameItemSortable() destroyTemplateItemSortable()
return return
} }
try { try {
isGameLoading.value = true isTemplateLoading.value = true
const data = await api.getGame(selectedTemplateId.value) const data = await api.getTopic(selectedTemplateId.value)
const loadedTemplate = data.template || data.topic || null
selectedTemplate.value = { selectedTemplate.value = {
...data, ...data,
template: loadedTemplate,
items: (data.items || []).map((item) => ({ items: (data.items || []).map((item) => ({
...item, ...item,
draftLabel: item.label, draftLabel: item.label,
})), })),
} }
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id) savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncGameItemSortable() await syncTemplateItemSortable()
} catch (e) { } catch (e) {
selectedTemplate.value = null selectedTemplate.value = null
error.value = '템플릿 정보를 불러오지 못했어요.' error.value = '템플릿 정보를 불러오지 못했어요.'
} finally { } finally {
isGameLoading.value = false isTemplateLoading.value = false
} }
} }
async function createTemplate(options = {}) { async function createTemplate(options = {}) {
const nextGameId = typeof options.gameId === 'string' ? options.gameId.trim() : newTemplateId.value.trim() const nextTopicId = typeof options.topicId === 'string' ? options.topicId.trim() : newTemplateId.value.trim()
const nextGameName = typeof options.gameName === 'string' ? options.gameName.trim() : newTemplateName.value.trim() const nextTopicName = typeof options.topicName === 'string' ? options.topicName.trim() : newTemplateName.value.trim()
const preserveUploadState = !!options.preserveUploadState const preserveUploadState = !!options.preserveUploadState
resetMessages() resetMessages()
try { try {
const res = await fetch(toApiUrl('/api/admin/games'), { const res = await fetch(toApiUrl('/api/admin/templates'), {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
id: nextGameId, id: nextTopicId,
name: nextGameName, name: nextTopicName,
isPublic: !!newTemplateIsPublic.value, isPublic: !!newTemplateIsPublic.value,
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '', thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
}), }),
@@ -169,27 +171,28 @@ export function useAdminGameManager({
if (!res.ok) throw new Error('failed') if (!res.ok) throw new Error('failed')
const data = await res.json() const data = await res.json()
const createdTemplate = data.template || {}
if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) { if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) {
const linkData = await api.linkAdminTemplateRequestGame(activeTemplateRequest.value.id, { const linkData = await api.linkAdminTemplateRequestTemplate(activeTemplateRequest.value.id, {
gameId: data.game.id, topicId: createdTemplate.id,
}) })
activeTemplateRequest.value = { activeTemplateRequest.value = {
...activeTemplateRequest.value, ...activeTemplateRequest.value,
targetGameId: linkData.request?.targetGameId || data.game.id, targetTopicId: linkData.request?.targetTopicId || createdTemplate.id,
targetGameName: linkData.request?.targetGameName || data.game.name || nextGameName, targetTopicName: linkData.request?.targetTopicName || createdTemplate.name || nextTopicName,
} }
const requestIndex = templateRequests.value.findIndex((entry) => entry.id === activeTemplateRequest.value.id) const requestIndex = templateRequests.value.findIndex((entry) => entry.id === activeTemplateRequest.value.id)
if (requestIndex >= 0) { if (requestIndex >= 0) {
templateRequests.value.splice(requestIndex, 1, { templateRequests.value.splice(requestIndex, 1, {
...templateRequests.value[requestIndex], ...templateRequests.value[requestIndex],
targetGameId: linkData.request?.targetGameId || data.game.id, targetTopicId: linkData.request?.targetTopicId || createdTemplate.id,
targetGameName: linkData.request?.targetGameName || data.game.name || nextGameName, targetTopicName: linkData.request?.targetTopicName || createdTemplate.name || nextTopicName,
}) })
} }
} }
await refreshTemplates() await refreshTemplates()
selectedTemplateId.value = data.game.id selectedTemplateId.value = createdTemplate.id
if (customItemModalOpen.value) customItemModalTargetTemplateId.value = data.game.id if (customItemModalOpen.value) customItemModalTargetTemplateId.value = createdTemplate.id
closeTemplateCreateModal() closeTemplateCreateModal()
await loadTemplate({ preserveUploadState }) await loadTemplate({ preserveUploadState })
if (!preserveUploadState && activeTemplateRequest.value?.id) { if (!preserveUploadState && activeTemplateRequest.value?.id) {
@@ -254,16 +257,16 @@ export function useAdminGameManager({
} }
try { try {
if (!selectedTemplateId.value && activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetGameId) { if (!selectedTemplateId.value && activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetTopicId) {
const draftGameId = (activeTemplateRequest.value?.draftGameId || '').trim() const draftTopicId = (activeTemplateRequest.value?.draftTopicId || '').trim()
const draftGameName = (activeTemplateRequest.value?.draftGameName || '').trim() const draftTopicName = (activeTemplateRequest.value?.draftTopicName || '').trim()
if (!draftGameId || !draftGameName) { if (!draftTopicId || !draftTopicName) {
error.value = '먼저 신규 템플릿의 이름과 템플릿 ID를 저장해주세요.' error.value = '먼저 신규 템플릿의 이름과 템플릿 ID를 저장해주세요.'
return return
} }
await createTemplate({ await createTemplate({
gameId: draftGameId, topicId: draftTopicId,
gameName: draftGameName, topicName: draftTopicName,
preserveUploadState: true, preserveUploadState: true,
}) })
} }
@@ -283,7 +286,7 @@ export function useAdminGameManager({
fd.append('images', entry.file) fd.append('images', entry.file)
fd.append('labels', entry.label.trim()) fd.append('labels', entry.label.trim())
}) })
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}/images`), { const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/images`), {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: fd, body: fd,
@@ -297,7 +300,7 @@ export function useAdminGameManager({
for (const requestId of requestIds) { for (const requestId of requestIds) {
const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId) const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId)
const result = await api.promoteAdminTemplateRequestItems(requestId, { const result = await api.promoteAdminTemplateRequestItems(requestId, {
gameId: selectedTemplateId.value, topicId: selectedTemplateId.value,
itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean), itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean),
itemSrcs: draftsForRequest.map((entry) => entry.src).filter(Boolean), itemSrcs: draftsForRequest.map((entry) => entry.src).filter(Boolean),
itemLabels: draftsForRequest.reduce((acc, entry) => { itemLabels: draftsForRequest.reduce((acc, entry) => {
@@ -323,7 +326,7 @@ export function useAdminGameManager({
error.value = `요청 아이템을 템플릿 기본 아이템으로 옮기지 못했어요.${detail}` error.value = `요청 아이템을 템플릿 기본 아이템으로 옮기지 못했어요.${detail}`
return return
} }
if (apiError === 'game_not_found') { if (apiError === 'topic_not_found') {
error.value = '선택한 템플릿을 찾지 못했어요.' error.value = '선택한 템플릿을 찾지 못했어요.'
return return
} }
@@ -336,7 +339,7 @@ export function useAdminGameManager({
if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) return if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) return
try { try {
const data = await api.updateAdminGameItemDisplayOrder(selectedTemplateId.value, { const data = await api.updateAdminTemplateItemDisplayOrder(selectedTemplateId.value, {
itemIds: selectedTemplate.value.items.map((item) => item.id), itemIds: selectedTemplate.value.items.map((item) => item.id),
}) })
selectedTemplate.value = { selectedTemplate.value = {
@@ -346,8 +349,8 @@ export function useAdminGameManager({
draftLabel: item.label, draftLabel: item.label,
})), })),
} }
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id) savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncGameItemSortable() await syncTemplateItemSortable()
success.value = '기본 아이템 순서를 저장했어요.' success.value = '기본 아이템 순서를 저장했어요.'
} catch (e) { } catch (e) {
error.value = '기본 아이템 순서 저장에 실패했어요.' error.value = '기본 아이템 순서 저장에 실패했어요.'
@@ -356,8 +359,8 @@ export function useAdminGameManager({
return { return {
requestItemFilename, requestItemFilename,
destroyGameItemSortable, destroyTemplateItemSortable,
syncGameItemSortable, syncTemplateItemSortable,
mergeRequestItemsIntoDrafts, mergeRequestItemsIntoDrafts,
removeUploadDraft, removeUploadDraft,
loadTemplate, loadTemplate,

View File

@@ -21,14 +21,14 @@ export function useAdminTemplateRequests({
type: request.type, type: request.type,
status: request.status, status: request.status,
thumbnailSrc: request.thumbnailSrc || '', thumbnailSrc: request.thumbnailSrc || '',
draftGameId: request.draftGameId || '', draftTopicId: request.draftTopicId || '',
draftGameName: request.draftGameName || '', draftTopicName: request.draftTopicName || '',
draftGameIsPublic: !!request.draftGameIsPublic, draftTopicIsPublic: !!request.draftTopicIsPublic,
sourceTierListId: request.sourceTierListId || '', sourceTierListId: request.sourceTierListId || '',
sourceGameId: request.sourceGameId || '', sourceTopicId: request.sourceTopicId || '',
sourceTierListTitle: request.sourceTierListTitle || '', sourceTierListTitle: request.sourceTierListTitle || '',
targetGameId: request.targetGameId || '', targetTopicId: request.targetTopicId || '',
targetGameName: request.targetGameName || '', targetTopicName: request.targetTopicName || '',
requesterName: request.requesterName || '', requesterName: request.requesterName || '',
} }
} }
@@ -38,8 +38,8 @@ export function useAdminTemplateRequests({
} }
function templateRequestSourceUrl(request) { function templateRequestSourceUrl(request) {
if (!request?.sourceGameId || !request?.sourceTierListId) return '' if (!request?.sourceTopicId || !request?.sourceTierListId) return ''
return editorPath(request.sourceGameId, request.sourceTierListId, { preview: true }) return editorPath(request.sourceTopicId, request.sourceTierListId, { preview: true })
} }
function templateRequestReviewHint(request) { function templateRequestReviewHint(request) {
@@ -55,28 +55,28 @@ export function useAdminTemplateRequests({
const syncedRequest = { const syncedRequest = {
...request, ...request,
...(data.request || {}), ...(data.request || {}),
draftGameId: request.draftGameId || '', draftTopicId: request.draftTopicId || '',
draftGameName: request.draftGameName || '', draftTopicName: request.draftTopicName || '',
draftGameIsPublic: !!request.draftGameIsPublic, draftTopicIsPublic: !!request.draftTopicIsPublic,
} }
Object.assign(request, syncedRequest) Object.assign(request, syncedRequest)
request.status = syncedRequest.status || 'reviewing' request.status = syncedRequest.status || 'reviewing'
updateActiveTemplateRequest(syncedRequest) updateActiveTemplateRequest(syncedRequest)
setTab('game-admin') setTab('template-admin')
if (request.type === 'create') { if (request.type === 'create') {
const linkedGameId = syncedRequest.targetGameId || '' const linkedTopicId = syncedRequest.targetTopicId || ''
if (linkedGameId) { if (linkedTopicId) {
await selectAdminTemplate(linkedGameId) await selectAdminTemplate(linkedTopicId)
} else { } else {
openTemplateCreateModal() openTemplateCreateModal()
newTemplateId.value = (syncedRequest.draftGameId || '').trim() newTemplateId.value = (syncedRequest.draftTopicId || '').trim()
newTemplateName.value = (syncedRequest.draftGameName || '').trim() newTemplateName.value = (syncedRequest.draftTopicName || '').trim()
} }
mergeRequestItemsIntoDrafts(syncedRequest) mergeRequestItemsIntoDrafts(syncedRequest)
} else { } else {
const nextGameId = syncedRequest.targetGameId || syncedRequest.sourceGameId || '' const nextTopicId = syncedRequest.targetTopicId || syncedRequest.sourceTopicId || ''
if (nextGameId) await selectAdminTemplate(nextGameId) if (nextTopicId) await selectAdminTemplate(nextTopicId)
mergeRequestItemsIntoDrafts(syncedRequest) mergeRequestItemsIntoDrafts(syncedRequest)
} }
success.value = '요청 아이템을 템플릿 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.' success.value = '요청 아이템을 템플릿 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'

View File

@@ -1,56 +1,83 @@
import { toApiUrl } from './runtime' 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 } = {}) { async function request(path, { method = 'GET', body, headers } = {}) {
const res = await fetch(toApiUrl(path), { let res
method, try {
credentials: 'include', res = await fetch(toApiUrl(path), {
headers: { method,
...(body ? { 'Content-Type': 'application/json' } : {}), credentials: 'include',
...(headers || {}), headers: {
}, ...(body ? { 'Content-Type': 'application/json' } : {}),
body: body ? JSON.stringify(body) : undefined, ...(headers || {}),
}) },
body: body ? JSON.stringify(body) : undefined,
})
} catch (error) {
emitBackendStatus({
state: 'offline',
message: '서버 연결을 확인할 수 없어 잠시 후 다시 시도해주세요.',
path,
})
throw error
}
const contentType = res.headers.get('content-type') || '' const contentType = res.headers.get('content-type') || ''
const data = contentType.includes('application/json') ? await res.json() : await res.text() const data = contentType.includes('application/json') ? await res.json() : await res.text()
if (!res.ok) { 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') const err = new Error('request_failed')
err.status = res.status err.status = res.status
err.data = data err.data = data
throw err throw err
} }
emitBackendStatus({ state: 'online', path })
return data return data
} }
export const api = { export const api = {
me: () => request('/api/auth/me'), me: () => request('/api/auth/me'),
authMeta: () => request('/api/auth/meta'), authMeta: () => request('/api/auth/meta'),
signup: ({ email, password }) => request('/api/auth/signup', { method: 'POST', body: { email, password } }), signup: ({ email, nickname, password }) => request('/api/auth/signup', { method: 'POST', body: { email, nickname, password } }),
login: ({ email, password }) => request('/api/auth/login', { method: 'POST', body: { email, password } }), login: ({ email, password }) => request('/api/auth/login', { method: 'POST', body: { email, password } }),
logout: () => request('/api/auth/logout', { method: 'POST' }), logout: () => request('/api/auth/logout', { method: 'POST' }),
listGames: () => request('/api/games'), listTopics: () => request('/api/topics'),
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`), getTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}`),
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }), favoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'POST' }),
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }), unfavoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'DELETE' }),
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }), updateAdminTemplateDisplayOrder: (payload) => request('/api/admin/templates/display-order', { method: 'PATCH', body: payload }),
updateAdminGameItemDisplayOrder: (gameId, payload) => updateAdminTemplateItemDisplayOrder: (templateId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { method: 'PATCH', body: payload }), request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/display-order`, { method: 'PATCH', body: payload }),
updateAdminGame: (gameId, payload) => updateAdminTemplate: (templateId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}`, { method: 'PATCH', body: payload }), request(`/api/admin/templates/${encodeURIComponent(templateId)}`, { method: 'PATCH', body: payload }),
updateAdminGameItem: (gameId, itemId, payload) => updateAdminTemplateItem: (templateId, itemId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }), request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) => listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) =>
request( request(
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}` `/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
), ),
listAdminTierLists: ({ q = '', gameId = '', page = 1, limit = 50 } = {}) => listAdminTierLists: ({ q = '', topicId = '', page = 1, limit = 50 } = {}) =>
request( request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
`/api/admin/tierlists?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}` getAdminTierListStats: ({ q = '', topicId = '' } = {}) =>
), request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}`),
getAdminTierListStats: ({ q = '', gameId = '' } = {}) =>
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}`),
updateAdminTierList: (tierListId, payload) => updateAdminTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }), request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
deleteAdminTierList: (tierListId) => deleteAdminTierList: (tierListId) =>
@@ -66,18 +93,18 @@ export const api = {
cleanupAdminMissingImageReferences: () => request('/api/admin/image-assets/missing/cleanup', { method: 'POST', body: {} }), 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)}`), 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 || {} }), 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 }), request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
updateAdminCustomItemLabel: (itemId, payload) => updateAdminCustomItemLabel: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }), request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
promoteAdminTierListItems: (tierListId, payload) => promoteAdminTierListItems: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }), request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
createAdminGameTemplateFromTierList: (tierListId, payload) => createAdminTemplateFromTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }), request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-template`, { method: 'POST', body: payload }),
startAdminTemplateRequestReview: (requestId) => startAdminTemplateRequestReview: (requestId) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/review`, { method: 'POST', body: {} }), request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/review`, { method: 'POST', body: {} }),
linkAdminTemplateRequestGame: (requestId, payload) => linkAdminTemplateRequestTemplate: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/link-game`, { method: 'POST', body: payload }), request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/link-template`, { method: 'POST', body: payload }),
promoteAdminTemplateRequestItems: (requestId, payload) => promoteAdminTemplateRequestItems: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/promote-items`, { method: 'POST', body: payload }), request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/promote-items`, { method: 'POST', body: payload }),
completeAdminTemplateRequest: (requestId) => completeAdminTemplateRequest: (requestId) =>
@@ -111,10 +138,10 @@ export const api = {
}, },
deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }), deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
listPublicTierLists: (gameId) => listPublicTierListsByTopic: (topicId) =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`), request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}`),
searchPublicTierLists: (gameId, q = '') => searchPublicTierListsByTopic: (topicId, q = '') =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`), request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}&q=${encodeURIComponent(q || '')}`),
searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`), searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`),
listMyTierLists: () => request('/api/tierlists/me'), listMyTierLists: () => request('/api/tierlists/me'),
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) => listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>

View File

@@ -1,7 +1,7 @@
import { createRouter as _createRouter, createWebHistory } from 'vue-router' import { createRouter as _createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue' import HomeView from '../views/HomeView.vue'
import GameHubView from '../views/GameHubView.vue' import TopicHubView from '../views/TopicHubView.vue'
import TierEditorView from '../views/TierEditorView.vue' import TierEditorView from '../views/TierEditorView.vue'
import LoginView from '../views/LoginView.vue' import LoginView from '../views/LoginView.vue'
import MyTierListsView from '../views/MyTierListsView.vue' import MyTierListsView from '../views/MyTierListsView.vue'
@@ -16,16 +16,16 @@ export function createRouter() {
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: '/', name: 'home', component: HomeView }, { path: '/', name: 'home', component: HomeView },
{ path: '/topics/:topicId', alias: ['/games/:gameId'], name: 'topicHub', component: GameHubView }, { path: '/topics/:topicId', name: 'topicHub', component: TopicHubView },
{ path: '/editor/:topicId/new', alias: ['/editor/:gameId/new'], name: 'newEditor', component: TierEditorView }, { path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView },
{ path: '/editor/:topicId/:tierListId', alias: ['/editor/:gameId/:tierListId'], name: 'editEditor', component: TierEditorView }, { path: '/editor/:topicId/:tierListId', name: 'editEditor', component: TierEditorView },
{ path: '/login', name: 'login', component: LoginView }, { path: '/login', name: 'login', component: LoginView },
{ path: '/me', name: 'me', component: MyTierListsView }, { path: '/me', name: 'me', component: MyTierListsView },
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView }, { path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
{ path: '/search', name: 'search', component: SearchResultsView }, { path: '/search', name: 'search', component: SearchResultsView },
{ path: '/admin', redirect: '/admin/featured' }, { path: '/admin', redirect: '/admin/featured' },
{ path: '/admin/featured', name: 'adminFeatured', component: AdminView }, { 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/items', name: 'adminItems', component: AdminView },
{ path: '/admin/tierlists', name: 'adminTierlists', component: AdminView }, { path: '/admin/tierlists', name: 'adminTierlists', component: AdminView },
{ path: '/admin/users', name: 'adminUsers', component: AdminView }, { path: '/admin/users', name: 'adminUsers', component: AdminView },

View File

@@ -29,8 +29,8 @@ export const useAuthStore = defineStore('auth', {
})() })()
return refreshPromise return refreshPromise
}, },
async signup(email, password) { async signup(email, nickname, password) {
const user = await api.signup({ email, password }) const user = await api.signup({ email, nickname, password })
this.user = user this.user = user
this.hydrated = true this.hydrated = true
return user return user

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { Teleport, computed, inject, onMounted, onUnmounted, ref, watch } from 'vue' import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api' import { api } from '../lib/api'
import { editorPath } from '../lib/paths' import { editorPath } from '../lib/paths'
@@ -8,13 +8,13 @@ import lockResetIcon from '../assets/icons/lock_reset.svg'
import deleteIcon from '../assets/icons/delete.svg' import deleteIcon from '../assets/icons/delete.svg'
import SvgIcon from '../components/SvgIcon.vue' import SvgIcon from '../components/SvgIcon.vue'
import AdminFeaturedSection from '../components/admin/AdminFeaturedSection.vue' import AdminFeaturedSection from '../components/admin/AdminFeaturedSection.vue'
import AdminGamesSection from '../components/admin/AdminGamesSection.vue' import AdminTemplatesSection from '../components/admin/AdminTemplatesSection.vue'
import AdminItemsSection from '../components/admin/AdminItemsSection.vue' import AdminItemsSection from '../components/admin/AdminItemsSection.vue'
import AdminTierlistsSection from '../components/admin/AdminTierlistsSection.vue' import AdminTierlistsSection from '../components/admin/AdminTierlistsSection.vue'
import AdminUsersSection from '../components/admin/AdminUsersSection.vue' import AdminUsersSection from '../components/admin/AdminUsersSection.vue'
import { useAdminCustomItems } from '../composables/useAdminCustomItems' import { useAdminCustomItems } from '../composables/useAdminCustomItems'
import { useAdminFeaturedGames } from '../composables/useAdminFeaturedGames' import { useAdminFeaturedTemplates } from '../composables/useAdminFeaturedTemplates'
import { useAdminGameManager } from '../composables/useAdminGameManager' import { useAdminTemplateManager } from '../composables/useAdminTemplateManager'
import { useAdminTemplateRequests } from '../composables/useAdminTemplateRequests' import { useAdminTemplateRequests } from '../composables/useAdminTemplateRequests'
import { useAdminUsers } from '../composables/useAdminUsers' import { useAdminUsers } from '../composables/useAdminUsers'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -36,7 +36,7 @@ const selectedTemplateId = ref('')
const selectedTemplate = ref(null) const selectedTemplate = ref(null)
const featuredTemplateIds = ref([]) const featuredTemplateIds = ref([])
const templatePickerModalOpen = ref(false) const templatePickerModalOpen = ref(false)
const templatePickerMode = ref('game-admin') const templatePickerMode = ref('template-admin')
const templatePickerQuery = ref('') const templatePickerQuery = ref('')
const templatePickerSort = ref('recent') const templatePickerSort = ref('recent')
@@ -50,7 +50,7 @@ const customItemModalTargetTemplateId = ref('')
const adminTierLists = ref([]) const adminTierLists = ref([])
const adminTierListQuery = ref('') const adminTierListQuery = ref('')
const adminTierListGameId = ref('') const adminTierListTopicId = ref('')
const adminTierListPage = ref(1) const adminTierListPage = ref(1)
const adminTierListLimit = ref(50) const adminTierListLimit = ref(50)
const adminTierListTotal = ref(0) const adminTierListTotal = ref(0)
@@ -109,7 +109,7 @@ const success = ref('')
const newTemplateId = ref('') const newTemplateId = ref('')
const newTemplateName = ref('') const newTemplateName = ref('')
const newTemplateIsPublic = ref(false) const newTemplateIsPublic = ref(false)
const gameVisibilitySaving = ref(false) const templateVisibilitySaving = ref(false)
const uploadFiles = ref([]) const uploadFiles = ref([])
const uploadItemDrafts = ref([]) const uploadItemDrafts = ref([])
@@ -122,12 +122,12 @@ const itemFileInput = ref(null)
const thumbFileInput = ref(null) const thumbFileInput = ref(null)
const featuredListEl = ref(null) const featuredListEl = ref(null)
const featuredSortable = ref(null) const featuredSortable = ref(null)
const gameItemListEl = ref(null) const templateItemListEl = ref(null)
const gameItemSortable = ref(null) const templateItemSortable = ref(null)
let gameItemSortableSyncTimer = null let templateItemSortableSyncTimer = null
const savedGameItemOrderIds = ref([]) const savedTemplateItemOrderIds = ref([])
const userAvatarInputs = ref({}) const userAvatarInputs = ref({})
const isGameLoading = ref(false) const isTemplateLoading = ref(false)
const templateCreateModalOpen = ref(false) const templateCreateModalOpen = ref(false)
const previousBodyOverflow = ref('') const previousBodyOverflow = ref('')
@@ -143,23 +143,23 @@ function setThumbFileInputRef(el) {
thumbFileInput.value = el thumbFileInput.value = el
} }
function scheduleGameItemSortableSync() { function scheduleTemplateItemSortableSync() {
if (gameItemSortableSyncTimer) { if (templateItemSortableSyncTimer) {
clearTimeout(gameItemSortableSyncTimer) clearTimeout(templateItemSortableSyncTimer)
gameItemSortableSyncTimer = null templateItemSortableSyncTimer = null
} }
if (!gameItemListEl.value || !selectedTemplate.value?.items?.length) return if (!templateItemListEl.value || !selectedTemplate.value?.items?.length) return
gameItemSortableSyncTimer = setTimeout(() => { templateItemSortableSyncTimer = setTimeout(() => {
gameItemSortableSyncTimer = null templateItemSortableSyncTimer = null
syncGameItemSortable() syncTemplateItemSortable()
}, 0) }, 0)
} }
function setGameItemListRef(el) { function setTemplateItemListRef(el) {
gameItemListEl.value = el templateItemListEl.value = el
if (!el) return if (!el) return
scheduleGameItemSortableSync() scheduleTemplateItemSortableSync()
} }
function normalizeAdminSrc(src) { function normalizeAdminSrc(src) {
@@ -175,7 +175,7 @@ function normalizeAdminSrc(src) {
} }
} }
const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.game?.id) const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.template?.id)
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value) const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value)
const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedTemplateId.value) const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedTemplateId.value)
const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length) const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length)
@@ -188,7 +188,7 @@ const appliedRequestItemCount = computed(() => {
}) })
const hasTemplateItemOrderChanges = computed(() => { const hasTemplateItemOrderChanges = computed(() => {
const currentIds = (selectedTemplate.value?.items || []).map((item) => item.id) const currentIds = (selectedTemplate.value?.items || []).map((item) => item.id)
return currentIds.join('|') !== savedGameItemOrderIds.value.join('|') return currentIds.join('|') !== savedTemplateItemOrderIds.value.join('|')
}) })
const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value))) const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value)))
const adminTierListPageCount = computed(() => Math.max(1, Math.ceil(adminTierListTotal.value / adminTierListLimit.value))) const adminTierListPageCount = computed(() => Math.max(1, Math.ceil(adminTierListTotal.value / adminTierListLimit.value)))
@@ -215,7 +215,7 @@ const customItemTargetTemplate = computed(() => templates.value.find((template)
const importModalItemCount = computed(() => importModalItems.value.length) const importModalItemCount = computed(() => importModalItems.value.length)
const activeTabTitle = computed(() => { const activeTabTitle = computed(() => {
if (activeTab.value === 'featured') return '목록 관리' if (activeTab.value === 'featured') return '목록 관리'
if (activeTab.value === 'game-admin') return '템플릿 관리' if (activeTab.value === 'template-admin') return '템플릿 관리'
if (activeTab.value === 'items') return '아이템 관리' if (activeTab.value === 'items') return '아이템 관리'
if (activeTab.value === 'tierlists') { if (activeTab.value === 'tierlists') {
return tierlistsMode.value === 'requests' ? '템플릿 요청 관리' : '전체 티어표 관리' return tierlistsMode.value === 'requests' ? '템플릿 요청 관리' : '전체 티어표 관리'
@@ -226,7 +226,7 @@ const activeTabDescription = computed(() => {
if (activeTab.value === 'featured') { if (activeTab.value === 'featured') {
return '홈 화면 상단에 고정 노출되는 템플릿 순서를 따로 관리합니다.' return '홈 화면 상단에 고정 노출되는 템플릿 순서를 따로 관리합니다.'
} }
if (activeTab.value === 'game-admin') { if (activeTab.value === 'template-admin') {
return '템플릿 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.' return '템플릿 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
} }
if (activeTab.value === 'items') { if (activeTab.value === 'items') {
@@ -251,7 +251,7 @@ const adminOverviewStats = computed(() => {
{ label: '추가 가능', value: `${Math.max(0, 50 - featuredTemplateIds.value.length)}` }, { label: '추가 가능', value: `${Math.max(0, 50 - featuredTemplateIds.value.length)}` },
] ]
} }
if (activeTab.value === 'game-admin') { if (activeTab.value === 'template-admin') {
return [ return [
{ label: '전체 템플릿', value: `${templates.value.length}` }, { label: '전체 템플릿', value: `${templates.value.length}` },
{ label: '티어표 전체', value: `${selectedTemplateTierListStats.value.total || 0}` }, { label: '티어표 전체', value: `${selectedTemplateTierListStats.value.total || 0}` },
@@ -305,14 +305,14 @@ const isAnyModalOpen = computed(
) )
const adminRouteNameByTab = { const adminRouteNameByTab = {
featured: 'adminFeatured', featured: 'adminFeatured',
'game-admin': 'adminGames', 'template-admin': 'adminTemplates',
items: 'adminItems', items: 'adminItems',
tierlists: 'adminTierlists', tierlists: 'adminTierlists',
users: 'adminUsers', users: 'adminUsers',
} }
function tabFromAdminRoute(name) { function tabFromAdminRoute(name) {
if (name === 'adminGames') return 'game-admin' if (name === 'adminTemplates') return 'template-admin'
if (name === 'adminItems') return 'items' if (name === 'adminItems') return 'items'
if (name === 'adminTierlists') return 'tierlists' if (name === 'adminTierlists') return 'tierlists'
if (name === 'adminUsers') return 'users' if (name === 'adminUsers') return 'users'
@@ -375,12 +375,12 @@ onUnmounted(() => {
if (typeof document !== 'undefined') document.body.style.overflow = previousBodyOverflow.value || '' if (typeof document !== 'undefined') document.body.style.overflow = previousBodyOverflow.value || ''
clearPreviewUrl('item') clearPreviewUrl('item')
clearPreviewUrl('thumb') clearPreviewUrl('thumb')
if (gameItemSortableSyncTimer) { if (templateItemSortableSyncTimer) {
clearTimeout(gameItemSortableSyncTimer) clearTimeout(templateItemSortableSyncTimer)
gameItemSortableSyncTimer = null templateItemSortableSyncTimer = null
} }
destroyFeaturedSortable() destroyFeaturedSortable()
destroyGameItemSortable() destroyTemplateItemSortable()
}) })
function clearPreviewUrl(kind) { function clearPreviewUrl(kind) {
@@ -423,12 +423,12 @@ watch(
() => route.name, () => route.name,
(name) => { (name) => {
activeTab.value = tabFromAdminRoute(name) activeTab.value = tabFromAdminRoute(name)
if (name === 'adminGames') { if (name === 'adminTemplates') {
const nextGameId = typeof route.query.gameId === 'string' ? route.query.gameId : '' const nextTopicId = typeof route.query.topicId === 'string' ? route.query.topicId : ''
if (nextGameId && nextGameId !== selectedTemplateId.value) { if (nextTopicId && nextTopicId !== selectedTemplateId.value) {
selectedTemplateId.value = nextGameId selectedTemplateId.value = nextTopicId
queueMicrotask(() => { queueMicrotask(() => {
if (selectedTemplateId.value === nextGameId) void loadTemplate() if (selectedTemplateId.value === nextTopicId) void loadTemplate()
}) })
} }
return return
@@ -436,8 +436,8 @@ watch(
if (name === 'adminTierlists') { if (name === 'adminTierlists') {
const nextMode = route.query.mode === 'all' ? 'all' : 'requests' const nextMode = route.query.mode === 'all' ? 'all' : 'requests'
if (tierlistsMode.value !== nextMode) tierlistsMode.value = nextMode if (tierlistsMode.value !== nextMode) tierlistsMode.value = nextMode
const nextTierListGameId = typeof route.query.gameId === 'string' ? route.query.gameId : '' const nextTierListTopicId = typeof route.query.topicId === 'string' ? route.query.topicId : ''
if (adminTierListGameId.value !== nextTierListGameId) adminTierListGameId.value = nextTierListGameId if (adminTierListTopicId.value !== nextTierListTopicId) adminTierListTopicId.value = nextTierListTopicId
} }
}, },
{ immediate: true } { immediate: true }
@@ -446,13 +446,13 @@ watch(
watch( watch(
() => selectedTemplateId.value, () => selectedTemplateId.value,
(templateId) => { (templateId) => {
if (route.name !== 'adminGames') return if (route.name !== 'adminTemplates') return
syncAdminRouteQuery({ gameId: templateId || undefined }) syncAdminRouteQuery({ topicId: templateId || undefined })
} }
) )
watch( watch(
() => selectedTemplate.value?.game?.id || '', () => selectedTemplate.value?.template?.id || '',
async (templateId) => { async (templateId) => {
await refreshSelectedTemplateTierListStats(templateId) await refreshSelectedTemplateTierListStats(templateId)
}, },
@@ -465,23 +465,23 @@ watch(
if (route.name !== 'adminTierlists') return if (route.name !== 'adminTierlists') return
syncAdminRouteQuery({ syncAdminRouteQuery({
mode: mode === 'all' ? 'all' : undefined, mode: mode === 'all' ? 'all' : undefined,
gameId: mode === 'all' && adminTierListGameId.value ? adminTierListGameId.value : undefined, topicId: mode === 'all' && adminTierListTopicId.value ? adminTierListTopicId.value : undefined,
}) })
} }
) )
watch( watch(
() => adminTierListGameId.value, () => adminTierListTopicId.value,
(gameId) => { (topicId) => {
if (route.name !== 'adminTierlists' || tierlistsMode.value !== 'all') return if (route.name !== 'adminTierlists' || tierlistsMode.value !== 'all') return
syncAdminRouteQuery({ gameId: gameId || undefined }) syncAdminRouteQuery({ topicId: topicId || undefined })
} }
) )
watch( watch(
() => activeTab.value, () => activeTab.value,
async (tab) => { async (tab) => {
if (tab === 'game-admin' && selectedTemplateId.value && !selectedTemplate.value?.game?.id) { if (tab === 'template-admin' && selectedTemplateId.value && !selectedTemplate.value?.template?.id) {
await loadTemplate() await loadTemplate()
return return
} }
@@ -524,10 +524,10 @@ watch(
) )
watch( watch(
() => [selectedTemplate.value?.game?.id || '', selectedTemplate.value?.items?.length || 0, !!gameItemListEl.value], () => [selectedTemplate.value?.template?.id || '', selectedTemplate.value?.items?.length || 0, !!templateItemListEl.value],
([templateId, itemCount, hasListEl]) => { ([templateId, itemCount, hasListEl]) => {
if (!templateId || !itemCount || !hasListEl) return if (!templateId || !itemCount || !hasListEl) return
scheduleGameItemSortableSync() scheduleTemplateItemSortableSync()
} }
) )
@@ -571,7 +571,7 @@ function formatImageJobSourceCategory(category) {
return '커스텀 아이템' return '커스텀 아이템'
case 'tierlists': case 'tierlists':
return '티어표 썸네일' return '티어표 썸네일'
case 'games': case 'topics':
return '주제/템플릿 이미지' return '주제/템플릿 이미지'
case 'avatars': case 'avatars':
return '프로필 아바타' return '프로필 아바타'
@@ -619,7 +619,7 @@ const imageDiagnosticsCards = computed(() => {
] ]
}) })
const visibleLinkedTemplates = computed(() => const visibleLinkedTemplates = computed(() =>
(modalTargetCustomItem.value?.linkedGames || []).filter((template) => template?.id && template.id !== 'freeform') (modalTargetCustomItem.value?.linkedTemplates || []).filter((template) => template?.id && template.id !== 'freeform')
) )
const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean))) const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean)))
@@ -715,10 +715,10 @@ async function cleanupMissingImageReferences() {
success.value = success.value =
`누락 참조를 정리했어요. ` + `누락 참조를 정리했어요. ` +
`아바타 ${result.clearedAvatars || 0}건, ` + `아바타 ${result.clearedAvatars || 0}건, ` +
`템플릿 썸네일 ${result.clearedGameThumbnails || 0}건, ` + `템플릿 썸네일 ${result.clearedTopicThumbnails || 0}건, ` +
`티어표 썸네일 ${result.clearedTierListThumbnails || 0}건, ` + `티어표 썸네일 ${result.clearedTierListThumbnails || 0}건, ` +
`요청 썸네일 ${result.clearedTemplateRequestThumbnails || 0}건, ` + `요청 썸네일 ${result.clearedTemplateRequestThumbnails || 0}건, ` +
`템플릿 아이템 ${result.deletedGameItems || 0}건, ` + `템플릿 아이템 ${result.deletedTopicItems || 0}건, ` +
`커스텀 아이템 ${result.deletedCustomItems || 0}` `커스텀 아이템 ${result.deletedCustomItems || 0}`
} catch (e) { } catch (e) {
error.value = '누락 이미지 참조 정리에 실패했어요.' error.value = '누락 이미지 참조 정리에 실패했어요.'
@@ -732,8 +732,8 @@ function setTab(tab) {
const nextRouteName = adminRouteNameByTab[tab] const nextRouteName = adminRouteNameByTab[tab]
if (nextRouteName && route.name !== nextRouteName) { if (nextRouteName && route.name !== nextRouteName) {
const nextQuery = const nextQuery =
tab === 'game-admin' tab === 'template-admin'
? { gameId: selectedTemplateId.value || undefined } ? { topicId: selectedTemplateId.value || undefined }
: tab === 'tierlists' && tierlistsMode.value === 'all' : tab === 'tierlists' && tierlistsMode.value === 'all'
? { mode: 'all' } ? { mode: 'all' }
: {} : {}
@@ -758,10 +758,10 @@ function setTierlistsMode(mode) {
function openTemplateCreateModal() { function openTemplateCreateModal() {
resetMessages() resetMessages()
if (activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetGameId) { if (activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetTopicId) {
newTemplateId.value = activeTemplateRequest.value?.draftGameId || '' newTemplateId.value = activeTemplateRequest.value?.draftTopicId || ''
newTemplateName.value = activeTemplateRequest.value?.draftGameName || '' newTemplateName.value = activeTemplateRequest.value?.draftTopicName || ''
newTemplateIsPublic.value = !!activeTemplateRequest.value?.draftGameIsPublic newTemplateIsPublic.value = !!activeTemplateRequest.value?.draftTopicIsPublic
} else { } else {
newTemplateId.value = '' newTemplateId.value = ''
newTemplateName.value = '' newTemplateName.value = ''
@@ -787,8 +787,8 @@ async function selectAdminTemplate(templateId) {
async function refreshTemplates() { async function refreshTemplates() {
try { try {
const data = await api.listGames() const data = await api.listTopics()
templates.value = data.games || [] templates.value = data.topics || []
featuredTemplateIds.value = templates.value featuredTemplateIds.value = templates.value
.filter((template) => template.displayRank != null) .filter((template) => template.displayRank != null)
.sort((a, b) => a.displayRank - b.displayRank) .sort((a, b) => a.displayRank - b.displayRank)
@@ -822,7 +822,7 @@ async function refreshAdminTierLists() {
try { try {
const data = await api.listAdminTierLists({ const data = await api.listAdminTierLists({
q: adminTierListQuery.value, q: adminTierListQuery.value,
gameId: adminTierListGameId.value, topicId: adminTierListTopicId.value,
page: adminTierListPage.value, page: adminTierListPage.value,
limit: adminTierListLimit.value, limit: adminTierListLimit.value,
}) })
@@ -839,7 +839,7 @@ async function refreshAdminTierLists() {
async function refreshAdminTierListStats() { async function refreshAdminTierListStats() {
if (!auth.user?.isAdmin) return if (!auth.user?.isAdmin) return
try { try {
const data = await api.getAdminTierListStats({ q: adminTierListQuery.value, gameId: adminTierListGameId.value }) const data = await api.getAdminTierListStats({ q: adminTierListQuery.value, topicId: adminTierListTopicId.value })
adminTierListStats.value = { adminTierListStats.value = {
total: data.total || 0, total: data.total || 0,
publicCount: data.publicCount || 0, publicCount: data.publicCount || 0,
@@ -857,7 +857,7 @@ async function refreshSelectedTemplateTierListStats(templateId = '') {
} }
try { try {
const data = await api.getAdminTierListStats({ gameId: templateId }) const data = await api.getAdminTierListStats({ topicId: templateId })
selectedTemplateTierListStats.value = { selectedTemplateTierListStats.value = {
total: data.total || 0, total: data.total || 0,
publicCount: data.publicCount || 0, publicCount: data.publicCount || 0,
@@ -874,15 +874,15 @@ async function refreshTemplateRequests() {
const data = await api.listAdminTemplateRequests() const data = await api.listAdminTemplateRequests()
templateRequests.value = (data.requests || []).map((request) => ({ templateRequests.value = (data.requests || []).map((request) => ({
...request, ...request,
draftGameId: draftTopicId:
request.type === 'create' request.type === 'create'
? ('tmpl-' + String(request.id || 'request').replace(/[^a-zA-Z0-9]+/g, '').slice(0, 12).toLowerCase()) ? ('tmpl-' + String(request.id || 'request').replace(/[^a-zA-Z0-9]+/g, '').slice(0, 12).toLowerCase())
: request.targetGameId || request.sourceGameId || '', : request.targetTopicId || request.sourceTopicId || '',
draftGameName: draftTopicName:
request.type === 'create' request.type === 'create'
? `${request.sourceTierListTitle || request.sourceGameName || '새 템플릿'}` ? `${request.sourceTierListTitle || request.sourceTopicName || '새 템플릿'}`
: request.targetGameName || request.sourceGameName || '', : request.targetTopicName || request.sourceTopicName || '',
draftGameIsPublic: false, draftTopicIsPublic: false,
})) }))
} catch (e) { } catch (e) {
error.value = '템플릿 요청 목록을 불러오지 못했어요.' error.value = '템플릿 요청 목록을 불러오지 못했어요.'
@@ -919,7 +919,7 @@ const {
removeFeaturedTemplate, removeFeaturedTemplate,
moveFeaturedTemplate, moveFeaturedTemplate,
saveFeaturedOrder, saveFeaturedOrder,
} = useAdminFeaturedGames({ } = useAdminFeaturedTemplates({
api, api,
featuredListEl, featuredListEl,
featuredSortable, featuredSortable,
@@ -931,8 +931,8 @@ const {
}) })
const { const {
destroyGameItemSortable, destroyTemplateItemSortable,
syncGameItemSortable, syncTemplateItemSortable,
mergeRequestItemsIntoDrafts, mergeRequestItemsIntoDrafts,
removeUploadDraft, removeUploadDraft,
loadTemplate, loadTemplate,
@@ -943,7 +943,7 @@ const {
clearItemFiles, clearItemFiles,
uploadItem, uploadItem,
saveTemplateItemOrder, saveTemplateItemOrder,
} = useAdminGameManager({ } = useAdminTemplateManager({
api, api,
toApiUrl, toApiUrl,
selectedTemplateId, selectedTemplateId,
@@ -953,10 +953,10 @@ const {
thumbFile, thumbFile,
itemPreviewUrls, itemPreviewUrls,
itemFileInput, itemFileInput,
gameItemListEl, templateItemListEl,
gameItemSortable, templateItemSortable,
savedGameItemOrderIds, savedTemplateItemOrderIds,
isGameLoading, isTemplateLoading,
activeTemplateRequest, activeTemplateRequest,
templateRequests, templateRequests,
customItemModalOpen, customItemModalOpen,
@@ -1148,7 +1148,7 @@ async function uploadThumbnail() {
try { try {
const fd = new FormData() const fd = new FormData()
fd.append('thumbnail', thumbFile.value) fd.append('thumbnail', thumbFile.value)
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}/thumbnail`), { const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/thumbnail`), {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: fd, body: fd,
@@ -1167,37 +1167,38 @@ async function uploadThumbnail() {
} }
async function saveTemplateVisibility() { async function saveTemplateVisibility() {
if (!selectedTemplate.value?.game?.id) return if (!selectedTemplate.value?.template?.id) return
try { try {
gameVisibilitySaving.value = true templateVisibilitySaving.value = true
const data = await api.updateAdminGame(selectedTemplate.value.game.id, { const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, {
isPublic: !!selectedTemplate.value.game.isPublic, isPublic: !!selectedTemplate.value.template.isPublic,
}) })
const nextTemplate = data.template || {}
selectedTemplate.value = { selectedTemplate.value = {
...selectedTemplate.value, ...selectedTemplate.value,
game: { template: {
...selectedTemplate.value.game, ...selectedTemplate.value.template,
...data.game, ...nextTemplate,
}, },
} }
await refreshTemplates() await refreshTemplates()
success.value = data.game?.isPublic ? '템플릿을 공개 상태로 전환했어요.' : '템플릿을 비공개 상태로 전환했어요.' success.value = nextTemplate?.isPublic ? '템플릿을 공개 상태로 전환했어요.' : '템플릿을 비공개 상태로 전환했어요.'
return true return true
} catch (e) { } catch (e) {
error.value = '템플릿 공개 상태를 저장하지 못했어요.' error.value = '템플릿 공개 상태를 저장하지 못했어요.'
return false return false
} finally { } finally {
gameVisibilitySaving.value = false templateVisibilitySaving.value = false
} }
} }
async function toggleSelectedTemplateVisibility(nextValue) { async function toggleSelectedTemplateVisibility(nextValue) {
if (!selectedTemplate.value?.game?.id || gameVisibilitySaving.value) return if (!selectedTemplate.value?.template?.id || templateVisibilitySaving.value) return
const previous = !!selectedTemplate.value.game.isPublic const previous = !!selectedTemplate.value.template.isPublic
selectedTemplate.value = { selectedTemplate.value = {
...selectedTemplate.value, ...selectedTemplate.value,
game: { template: {
...selectedTemplate.value.game, ...selectedTemplate.value.template,
isPublic: !!nextValue, isPublic: !!nextValue,
}, },
} }
@@ -1205,8 +1206,8 @@ async function toggleSelectedTemplateVisibility(nextValue) {
if (!saved) { if (!saved) {
selectedTemplate.value = { selectedTemplate.value = {
...selectedTemplate.value, ...selectedTemplate.value,
game: { template: {
...selectedTemplate.value.game, ...selectedTemplate.value.template,
isPublic: previous, isPublic: previous,
}, },
} }
@@ -1215,9 +1216,27 @@ async function toggleSelectedTemplateVisibility(nextValue) {
async function removeTemplateItem(itemId) { async function removeTemplateItem(itemId) {
resetMessages() resetMessages()
if (!selectedTemplateId.value) return
try { try {
const usageRes = await fetch(
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}/usage`),
{
credentials: 'include',
}
)
if (!usageRes.ok) throw new Error('usage_failed')
const usageData = await usageRes.json()
const usage = usageData?.usage || { totalCount: 0, publicCount: 0, privateCount: 0 }
const impactMessage = usage.totalCount
? `이 아이템은 이미 저장된 티어표 ${usage.totalCount}개(공개 ${usage.publicCount}개, 비공개 ${usage.privateCount}개)에서 사용 중이에요.\n기존 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외됩니다.\n정말 삭제할까요?`
: '이 기본 아이템을 삭제할까요?\n기존 저장 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외됩니다.'
const ok = window.confirm(impactMessage)
if (!ok) return
const previousScrollY = window.scrollY
const res = await fetch( const res = await fetch(
toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}`), toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}`),
{ {
method: 'DELETE', method: 'DELETE',
credentials: 'include', credentials: 'include',
@@ -1226,6 +1245,8 @@ async function removeTemplateItem(itemId) {
if (!res.ok) throw new Error('failed') if (!res.ok) throw new Error('failed')
await loadTemplate() await loadTemplate()
await nextTick()
window.scrollTo({ top: previousScrollY, behavior: 'auto' })
success.value = '템플릿 기본 아이템을 삭제했어요.' success.value = '템플릿 기본 아이템을 삭제했어요.'
} catch (e) { } catch (e) {
error.value = '템플릿 기본 아이템 삭제에 실패했어요.' error.value = '템플릿 기본 아이템 삭제에 실패했어요.'
@@ -1244,7 +1265,7 @@ async function saveTemplateItemLabel(item) {
try { try {
item.isSavingLabel = true item.isSavingLabel = true
const data = await api.updateAdminGameItem(selectedTemplateId.value, item.id, { label: nextLabel }) const data = await api.updateAdminTemplateItem(selectedTemplateId.value, item.id, { label: nextLabel })
item.label = data.item.label item.label = data.item.label
item.draftLabel = data.item.label item.draftLabel = data.item.label
success.value = '기본 아이템 이름을 수정했어요.' success.value = '기본 아이템 이름을 수정했어요.'
@@ -1257,19 +1278,19 @@ async function saveTemplateItemLabel(item) {
async function removeTemplate() { async function removeTemplate() {
resetMessages() resetMessages()
if (!selectedTemplateId.value || !selectedTemplate.value?.game) return if (!selectedTemplateId.value || !selectedTemplate.value?.template) return
const ok = window.confirm(`"${selectedTemplate.value.game.name}" 템플릿을 삭제할까요? 관련 기본 아이템과 티어표도 함께 삭제됩니다.`) const ok = window.confirm(`"${selectedTemplate.value.template.name}" 템플릿을 삭제할까요? 관련 기본 아이템과 티어표도 함께 삭제됩니다.`)
if (!ok) return if (!ok) return
try { try {
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}`), { const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}`), {
method: 'DELETE', method: 'DELETE',
credentials: 'include', credentials: 'include',
}) })
if (!res.ok) throw new Error('failed') if (!res.ok) throw new Error('failed')
const deletedName = selectedTemplate.value.game.name const deletedName = selectedTemplate.value.template.name
selectedTemplateId.value = '' selectedTemplateId.value = ''
selectedTemplate.value = null selectedTemplate.value = null
resetUploadState() resetUploadState()
@@ -1285,13 +1306,13 @@ function submitAdminTierListSearch() {
refreshAdminTierLists() refreshAdminTierLists()
} }
function setAdminTierListGameId(gameId) { function setAdminTierListTopicId(topicId) {
adminTierListGameId.value = gameId || '' adminTierListTopicId.value = topicId || ''
adminTierListPage.value = 1 adminTierListPage.value = 1
refreshAdminTierLists() refreshAdminTierLists()
} }
function openTemplatePickerModal(mode = 'game-admin') { function openTemplatePickerModal(mode = 'template-admin') {
templatePickerMode.value = mode templatePickerMode.value = mode
templatePickerQuery.value = '' templatePickerQuery.value = ''
templatePickerSort.value = 'recent' templatePickerSort.value = 'recent'
@@ -1306,7 +1327,7 @@ function closeTemplatePickerModal() {
async function chooseTemplateFromPicker(templateId) { async function chooseTemplateFromPicker(templateId) {
if (!templateId) return if (!templateId) return
if (templatePickerMode.value === 'tierlists-filter') { if (templatePickerMode.value === 'tierlists-filter') {
setAdminTierListGameId(templateId) setAdminTierListTopicId(templateId)
closeTemplatePickerModal() closeTemplatePickerModal()
return return
} }
@@ -1347,7 +1368,7 @@ function buildModalItemFromTierListItem(item, tierList) {
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'template' : 'user'), sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'template' : 'user'),
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템', sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList), ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
linkedGames: Array.isArray(matchedItem?.linkedGames) ? matchedItem.linkedGames : [], linkedTemplates: Array.isArray(matchedItem?.linkedTemplates) ? matchedItem.linkedTemplates : [],
usageCount: matchedItem?.usageCount || 0, usageCount: matchedItem?.usageCount || 0,
canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false, canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false,
isPromoting: false, isPromoting: false,
@@ -1411,7 +1432,7 @@ async function saveAdminTierListMeta() {
adminTierLists.value = adminTierLists.value.map((tierList) => (tierList.id === updated.id ? { ...tierList, ...updated } : tierList)) adminTierLists.value = adminTierLists.value.map((tierList) => (tierList.id === updated.id ? { ...tierList, ...updated } : tierList))
if (previewTierList.value?.id === updated.id) previewTierList.value = { ...previewTierList.value, ...updated } if (previewTierList.value?.id === updated.id) previewTierList.value = { ...previewTierList.value, ...updated }
modalTargetAdminTierList.value = updated modalTargetAdminTierList.value = updated
await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.game?.id || '')]) await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.template?.id || '')])
success.value = '티어표 정보를 수정했어요.' success.value = '티어표 정보를 수정했어요.'
closeAdminTierListManageModal() closeAdminTierListManageModal()
} catch (e) { } catch (e) {
@@ -1433,7 +1454,7 @@ async function deleteAdminTierListEntry() {
adminTierLists.value = adminTierLists.value.filter((tierList) => tierList.id !== modalTargetAdminTierList.value.id) adminTierLists.value = adminTierLists.value.filter((tierList) => tierList.id !== modalTargetAdminTierList.value.id)
adminTierListTotal.value = Math.max(0, adminTierListTotal.value - 1) adminTierListTotal.value = Math.max(0, adminTierListTotal.value - 1)
if (previewTierList.value?.id === modalTargetAdminTierList.value.id) previewTierList.value = null if (previewTierList.value?.id === modalTargetAdminTierList.value.id) previewTierList.value = null
await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.game?.id || '')]) await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.template?.id || '')])
success.value = '티어표를 삭제했어요.' success.value = '티어표를 삭제했어요.'
closeAdminTierListManageModal() closeAdminTierListManageModal()
if (!adminTierLists.value.length && adminTierListPage.value > 1) { if (!adminTierLists.value.length && adminTierListPage.value > 1) {
@@ -1530,8 +1551,8 @@ function closePreviewModal() {
} }
function previewTierListUrl(tierList) { function previewTierListUrl(tierList) {
if (!tierList?.gameId || !tierList?.id) return '' if (!tierList?.topicId || !tierList?.id) return ''
return editorPath(tierList.gameId, tierList.id, { preview: true }) return editorPath(tierList.topicId, tierList.id, { preview: true })
} }
function openTierListImportModal(tierList, items) { function openTierListImportModal(tierList, items) {
@@ -1546,9 +1567,9 @@ function openTierListImportModal(tierList, items) {
importModalItems.value = nextItems importModalItems.value = nextItems
importModalMode.value = 'existing' importModalMode.value = 'existing'
importModalTargetTemplateId.value = '' importModalTargetTemplateId.value = ''
importModalNewTemplateId.value = tierList.gameId === 'freeform' ? '' : `${tierList.gameId}-copy` importModalNewTemplateId.value = tierList.topicId === 'freeform' ? '' : `${tierList.topicId}-copy`
importModalNewTemplateName.value = importModalNewTemplateName.value =
tierList.gameId === 'freeform' ? `${tierList.title} 템플릿` : `${tierList.gameName || tierList.gameId} 파생 템플릿` tierList.topicId === 'freeform' ? `${tierList.title} 템플릿` : `${tierList.topicName || tierList.topicId} 파생 템플릿`
importModalOpen.value = true importModalOpen.value = true
} }
@@ -1576,26 +1597,26 @@ async function confirmTierListImport() {
} }
const data = await api.promoteAdminTierListItems(tierList.id, { const data = await api.promoteAdminTierListItems(tierList.id, {
gameId: importModalTargetTemplateId.value, topicId: importModalTargetTemplateId.value,
itemIds, itemIds,
}) })
if (selectedTemplateId.value === importModalTargetTemplateId.value) await loadTemplate() if (selectedTemplateId.value === importModalTargetTemplateId.value) await loadTemplate()
success.value = `${data.items?.length || 0}개의 아이템을 기존 템플릿에 추가했어요.` success.value = `${data.items?.length || 0}개의 아이템을 기존 템플릿에 추가했어요.`
} else { } else {
const nextGameId = (importModalNewTemplateId.value || '').trim() const nextTopicId = (importModalNewTemplateId.value || '').trim()
const nextGameName = (importModalNewTemplateName.value || '').trim() const nextTopicName = (importModalNewTemplateName.value || '').trim()
if (!nextGameId || !nextGameName) { if (!nextTopicId || !nextTopicName) {
error.value = '새 템플릿 ID와 이름을 모두 입력해주세요.' error.value = '새 템플릿 ID와 이름을 모두 입력해주세요.'
return return
} }
const data = await api.createAdminGameTemplateFromTierList(tierList.id, { const data = await api.createAdminTemplateFromTierList(tierList.id, {
gameId: nextGameId, topicId: nextTopicId,
name: nextGameName, name: nextTopicName,
itemIds, itemIds,
}) })
await refreshTemplates() await refreshTemplates()
success.value = `"${data.game?.name || nextGameName}" 템플릿을 생성했어요.` success.value = `"${data.template?.name || nextTopicName}" 템플릿을 생성했어요.`
} }
closeTierListImportModal() closeTierListImportModal()
@@ -1610,17 +1631,17 @@ function templateRequestTypeLabel(request) {
function templateRequestTargetLabel(request) { function templateRequestTargetLabel(request) {
if (request.type === 'create') { if (request.type === 'create') {
if (request.targetGameName || request.targetGameId) { if (request.targetTopicName || request.targetTopicId) {
return `연결된 템플릿 · ${request.targetGameName || request.targetGameId}` return `연결된 템플릿 · ${request.targetTopicName || request.targetTopicId}`
} }
return '연결된 템플릿 없음' return '연결된 템플릿 없음'
} }
return request.targetGameName || request.targetGameId || request.sourceGameName return request.targetTopicName || request.targetTopicId || request.sourceTopicName
} }
const displayThumbnailUrl = computed(() => { const displayThumbnailUrl = computed(() => {
if (thumbPreviewUrl.value) return thumbPreviewUrl.value if (thumbPreviewUrl.value) return thumbPreviewUrl.value
if (selectedTemplate.value?.game?.thumbnailSrc) return toApiUrl(selectedTemplate.value.game.thumbnailSrc) if (selectedTemplate.value?.template?.thumbnailSrc) return toApiUrl(selectedTemplate.value.template.thumbnailSrc)
return '' return ''
}) })
@@ -1679,19 +1700,19 @@ function userAvatarFallback(user) {
:add-featured-template="addFeaturedTemplate" :add-featured-template="addFeaturedTemplate"
/> />
<AdminGamesSection <AdminTemplatesSection
v-else-if="activeTab === 'game-admin'" v-else-if="activeTab === 'template-admin'"
:active-template-request="activeTemplateRequest" :active-template-request="activeTemplateRequest"
:template-request-source-url="templateRequestSourceUrl" :template-request-source-url="templateRequestSourceUrl"
:staged-request-draft-count="stagedRequestDraftCount" :staged-request-draft-count="stagedRequestDraftCount"
:applied-request-item-count="appliedRequestItemCount" :applied-request-item-count="appliedRequestItemCount"
:open-template-create-modal="openTemplateCreateModal" :open-template-create-modal="openTemplateCreateModal"
:is-game-loading="isGameLoading" :is-template-loading="isTemplateLoading"
:has-selected-template="hasSelectedTemplate" :has-selected-template="hasSelectedTemplate"
:selected-template="selectedTemplate" :selected-template="selectedTemplate"
:display-thumbnail-url="displayThumbnailUrl" :display-thumbnail-url="displayThumbnailUrl"
:can-apply-thumbnail="canApplyThumbnail" :can-apply-thumbnail="canApplyThumbnail"
:game-visibility-saving="gameVisibilitySaving" :template-visibility-saving="templateVisibilitySaving"
:thumb-file-input-ref="setThumbFileInputRef" :thumb-file-input-ref="setThumbFileInputRef"
:open-thumb-file-picker="openThumbFilePicker" :open-thumb-file-picker="openThumbFilePicker"
:on-thumb="onThumb" :on-thumb="onThumb"
@@ -1718,7 +1739,7 @@ function userAvatarFallback(user) {
:remove-upload-draft="removeUploadDraft" :remove-upload-draft="removeUploadDraft"
:has-template-item-order-changes="hasTemplateItemOrderChanges" :has-template-item-order-changes="hasTemplateItemOrderChanges"
:save-template-item-order="saveTemplateItemOrder" :save-template-item-order="saveTemplateItemOrder"
:game-item-list-ref="setGameItemListRef" :template-item-list-ref="setTemplateItemListRef"
:save-template-item-label="saveTemplateItemLabel" :save-template-item-label="saveTemplateItemLabel"
:remove-template-item="removeTemplateItem" :remove-template-item="removeTemplateItem"
:selected-template-id="selectedTemplateId" :selected-template-id="selectedTemplateId"
@@ -1802,7 +1823,7 @@ function userAvatarFallback(user) {
v-model="newTemplateId" v-model="newTemplateId"
class="field__input" class="field__input"
maxlength="120" maxlength="120"
placeholder="game id (영문/숫자)" placeholder="topic id (영문/숫자)"
@keydown.enter.prevent="createTemplate" @keydown.enter.prevent="createTemplate"
/> />
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newTemplateId.length }}/120</span> <span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newTemplateId.length }}/120</span>
@@ -1960,12 +1981,15 @@ function userAvatarFallback(user) {
</div> </div>
<div class="customItemModal__pickerActions"> <div class="customItemModal__pickerActions">
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button> <button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
<button class="btn btn--ghost btn--small customItemModal__createGameButton" type="button" @click="openTemplateCreateModal"> 템플릿 만들기</button> <button class="btn btn--ghost btn--small customItemModal__createTemplateButton" type="button" @click="openTemplateCreateModal"> 템플릿 만들기</button>
</div> </div>
</aside> </aside>
<div class="customItemModal__body"> <div class="customItemModal__body">
<button class="customItemModal__close" type="button" @click="closeCustomItemModal">닫기</button> <button class="customItemModal__close" type="button" @click="closeCustomItemModal">닫기</button>
<div class="customItemModal__content"> <div class="customItemModal__content">
<div class="customItemModal__preview">
<img class="customItemModal__previewImage" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
</div>
<div class="customItemModal__titleRow"> <div class="customItemModal__titleRow">
<div> <div>
<div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div> <div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div>
@@ -2025,34 +2049,34 @@ function userAvatarFallback(user) {
<option value="oldest">오래된순</option> <option value="oldest">오래된순</option>
</select> </select>
<button <button
v-if="templatePickerMode === 'tierlists-filter' && adminTierListGameId" v-if="templatePickerMode === 'tierlists-filter' && adminTierListTopicId"
class="btn btn--ghost" class="btn btn--ghost"
type="button" type="button"
@click="setAdminTierListGameId(''); closeTemplatePickerModal()" @click="setAdminTierListTopicId(''); closeTemplatePickerModal()"
> >
모든 주제 보기 모든 주제 보기
</button> </button>
</div> </div>
<div class="gamePickerModalList"> <div class="templatePickerModalList">
<button <button
v-for="template in filteredTemplatePickerTemplates" v-for="template in filteredTemplatePickerTemplates"
:key="template.id" :key="template.id"
class="adminGamePicker__item" class="adminTemplatePicker__item"
:class="{ :class="{
'adminGamePicker__item--active': templatePickerMode === 'tierlists-filter' 'adminTemplatePicker__item--active': templatePickerMode === 'tierlists-filter'
? adminTierListGameId === template.id ? adminTierListTopicId === template.id
: templatePickerMode === 'custom-item-target' : templatePickerMode === 'custom-item-target'
? customItemModalTargetTemplateId === template.id ? customItemModalTargetTemplateId === template.id
: selectedTemplateId === template.id, : selectedTemplateId === template.id,
'adminGamePicker__item--disabled': templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id), 'adminTemplatePicker__item--disabled': templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id),
}" }"
type="button" type="button"
:disabled="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)" :disabled="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)"
@click="chooseTemplateFromPicker(template.id)" @click="chooseTemplateFromPicker(template.id)"
> >
<span class="adminGamePicker__name">{{ template.name }}</span> <span class="adminTemplatePicker__name">{{ template.name }}</span>
<span class="adminGamePicker__meta">{{ template.id }}</span> <span class="adminTemplatePicker__meta">{{ template.id }}</span>
<span v-if="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)" class="adminGamePicker__state">이미 추가됨</span> <span v-if="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)" class="adminTemplatePicker__state">이미 추가됨</span>
</button> </button>
<div v-if="!filteredTemplatePickerTemplates.length" class="hint hint--tight">검색 결과가 없어요.</div> <div v-if="!filteredTemplatePickerTemplates.length" class="hint hint--tight">검색 결과가 없어요.</div>
</div> </div>
@@ -2074,7 +2098,7 @@ function userAvatarFallback(user) {
<div class="modalCard" role="dialog" aria-modal="true"> <div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">티어표 관리</div> <div class="modalCard__title">티어표 관리</div>
<div class="modalCard__desc"> <div class="modalCard__desc">
{{ modalTargetAdminTierList ? `${modalTargetAdminTierList.gameName || modalTargetAdminTierList.gameId} · ${tierListAuthorDisplayName(modalTargetAdminTierList)}` : '' }} {{ modalTargetAdminTierList ? `${modalTargetAdminTierList.topicName || modalTargetAdminTierList.topicId} · ${tierListAuthorDisplayName(modalTargetAdminTierList)}` : '' }}
</div> </div>
<div class="modalCard__form"> <div class="modalCard__form">
<label class="field"> <label class="field">
@@ -2202,24 +2226,24 @@ function userAvatarFallback(user) {
<div class="adminSidebar__label">Mode</div> <div class="adminSidebar__label">Mode</div>
<div class="adminSidebar__tabs"> <div class="adminSidebar__tabs">
<button class="tab" :class="{ 'tab--active': activeTab === 'featured' }" @click="setTab('featured')">목록 관리</button> <button class="tab" :class="{ 'tab--active': activeTab === 'featured' }" @click="setTab('featured')">목록 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'game-admin' }" @click="setTab('game-admin')">템플릿 관리</button> <button class="tab" :class="{ 'tab--active': activeTab === 'template-admin' }" @click="setTab('template-admin')">템플릿 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button> <button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button> <button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button> <button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
</div> </div>
</section> </section>
<section v-if="activeTab === 'game-admin'" class="adminSidebar__panel"> <section v-if="activeTab === 'template-admin'" class="adminSidebar__panel">
<div class="adminSidebar__label">Template</div> <div class="adminSidebar__label">Template</div>
<div class="adminSidebar__group"> <div class="adminSidebar__group">
<button class="btn btn--primary" @click="openTemplateCreateModal"> 템플릿 생성</button> <button class="btn btn--primary" @click="openTemplateCreateModal"> 템플릿 생성</button>
<button class="btn btn--ghost" @click="openTemplatePickerModal('game-admin')">템플릿 선택</button> <button class="btn btn--ghost" @click="openTemplatePickerModal('template-admin')">템플릿 선택</button>
<div v-if="selectedTemplate?.game" class="adminSelectionCard"> <div v-if="selectedTemplate?.template" class="adminSelectionCard">
<div class="adminSelectionCard__label">선택한 템플릿</div> <div class="adminSelectionCard__label">선택한 템플릿</div>
<div class="adminSelectionCard__title">{{ selectedTemplate.game.name }}</div> <div class="adminSelectionCard__title">{{ selectedTemplate.template.name }}</div>
<div class="adminSelectionCard__meta">{{ selectedTemplate.game.id }}</div> <div class="adminSelectionCard__meta">{{ selectedTemplate.template.id }}</div>
</div> </div>
<div v-if="selectedTemplateId && !hasSelectedTemplate && !isGameLoading" class="hint hint--tight">선택된 템플릿 ID: {{ selectedTemplateId }}</div> <div v-if="selectedTemplateId && !hasSelectedTemplate && !isTemplateLoading" class="hint hint--tight">선택된 템플릿 ID: {{ selectedTemplateId }}</div>
</div> </div>
</section> </section>
@@ -2284,11 +2308,11 @@ function userAvatarFallback(user) {
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button> <button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
</div> </div>
<button class="btn btn--ghost" @click="openTemplatePickerModal('tierlists-filter')">주제 선택</button> <button class="btn btn--ghost" @click="openTemplatePickerModal('tierlists-filter')">주제 선택</button>
<div v-if="adminTierListGameId" class="adminSelectionCard"> <div v-if="adminTierListTopicId" class="adminSelectionCard">
<div class="adminSelectionCard__label">필터된 주제</div> <div class="adminSelectionCard__label">필터된 주제</div>
<div class="adminSelectionCard__title">{{ templates.find((template) => template.id === adminTierListGameId)?.name || adminTierListGameId }}</div> <div class="adminSelectionCard__title">{{ templates.find((template) => template.id === adminTierListTopicId)?.name || adminTierListTopicId }}</div>
<div class="adminSelectionCard__meta">{{ adminTierListGameId }}</div> <div class="adminSelectionCard__meta">{{ adminTierListTopicId }}</div>
<button class="btn btn--ghost btn--small" @click="setAdminTierListGameId('')">필터 해제</button> <button class="btn btn--ghost btn--small" @click="setAdminTierListTopicId('')">필터 해제</button>
</div> </div>
<select :value="adminTierListLimit" class="select" @change="changeAdminTierListLimit(Number($event.target.value))"> <select :value="adminTierListLimit" class="select" @change="changeAdminTierListLimit(Number($event.target.value))">
<option :value="50">50개씩 보기</option> <option :value="50">50개씩 보기</option>
@@ -2562,14 +2586,14 @@ function userAvatarFallback(user) {
font-weight: 800; font-weight: 800;
color: var(--theme-text); color: var(--theme-text);
} }
.adminUiScope .adminGamePicker { .adminUiScope .adminTemplatePicker {
display: grid; display: grid;
gap: 8px; gap: 8px;
max-height: 640px; max-height: 640px;
overflow: auto; overflow: auto;
padding-right: 4px; padding-right: 4px;
} }
.adminUiScope .adminGamePicker__item { .adminUiScope .adminTemplatePicker__item {
display: grid; display: grid;
/* gap: 2px; */ /* gap: 2px; */
padding: 11px 12px; padding: 11px 12px;
@@ -2580,32 +2604,32 @@ function userAvatarFallback(user) {
color: var(--theme-text); color: var(--theme-text);
cursor: pointer; cursor: pointer;
} }
.adminUiScope .adminGamePicker__item--active { .adminUiScope .adminTemplatePicker__item--active {
border-color: rgba(77, 127, 233, 0.58); border-color: rgba(77, 127, 233, 0.58);
background: rgba(77, 127, 233, 0.12); background: rgba(77, 127, 233, 0.12);
} }
.adminUiScope .adminGamePicker__item--disabled { .adminUiScope .adminTemplatePicker__item--disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.58; opacity: 0.58;
border-style: dashed; border-style: dashed;
} }
.adminUiScope .adminGamePicker__name { .adminUiScope .adminTemplatePicker__name {
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
} }
.adminUiScope .adminGamePicker__meta { .adminUiScope .adminTemplatePicker__meta {
font-size: 11px; font-size: 11px;
color: var(--theme-text-soft); color: var(--theme-text-soft);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.adminUiScope .adminGamePicker__state { .adminUiScope .adminTemplatePicker__state {
margin-top: 4px; margin-top: 4px;
font-size: 11px; font-size: 11px;
color: var(--theme-text-faint); color: var(--theme-text-faint);
} }
.adminUiScope .gamePickerModalList { .adminUiScope .templatePickerModalList {
margin-top: 14px; margin-top: 14px;
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -2852,16 +2876,16 @@ function userAvatarFallback(user) {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px; gap: 16px;
} }
.adminUiScope .gameManagerGrid { .adminUiScope .templateManagerGrid {
margin-top: 14px; margin-top: 14px;
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px; gap: 16px;
} }
.adminUiScope .gameManagerGrid--single { .adminUiScope .templateManagerGrid--single {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.adminUiScope .gameManagerCard__body { .adminUiScope .templateManagerCard__body {
margin-top: 10px; margin-top: 10px;
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -3027,37 +3051,37 @@ function userAvatarFallback(user) {
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
.adminUiScope .selectedGame__name { .adminUiScope .selectedTemplate__name {
margin-top: 8px; margin-top: 8px;
font-size: 22px; font-size: 22px;
font-weight: 900; font-weight: 900;
} }
.adminUiScope .selectedGame__id { .adminUiScope .selectedTemplate__id {
margin-top: 6px; margin-top: 6px;
opacity: 0.72; opacity: 0.72;
word-break: break-all; word-break: break-all;
} }
.adminUiScope .gameSettingsCard { .adminUiScope .templateSettingsCard {
display: grid; display: grid;
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr); grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
gap: 18px; gap: 18px;
align-items: center; align-items: center;
} }
.adminUiScope .gameSettingsCard__media { .adminUiScope .templateSettingsCard__media {
min-width: 0; min-width: 0;
} }
.adminUiScope .gameSettingsCard__body { .adminUiScope .templateSettingsCard__body {
display: grid; display: grid;
gap: 14px; gap: 14px;
align-content: center; align-content: center;
} }
.adminUiScope .gameSettingsCard__meta { .adminUiScope .templateSettingsCard__meta {
color: var(--theme-text-soft); color: var(--theme-text-soft);
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.5;
word-break: break-all; word-break: break-all;
} }
.adminUiScope .gameSettingsCard__actions { .adminUiScope .templateSettingsCard__actions {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
@@ -3080,11 +3104,11 @@ function userAvatarFallback(user) {
.adminUiScope .selectedThumb--sidebar { .adminUiScope .selectedThumb--sidebar {
width: 100%; width: 100%;
} }
.adminUiScope .selectedGameSidebar__name { .adminUiScope .selectedTemplateSidebar__name {
font-size: 18px; font-size: 18px;
font-weight: 900; font-weight: 900;
} }
.adminUiScope .selectedGameSidebar__id { .adminUiScope .selectedTemplateSidebar__id {
font-size: 12px; font-size: 12px;
opacity: 0.68; opacity: 0.68;
word-break: break-all; word-break: break-all;
@@ -3309,7 +3333,7 @@ function userAvatarFallback(user) {
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
} }
.adminUiScope .thumb--game { .adminUiScope .thumb--template {
max-width: 150px; max-width: 150px;
margin: 0 auto; margin: 0 auto;
display: block; display: block;
@@ -3472,7 +3496,7 @@ function userAvatarFallback(user) {
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
.adminUiScope .customItemModal__createGameButton { .adminUiScope .customItemModal__createTemplateButton {
justify-self: start; justify-self: start;
} }
.adminUiScope .customItemModal__body { .adminUiScope .customItemModal__body {
@@ -3496,6 +3520,19 @@ function userAvatarFallback(user) {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.18) transparent; scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
} }
.adminUiScope .customItemModal__preview {
display: flex;
justify-content: flex-start;
}
.adminUiScope .customItemModal__previewImage {
width: 88px;
height: 88px;
object-fit: cover;
border-radius: 18px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
flex: 0 0 auto;
}
.adminUiScope .customItemModal__pickerPanel::-webkit-scrollbar, .adminUiScope .customItemModal__pickerPanel::-webkit-scrollbar,
.adminUiScope .customItemModal__content::-webkit-scrollbar { .adminUiScope .customItemModal__content::-webkit-scrollbar {
width: 8px; width: 8px;
@@ -4458,8 +4495,8 @@ function userAvatarFallback(user) {
} }
.adminUiScope .featuredOrderPanel, .adminUiScope .featuredOrderPanel,
.adminUiScope .section--topGrid, .adminUiScope .section--topGrid,
.adminUiScope .gameManagerGrid, .adminUiScope .templateManagerGrid,
.adminUiScope .gameSettingsCard, .adminUiScope .templateSettingsCard,
.adminUiScope .toolbar, .adminUiScope .toolbar,
.adminUiScope .itemComposer, .adminUiScope .itemComposer,
.adminUiScope .tierAdminCard, .adminUiScope .tierAdminCard,

View File

@@ -48,7 +48,7 @@ async function loadFavorites() {
} }
function openTierList(tierList) { function openTierList(tierList) {
router.push(editorPath(tierList.gameId, tierList.id)) router.push(editorPath(tierList.topicId, tierList.id))
} }
onMounted(loadFavorites) onMounted(loadFavorites)

View File

@@ -36,8 +36,8 @@ const templates = computed(() => {
async function loadTemplates() { async function loadTemplates() {
try { try {
const data = await api.listGames() const data = await api.listTopics()
templateRecords.value = data.games || [] templateRecords.value = data.topics || []
} catch (e) { } catch (e) {
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.' error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
} }
@@ -60,8 +60,8 @@ async function toggleFavorite(template, event) {
try { try {
loadingFavoriteId.value = template.id loadingFavoriteId.value = template.id
const res = template.isFavorited ? await api.unfavoriteGame(template.id) : await api.favoriteGame(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.game } : entry)) templateRecords.value = templateRecords.value.map((entry) => (entry.id === template.id ? { ...entry, ...(res.topic || {}) } : entry))
} catch (e) { } catch (e) {
error.value = '즐겨찾기 변경에 실패했어요.' error.value = '즐겨찾기 변경에 실패했어요.'
} finally { } finally {

View File

@@ -4,25 +4,20 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { api } from '../lib/api' import { api } from '../lib/api'
import { homePath, mePath } from '../lib/paths' import { homePath, mePath } from '../lib/paths'
import { useToast } from '../composables/useToast'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast()
const email = ref('') const email = ref('')
const nickname = ref('')
const password = ref('') const password = ref('')
const passwordConfirm = ref('') const passwordConfirm = ref('')
const mode = ref('login') const mode = ref('login')
const error = ref('') const error = ref('')
const hasUsers = ref(true) const hasUsers = ref(true)
const emailError = ref('')
watch(error, (message) => { const nicknameError = ref('')
if (!message) return
toast.error(message)
error.value = ''
})
const title = computed(() => (mode.value === 'signup' ? '회원가입' : '로그인')) const title = computed(() => (mode.value === 'signup' ? '회원가입' : '로그인'))
const description = computed(() => const description = computed(() =>
@@ -57,18 +52,59 @@ watch(
{ immediate: true } { immediate: true }
) )
watch(mode, () => {
error.value = ''
emailError.value = ''
nicknameError.value = ''
})
watch(email, () => {
emailError.value = ''
if (error.value === '이메일이 이미 사용 중이에요.') error.value = ''
})
watch(nickname, () => {
nicknameError.value = ''
if (error.value === '닉네임이 이미 사용 중이에요.' || error.value === '사용할 수 없는 닉네임이에요.') error.value = ''
})
async function submit() { async function submit() {
error.value = '' error.value = ''
emailError.value = ''
nicknameError.value = ''
if (mode.value === 'signup' && nickname.value.trim().length < 2) {
nicknameError.value = '닉네임은 2자 이상 입력해주세요.'
error.value = '닉네임을 확인해주세요.'
return
}
if (mode.value === 'signup' && password.value !== passwordConfirm.value) { if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
error.value = '비밀번호 확인이 일치하지 않아요.' error.value = '비밀번호 확인이 일치하지 않아요.'
return return
} }
try { try {
if (mode.value === 'signup') await auth.signup(email.value, password.value) if (mode.value === 'signup') await auth.signup(email.value, nickname.value, password.value)
else await auth.login(email.value, password.value) else await auth.login(email.value, password.value)
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : mePath()) router.push(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
} catch (e) { } catch (e) {
error.value = '로그인/회원가입에 실패했어요.' const code = e?.data?.error
if (mode.value === 'signup') {
if (code === 'email_taken') {
emailError.value = '이미 사용 중인 이메일입니다.'
error.value = '이메일이 이미 사용 중이에요.'
return
}
if (code === 'nickname_taken') {
nicknameError.value = '이미 사용 중인 닉네임입니다.'
error.value = '닉네임이 이미 사용 중이에요.'
return
}
if (code === 'nickname_reserved') {
nicknameError.value = '운영자/관리자처럼 혼동될 수 있는 닉네임은 사용할 수 없어요.'
error.value = '사용할 수 없는 닉네임이에요.'
return
}
}
error.value = mode.value === 'signup' ? '회원가입에 실패했어요.' : '로그인에 실패했어요.'
} }
} }
</script> </script>
@@ -102,9 +138,17 @@ async function submit() {
<label class="field"> <label class="field">
<span class="field__label">이메일</span> <span class="field__label">이메일</span>
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" maxlength="255" /> <input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" maxlength="255" />
<span v-if="emailError" class="field__error">{{ emailError }}</span>
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255</span> <span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255</span>
</label> </label>
<label v-if="mode === 'signup'" class="field">
<span class="field__label">닉네임</span>
<input v-model="nickname" class="field__input" placeholder="사용할 닉네임" autocomplete="nickname" maxlength="40" />
<span v-if="nicknameError" class="field__error">{{ nicknameError }}</span>
<span class="field__hint">다른 사용자와 구분되는 이름으로 2~40자까지 입력할 있어요.</span>
</label>
<label class="field"> <label class="field">
<span class="field__label">비밀번호</span> <span class="field__label">비밀번호</span>
<input <input
@@ -132,6 +176,7 @@ async function submit() {
</label> </label>
<div v-if="!hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div> <div v-if="!hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
<div v-if="error" class="authError">{{ error }}</div>
<div class="authActions"> <div class="authActions">
<button class="secondaryAction" type="button" @click="router.push(homePath())">취소</button> <button class="secondaryAction" type="button" @click="router.push(homePath())">취소</button>
@@ -244,6 +289,12 @@ async function submit() {
color: var(--theme-text-soft); color: var(--theme-text-soft);
} }
.field__error {
font-size: 12px;
color: #ff7b7b;
font-weight: 700;
}
.roleBadge { .roleBadge {
width: fit-content; width: fit-content;
padding: 6px 10px; padding: 6px 10px;
@@ -255,6 +306,16 @@ async function submit() {
font-weight: 700; font-weight: 700;
} }
.authError {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(239, 68, 68, 0.28);
background: rgba(239, 68, 68, 0.1);
color: #ff9b9b;
font-size: 13px;
font-weight: 700;
}
.authActions { .authActions {
display: flex; display: flex;
gap: 12px; gap: 12px;

View File

@@ -60,7 +60,7 @@ onMounted(async () => {
}) })
function openList(t) { function openList(t) {
router.push(editorPath(t.gameId, t.id)) router.push(editorPath(t.topicId, t.id))
} }
</script> </script>

View File

@@ -38,7 +38,7 @@ function tierListThumbnailUrl(tierList) {
} }
function openTierList(tierList) { function openTierList(tierList) {
router.push(editorPath(tierList.gameId, tierList.id)) router.push(editorPath(tierList.topicId, tierList.id))
} }
async function loadResults() { async function loadResults() {

View File

@@ -20,7 +20,7 @@ const auth = useAuthStore()
const toast = useToast() const toast = useToast()
const globalRightRailOpen = inject('rightRailOpen', ref(true)) const globalRightRailOpen = inject('rightRailOpen', ref(true))
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root') const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
const templateId = computed(() => route.params.topicId || route.params.gameId) const templateId = computed(() => route.params.topicId)
const tierListId = computed(() => route.params.tierListId) const tierListId = computed(() => route.params.tierListId)
const previewMode = computed(() => route.query.preview === '1') const previewMode = computed(() => route.query.preview === '1')
const templateName = ref('') const templateName = ref('')
@@ -673,7 +673,7 @@ function buildPayload(existingId) {
const finalTitle = effectiveTitle.value const finalTitle = effectiveTitle.value
return { return {
id: existingId || undefined, id: existingId || undefined,
gameId: templateId.value, topicId: templateId.value,
title: finalTitle, title: finalTitle,
thumbnailSrc: thumbnailSrc.value || '', thumbnailSrc: thumbnailSrc.value || '',
description: (description.value || '').trim(), description: (description.value || '').trim(),
@@ -842,7 +842,7 @@ async function requestTemplate(type) {
await api.requestTierListTemplate({ await api.requestTierListTemplate({
type, type,
sourceTierListId: sourceId, sourceTierListId: sourceId,
gameId: templateId.value, topicId: templateId.value,
requestTitle: templateRequestDraftTitle.value.trim(), requestTitle: templateRequestDraftTitle.value.trim(),
requestDescription: templateRequestDraftDescription.value.trim(), requestDescription: templateRequestDraftDescription.value.trim(),
thumbnailSrc: saved.tierList?.thumbnailSrc || thumbnailSrc.value || '', thumbnailSrc: saved.tierList?.thumbnailSrc || thumbnailSrc.value || '',
@@ -898,13 +898,13 @@ onMounted(() => {
} }
try { try {
const gameRes = await api.getGame(templateId.value) const topicRes = await api.getTopic(templateId.value)
templateName.value = gameRes.game?.name || templateId.value templateName.value = topicRes.topic?.name || templateId.value
const base = (gameRes.items || []).map((img) => ({ const base = (topicRes.items || []).map((img) => ({
id: img.id, id: img.id,
src: img.src, src: img.src,
label: img.label, label: img.label,
origin: 'game', origin: 'template',
})) }))
const map = {} const map = {}
base.forEach((it) => (map[it.id] = it)) base.forEach((it) => (map[it.id] = it))

View File

@@ -10,7 +10,7 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const topicId = computed(() => route.params.topicId || route.params.gameId) const topicId = computed(() => route.params.topicId)
const topicName = ref('') const topicName = ref('')
const tierLists = ref([]) const tierLists = ref([])
const error = ref('') const error = ref('')
@@ -53,11 +53,11 @@ function handleThumbnailError(tierListId) {
async function loadTierLists() { async function loadTierLists() {
isTopicLoading.value = true isTopicLoading.value = true
try { try {
const [gameRes, listRes] = await Promise.all([ const [topicRes, listRes] = await Promise.all([
api.getGame(topicId.value), api.getTopic(topicId.value),
api.searchPublicTierLists(topicId.value, query.value), api.searchPublicTierListsByTopic(topicId.value, query.value),
]) ])
topicName.value = gameRes.game?.name || '' topicName.value = topicRes.topic?.name || ''
brokenThumbnailIds.value = {} brokenThumbnailIds.value = {}
tierLists.value = listRes.tierLists || [] tierLists.value = listRes.tierLists || []
} catch (e) { } catch (e) {