Compare commits

...

11 Commits

28 changed files with 622 additions and 624 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 topicsRoutes = 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 })
}) })
@@ -80,7 +80,6 @@ app.use(async (req, res, next) => {
}) })
app.use('/api/auth', authRoutes) app.use('/api/auth', authRoutes)
app.use('/api/games', topicsRoutes)
app.use('/api/topics', topicsRoutes) 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 }

View File

@@ -9,7 +9,6 @@ const DB_PASSWORD = process.env.DB_PASSWORD || ''
const DB_NAME = process.env.DB_NAME || 'tier_cursor' const DB_NAME = process.env.DB_NAME || 'tier_cursor'
const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10 const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10
const FREEFORM_TOPIC_ID = 'freeform' const FREEFORM_TOPIC_ID = 'freeform'
const FREEFORM_GAME_ID = FREEFORM_TOPIC_ID
let poolPromise = null let poolPromise = null
let initPromise = null let initPromise = null
@@ -68,7 +67,7 @@ function mapUserRow(row) {
} }
} }
function mapGameRow(row) { function mapTopicRow(row) {
if (!row) return null if (!row) return null
return { return {
id: row.id, id: row.id,
@@ -82,12 +81,11 @@ function mapGameRow(row) {
} }
} }
function mapGameItemRow(row) { function mapTopicItemRow(row) {
if (!row) return null if (!row) return null
return { return {
id: row.id, id: row.id,
topicId: row.topic_id, topicId: row.topic_id,
gameId: row.topic_id,
src: row.src, src: row.src,
label: row.label, label: row.label,
displayOrder: row.display_order == null ? null : Number(row.display_order), displayOrder: row.display_order == null ? null : Number(row.display_order),
@@ -137,8 +135,6 @@ function mapTierListRow(row) {
authorAvatarSrc: row.avatar_src || '', authorAvatarSrc: row.avatar_src || '',
topicId: row.topic_id, topicId: row.topic_id,
topicName: row.topic_name || '', topicName: row.topic_name || '',
gameId: row.topic_id,
gameName: row.topic_name || '',
title: row.title, title: row.title,
thumbnailSrc: row.thumbnail_src || '', thumbnailSrc: row.thumbnail_src || '',
description: row.description || '', description: row.description || '',
@@ -167,15 +163,11 @@ function mapTemplateRequestRow(row) {
sourceTierListId: row.source_tierlist_id || '', sourceTierListId: row.source_tierlist_id || '',
sourceTopicId: row.source_topic_id, sourceTopicId: row.source_topic_id,
sourceTopicName: row.source_topic_name || '', sourceTopicName: row.source_topic_name || '',
sourceGameId: row.source_topic_id,
sourceGameName: row.source_topic_name || '',
sourceTierListTitle: row.title_snapshot || '', sourceTierListTitle: row.title_snapshot || '',
sourceDescription: row.description_snapshot || '', sourceDescription: row.description_snapshot || '',
thumbnailSrc: row.thumbnail_src_snapshot || '', thumbnailSrc: row.thumbnail_src_snapshot || '',
targetTopicId: row.target_topic_id || '', targetTopicId: row.target_topic_id || '',
targetTopicName: row.target_topic_name || '', targetTopicName: row.target_topic_name || '',
targetGameId: row.target_topic_id || '',
targetGameName: row.target_topic_name || '',
status: row.status, status: row.status,
items: parseJson(row.items_json, []), items: parseJson(row.items_json, []),
snapshotGroups: parseJson(row.groups_json, []), snapshotGroups: parseJson(row.groups_json, []),
@@ -277,10 +269,6 @@ async function closePool() {
async function ensureSchema() { async function ensureSchema() {
if (initPromise) return initPromise if (initPromise) return initPromise
initPromise = (async () => { initPromise = (async () => {
const legacyGamesExists = await tableExists('games')
const legacyGameItemsExists = await tableExists('game_items')
const legacyFavoriteGamesExists = await tableExists('favorite_games')
await query(` await query(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
@@ -304,8 +292,8 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`) `)
const gameIsPublicColumns = await query("SHOW COLUMNS FROM topics LIKE 'is_public'") const topicIsPublicColumns = await query("SHOW COLUMNS FROM topics LIKE 'is_public'")
if (!gameIsPublicColumns.length) { if (!topicIsPublicColumns.length) {
await query('ALTER TABLE topics ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src') await query('ALTER TABLE topics ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src')
await query('UPDATE topics SET is_public = 1 WHERE is_public IS NULL') await query('UPDATE topics SET is_public = 1 WHERE is_public IS NULL')
} }
@@ -315,14 +303,6 @@ async function ensureSchema() {
await query('ALTER TABLE topics ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src') await query('ALTER TABLE topics ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
} }
if (legacyGamesExists) {
await query(`
INSERT IGNORE INTO topics (id, name, thumbnail_src, is_public, display_rank, created_at)
SELECT id, name, thumbnail_src, COALESCE(is_public, 1), display_rank, created_at
FROM games
`)
}
await query(` await query(`
CREATE TABLE IF NOT EXISTS topic_items ( CREATE TABLE IF NOT EXISTS topic_items (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
@@ -336,22 +316,11 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`) `)
const gameItemDisplayOrderColumns = await query("SHOW COLUMNS FROM topic_items LIKE 'display_order'") const topicItemDisplayOrderColumns = await query("SHOW COLUMNS FROM topic_items LIKE 'display_order'")
if (!gameItemDisplayOrderColumns.length) { if (!topicItemDisplayOrderColumns.length) {
await query('ALTER TABLE topic_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label') await query('ALTER TABLE topic_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label')
} }
if (legacyGameItemsExists) {
const legacyItemDisplayOrderColumns = await query("SHOW COLUMNS FROM game_items LIKE 'display_order'")
await query(
`
INSERT IGNORE INTO topic_items (id, topic_id, src, label, display_order, created_at)
SELECT id, game_id, src, label, ${legacyItemDisplayOrderColumns.length ? 'display_order' : 'NULL'}, created_at
FROM game_items
`
)
}
await query(` await query(`
CREATE TABLE IF NOT EXISTS custom_items ( CREATE TABLE IF NOT EXISTS custom_items (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
@@ -414,14 +383,6 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`) `)
if (legacyFavoriteGamesExists) {
await query(`
INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at)
SELECT user_id, game_id, created_at
FROM favorite_games
`)
}
await query(` await query(`
CREATE TABLE IF NOT EXISTS image_assets ( CREATE TABLE IF NOT EXISTS image_assets (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
@@ -494,16 +455,10 @@ async function ensureSchema() {
const hasSourceTopicId = await columnExists('template_requests', 'source_topic_id') const hasSourceTopicId = await columnExists('template_requests', 'source_topic_id')
if (!hasSourceTopicId) { if (!hasSourceTopicId) {
await query("ALTER TABLE template_requests ADD COLUMN source_topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id") await query("ALTER TABLE template_requests ADD COLUMN source_topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
if (await columnExists('template_requests', 'source_game_id')) {
await query('UPDATE template_requests SET source_topic_id = source_game_id WHERE source_topic_id = ?', [FREEFORM_TOPIC_ID])
}
} }
const hasTargetTopicId = await columnExists('template_requests', 'target_topic_id') const hasTargetTopicId = await columnExists('template_requests', 'target_topic_id')
if (!hasTargetTopicId) { if (!hasTargetTopicId) {
await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_topic_id") await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_topic_id")
if (await columnExists('template_requests', 'target_game_id')) {
await query("UPDATE template_requests SET target_topic_id = target_game_id WHERE target_topic_id = ''")
}
} }
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'") const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
if (!templateRequestStatusColumns.length) { if (!templateRequestStatusColumns.length) {
@@ -529,9 +484,6 @@ async function ensureSchema() {
const tierListTopicIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'topic_id'") const tierListTopicIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'topic_id'")
if (!tierListTopicIdColumns.length) { if (!tierListTopicIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER author_id") await query("ALTER TABLE tierlists ADD COLUMN topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER author_id")
if (await columnExists('tierlists', 'game_id')) {
await query('UPDATE tierlists SET topic_id = game_id WHERE topic_id = ?', [FREEFORM_TOPIC_ID])
}
} }
const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'") const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'")
if (!tierListShowNamesColumns.length) { if (!tierListShowNamesColumns.length) {
@@ -575,7 +527,7 @@ async function ensureSchema() {
(?, ?, ?, ?), (?, ?, ?, ?),
(?, ?, ?, ?) (?, ?, ?, ?)
`, `,
['example-game', '예시 게임', '', createdAt, 'another-game', '다른 예시 게임', '', createdAt] ['example-topic', '예시 주제', '', createdAt, 'another-topic', '다른 예시 주제', '', createdAt]
) )
await query( await query(
@@ -587,12 +539,12 @@ async function ensureSchema() {
`, `,
[ [
'img-1', 'img-1',
'example-game', 'example-topic',
'/uploads/seeds/example1.png', '/uploads/seeds/example1.png',
'샘플 1', '샘플 1',
createdAt, createdAt,
'img-2', 'img-2',
'example-game', 'example-topic',
'/uploads/seeds/example2.png', '/uploads/seeds/example2.png',
'샘플 2', '샘플 2',
createdAt, createdAt,
@@ -753,7 +705,7 @@ async function listTopics(currentUserId = '', options = {}) {
`, `,
[FREEFORM_TOPIC_ID] [FREEFORM_TOPIC_ID]
) )
const topics = rows.map(mapGameRow) const topics = rows.map(mapTopicRow)
if (!currentUserId) return topics.map((topic) => ({ ...topic, isFavorited: false })) if (!currentUserId) return topics.map((topic) => ({ ...topic, isFavorited: false }))
const favoriteRows = await query('SELECT topic_id FROM favorite_topics WHERE user_id = ?', [currentUserId]) const favoriteRows = await query('SELECT topic_id FROM favorite_topics WHERE user_id = ?', [currentUserId])
@@ -766,7 +718,7 @@ async function listTopics(currentUserId = '', options = {}) {
async function findTopicById(id) { async function findTopicById(id) {
const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1', [id]) const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1', [id])
return mapGameRow(rows[0]) return mapTopicRow(rows[0])
} }
async function listTopicItems(topicId) { async function listTopicItems(topicId) {
@@ -783,19 +735,19 @@ async function listTopicItems(topicId) {
`, `,
[topicId] [topicId]
) )
return rows.map(mapGameItemRow) return rows.map(mapTopicItemRow)
} }
async function findTopicItemById(itemId) { async function findTopicItemById(itemId) {
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId]) const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
return mapGameItemRow(rows[0]) return mapTopicItemRow(rows[0])
} }
async function getTopicDetail(topicId) { async function getTopicDetail(topicId) {
const topic = await findTopicById(topicId) const topic = await findTopicById(topicId)
if (!topic) return null if (!topic) return null
const items = await listTopicItems(topicId) const items = await listTopicItems(topicId)
return { topic, game: topic, items } return { topic, template: topic, items }
} }
async function createTopic({ id, name, isPublic = true }) { async function createTopic({ id, name, isPublic = true }) {
@@ -916,7 +868,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
const referencedSrcs = new Set() const referencedSrcs = new Set()
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([ const [userRows, topicRows, topicItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"), query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"), query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT src FROM topic_items WHERE src <> ''"), query("SELECT src FROM topic_items WHERE src <> ''"),
@@ -926,8 +878,8 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
]) ])
for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src) for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src)
for (const row of gameRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src) for (const row of topicRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
for (const row of gameItemRows) if (row.src) referencedSrcs.add(row.src) for (const row of topicItemRows) if (row.src) referencedSrcs.add(row.src)
for (const row of customItemRows) if (row.src) referencedSrcs.add(row.src) for (const row of customItemRows) if (row.src) referencedSrcs.add(row.src)
for (const row of tierListRows) { for (const row of tierListRows) {
@@ -969,7 +921,7 @@ async function listReferencedUploadUsage() {
usageMap.get(src).add(role) usageMap.get(src).add(role)
} }
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([ const [userRows, topicRows, topicItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"), query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"), query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT src FROM topic_items WHERE src <> ''"), query("SELECT src FROM topic_items WHERE src <> ''"),
@@ -979,8 +931,8 @@ async function listReferencedUploadUsage() {
]) ])
for (const row of userRows) addUsage(row.avatar_src, 'avatar') for (const row of userRows) addUsage(row.avatar_src, 'avatar')
for (const row of gameRows) addUsage(row.thumbnail_src, 'game-thumbnail') for (const row of topicRows) addUsage(row.thumbnail_src, 'topic-thumbnail')
for (const row of gameItemRows) addUsage(row.src, 'game-item') for (const row of topicItemRows) addUsage(row.src, 'topic-item')
for (const row of customItemRows) addUsage(row.src, 'custom-item') for (const row of customItemRows) addUsage(row.src, 'custom-item')
for (const row of tierListRows) { for (const row of tierListRows) {
@@ -1012,14 +964,14 @@ function replaceItemSrc(items, fromSrc, toSrc) {
async function replaceUploadSourceReferences({ fromSrc, toSrc }) { async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 } if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 }
const [userResult, gameResult, gameItemResult, customItemResult] = await Promise.all([ const [userResult, topicResult, topicItemResult, customItemResult] = await Promise.all([
query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]), query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
query('UPDATE topics SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]), query('UPDATE topics SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
query('UPDATE topic_items SET src = ? WHERE src = ?', [toSrc, fromSrc]), query('UPDATE topic_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]), query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
]) ])
let updatedRows = Number(userResult.affectedRows || 0) + Number(gameResult.affectedRows || 0) + Number(gameItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0) let updatedRows = Number(userResult.affectedRows || 0) + Number(topicResult.affectedRows || 0) + Number(topicItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0)
const tierListRows = await query('SELECT id, thumbnail_src, pool_json FROM tierlists') const tierListRows = await query('SELECT id, thumbnail_src, pool_json FROM tierlists')
for (const row of tierListRows) { for (const row of tierListRows) {
@@ -1168,16 +1120,16 @@ function stripMissingItems(items, missingItemIds, missingSrcs) {
async function cleanupMissingUploadReferences() { async function cleanupMissingUploadReferences() {
const stats = { const stats = {
clearedAvatars: 0, clearedAvatars: 0,
clearedGameThumbnails: 0, clearedTopicThumbnails: 0,
clearedTierListThumbnails: 0, clearedTierListThumbnails: 0,
clearedTemplateRequestThumbnails: 0, clearedTemplateRequestThumbnails: 0,
deletedGameItems: 0, deletedTopicItems: 0,
updatedTierLists: 0, updatedTierLists: 0,
updatedTemplateRequests: 0, updatedTemplateRequests: 0,
deletedCustomItems: 0, deletedCustomItems: 0,
} }
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([ const [userRows, topicRows, topicItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
query("SELECT id, avatar_src FROM users WHERE avatar_src <> ''"), query("SELECT id, avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT id, thumbnail_src FROM topics WHERE thumbnail_src <> ''"), query("SELECT id, thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT id, src FROM topic_items WHERE src <> ''"), query("SELECT id, src FROM topic_items WHERE src <> ''"),
@@ -1192,16 +1144,16 @@ async function cleanupMissingUploadReferences() {
stats.clearedAvatars += 1 stats.clearedAvatars += 1
} }
for (const row of gameRows) { for (const row of topicRows) {
if (await fileExistsForUploadSrc(row.thumbnail_src)) continue if (await fileExistsForUploadSrc(row.thumbnail_src)) continue
await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', ['', row.id]) await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', ['', row.id])
stats.clearedGameThumbnails += 1 stats.clearedTopicThumbnails += 1
} }
for (const row of gameItemRows) { for (const row of topicItemRows) {
if (await fileExistsForUploadSrc(row.src)) continue if (await fileExistsForUploadSrc(row.src)) continue
await deleteGameItem(row.id) await deleteTopicItem(row.id)
stats.deletedGameItems += 1 stats.deletedTopicItems += 1
} }
const missingCustomItemIds = new Set() const missingCustomItemIds = new Set()
@@ -1347,28 +1299,27 @@ async function clearImageOptimizationJobs({ month } = {}) {
const result = await query('DELETE FROM image_optimization_jobs') const result = await query('DELETE FROM image_optimization_jobs')
return Number(result.affectedRows || 0) return Number(result.affectedRows || 0)
} }
async function createTopicItem({ id, topicId, gameId = topicId, src, label }) { async function createTopicItem({ id, topicId, src, label }) {
const createdAt = now() const createdAt = now()
const resolvedTopicId = topicId || gameId const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM topic_items WHERE topic_id = ?', [topicId])
const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM topic_items WHERE topic_id = ?', [resolvedTopicId])
const nextDisplayOrder = const nextDisplayOrder =
minOrderRows[0]?.min_display_order == null ? 0 : Number(minOrderRows[0].min_display_order) - 1 minOrderRows[0]?.min_display_order == null ? 0 : Number(minOrderRows[0].min_display_order) - 1
await query('INSERT INTO topic_items (id, topic_id, src, label, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?)', [ await query('INSERT INTO topic_items (id, topic_id, src, label, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
id, id,
resolvedTopicId, topicId,
src, src,
label, label,
nextDisplayOrder, nextDisplayOrder,
createdAt, createdAt,
]) ])
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [id]) const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [id])
return mapGameItemRow(rows[0]) return mapTopicItemRow(rows[0])
} }
async function updateTopicItemLabel(itemId, label) { async function updateTopicItemLabel(itemId, label) {
await query('UPDATE topic_items SET label = ? WHERE id = ?', [label, itemId]) await query('UPDATE topic_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId]) const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
return mapGameItemRow(rows[0]) return mapTopicItemRow(rows[0])
} }
async function updateTopicItemDisplayOrder(topicId, itemIds) { async function updateTopicItemDisplayOrder(topicId, itemIds) {
@@ -1456,8 +1407,8 @@ async function updateTopicDisplayOrder(topicIds) {
await query('UPDATE topics SET display_rank = NULL WHERE id <> ?', [FREEFORM_TOPIC_ID]) await query('UPDATE topics SET display_rank = NULL WHERE id <> ?', [FREEFORM_TOPIC_ID])
await Promise.all( await Promise.all(
normalizedIds.map((gameId, index) => normalizedIds.map((topicId, index) =>
query('UPDATE topics SET display_rank = ? WHERE id = ? AND id <> ?', [index + 1, gameId, FREEFORM_TOPIC_ID]) query('UPDATE topics SET display_rank = ? WHERE id = ? AND id <> ?', [index + 1, topicId, FREEFORM_TOPIC_ID])
) )
) )
@@ -1476,22 +1427,6 @@ async function createCustomItem({ id, ownerId, src, label }) {
return { id, ownerId, src, label, origin: 'custom', createdAt } return { id, ownerId, src, label, origin: 'custom', createdAt }
} }
const listGames = listTopics
const findGameById = findTopicById
const listGameItems = listTopicItems
const findGameItemById = findTopicItemById
const getGameDetail = getTopicDetail
const createGame = createTopic
const updateGameThumbnail = updateTopicThumbnail
const updateGameVisibility = updateTopicVisibility
const createGameItem = createTopicItem
const updateGameItemLabel = updateTopicItemLabel
const updateGameItemDisplayOrder = updateTopicItemDisplayOrder
const countTierListsUsingGameItem = countTierListsUsingTopicItem
const deleteGameItem = deleteTopicItem
const deleteGame = deleteTopic
const updateGameDisplayOrder = updateTopicDisplayOrder
async function syncOwnedCustomItemLabels({ ownerId, items }) { async function syncOwnedCustomItemLabels({ ownerId, items }) {
const customItems = Array.from( const customItems = Array.from(
new Map( new Map(
@@ -1541,7 +1476,7 @@ async function getCustomItemUsageMeta() {
` `
) )
const usageMap = new Map() const usageMap = new Map()
const linkedGamesMap = new Map() const linkedTemplatesMap = new Map()
rows.forEach((row) => { rows.forEach((row) => {
const groups = parseJson(row.groups_json, []) const groups = parseJson(row.groups_json, [])
@@ -1565,8 +1500,8 @@ async function getCustomItemUsageMeta() {
if (!row.topic_id) return if (!row.topic_id) return
seenItemIds.forEach((itemId) => { seenItemIds.forEach((itemId) => {
if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map()) if (!linkedTemplatesMap.has(itemId)) linkedTemplatesMap.set(itemId, new Map())
linkedGamesMap.get(itemId).set(row.topic_id, { linkedTemplatesMap.get(itemId).set(row.topic_id, {
id: row.topic_id, id: row.topic_id,
name: row.topic_name || row.topic_id, name: row.topic_name || row.topic_id,
}) })
@@ -1575,7 +1510,7 @@ async function getCustomItemUsageMeta() {
return { return {
usageMap, usageMap,
linkedGamesMap: new Map(Array.from(linkedGamesMap.entries()).map(([itemId, gameMap]) => [itemId, Array.from(gameMap.values())])), linkedTemplatesMap: new Map(Array.from(linkedTemplatesMap.entries()).map(([itemId, templateMap]) => [itemId, Array.from(templateMap.values())])),
} }
} }
@@ -1586,7 +1521,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
const hasQuery = !!searchText const hasQuery = !!searchText
const search = `%${searchText}%` const search = `%${searchText}%`
const [customRows, gameItemRows, assetRows, usageMeta] = await Promise.all([ const [customRows, topicItemRows, assetRows, usageMeta] = await Promise.all([
query( query(
` `
SELECT SELECT
@@ -1634,7 +1569,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
]) ])
const templateLinkedBySrc = new Map() const templateLinkedBySrc = new Map()
gameItemRows.forEach((row) => { topicItemRows.forEach((row) => {
if (!row?.src) return if (!row?.src) return
if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map()) if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map())
templateLinkedBySrc.get(row.src).set(row.topic_id, { templateLinkedBySrc.get(row.src).set(row.topic_id, {
@@ -1644,7 +1579,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
}) })
const customItems = customRows.map((row) => { const customItems = customRows.map((row) => {
const linkedGames = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()) const linkedTemplates = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values())
return { return {
id: row.id, id: row.id,
ownerId: row.owner_id, ownerId: row.owner_id,
@@ -1654,14 +1589,14 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerName: row.nickname || row.email, ownerName: row.nickname || row.email,
ownerEmail: row.email, ownerEmail: row.email,
usageCount: usageMeta.usageMap.get(row.id) || 0, usageCount: usageMeta.usageMap.get(row.id) || 0,
linkedGames, linkedTemplates,
sourceType: 'user', sourceType: 'user',
sourceLabel: '사용자 업로드', sourceLabel: '사용자 업로드',
canDelete: true, canDelete: true,
} }
}) })
const templateSrcSet = new Set(gameItemRows.map((row) => row.src).filter(Boolean)) const templateSrcSet = new Set(topicItemRows.map((row) => row.src).filter(Boolean))
const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean)) const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean))
const assetLibraryItems = assetRows const assetLibraryItems = assetRows
.filter((row) => row?.src && !templateSrcSet.has(row.src) && !customSrcSet.has(row.src)) .filter((row) => row?.src && !templateSrcSet.has(row.src) && !customSrcSet.has(row.src))
@@ -1675,16 +1610,16 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerName: '관리자 보관 자산', ownerName: '관리자 보관 자산',
ownerEmail: '', ownerEmail: '',
usageCount: 0, usageCount: 0,
linkedGames: [], linkedTemplates: [],
sourceType: 'template', sourceType: 'template',
sourceLabel: '관리자 템플릿', sourceLabel: '관리자 템플릿',
canDelete: true, canDelete: true,
sourceGameId: '', sourceTopicId: '',
sourceGameName: '', sourceTopicName: '',
isAssetLibraryItem: true, isAssetLibraryItem: true,
})) }))
const templateItems = gameItemRows.map((row) => ({ const templateItems = topicItemRows.map((row) => ({
id: row.id, id: row.id,
ownerId: '', ownerId: '',
src: row.src, src: row.src,
@@ -1693,12 +1628,12 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerName: row.topic_name || row.topic_id, ownerName: row.topic_name || row.topic_id,
ownerEmail: '', ownerEmail: '',
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size, usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
linkedGames: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()), linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
sourceType: 'template', sourceType: 'template',
sourceLabel: '관리자 템플릿', sourceLabel: '관리자 템플릿',
canDelete: true, canDelete: true,
sourceGameId: row.topic_id, sourceTopicId: row.topic_id,
sourceGameName: row.topic_name || row.topic_id, sourceTopicName: row.topic_name || row.topic_id,
})) }))
const baseItems = [...customItems, ...templateItems, ...assetLibraryItems] const baseItems = [...customItems, ...templateItems, ...assetLibraryItems]
@@ -1712,7 +1647,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
const allItems = baseItems const allItems = baseItems
.map((item) => { .map((item) => {
const siblings = groupedBySrc.get(item.src) || [item] const siblings = groupedBySrc.get(item.src) || [item]
const linkedGames = new Map() const linkedTemplates = new Map()
let userReferenceCount = 0 let userReferenceCount = 0
let templateReferenceCount = 0 let templateReferenceCount = 0
let assetReferenceCount = 0 let assetReferenceCount = 0
@@ -1721,8 +1656,8 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
if (entry.sourceType === 'user') userReferenceCount += 1 if (entry.sourceType === 'user') userReferenceCount += 1
else if (entry.isAssetLibraryItem) assetReferenceCount += 1 else if (entry.isAssetLibraryItem) assetReferenceCount += 1
else templateReferenceCount += 1 else templateReferenceCount += 1
;(entry.linkedGames || []).forEach((game) => { ;(entry.linkedTemplates || []).forEach((template) => {
if (game?.id) linkedGames.set(game.id, game) if (template?.id) linkedTemplates.set(template.id, template)
}) })
}) })
@@ -1732,7 +1667,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
sharedUserReferenceCount: userReferenceCount, sharedUserReferenceCount: userReferenceCount,
sharedTemplateReferenceCount: templateReferenceCount, sharedTemplateReferenceCount: templateReferenceCount,
sharedAssetReferenceCount: assetReferenceCount, sharedAssetReferenceCount: assetReferenceCount,
sharedLinkedGameCount: linkedGames.size, sharedLinkedTemplateCount: linkedTemplates.size,
sharedEntries: siblings sharedEntries: siblings
.slice() .slice()
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0)) .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
@@ -1743,10 +1678,10 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
sourceType: entry.sourceType, sourceType: entry.sourceType,
ownerName: entry.ownerName, ownerName: entry.ownerName,
createdAt: entry.createdAt, createdAt: entry.createdAt,
sourceGameId: entry.sourceGameId || '', sourceTopicId: entry.sourceTopicId || '',
sourceGameName: entry.sourceGameName || '', sourceTopicName: entry.sourceTopicName || '',
usageCount: entry.usageCount || 0, usageCount: entry.usageCount || 0,
linkedGames: entry.linkedGames || [], linkedTemplates: entry.linkedTemplates || [],
isAssetLibraryItem: !!entry.isAssetLibraryItem, isAssetLibraryItem: !!entry.isAssetLibraryItem,
})), })),
} }
@@ -1760,7 +1695,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
case 'asset': case 'asset':
return !!item.isAssetLibraryItem return !!item.isAssetLibraryItem
case 'unused-user': case 'unused-user':
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0 return item.sourceType === 'user' && item.usageCount === 0 && item.linkedTemplates.length === 0
case 'unused-admin': case 'unused-admin':
return !!item.isAssetLibraryItem return !!item.isAssetLibraryItem
default: default:
@@ -1902,7 +1837,6 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
const tierLists = rows.map((row) => ({ const tierLists = rows.map((row) => ({
id: row.id, id: row.id,
topicId: row.topic_id, topicId: row.topic_id,
gameId: row.topic_id,
title: row.title, title: row.title,
thumbnailSrc: row.thumbnail_src || '', thumbnailSrc: row.thumbnail_src || '',
createdAt: Number(row.created_at), createdAt: Number(row.created_at),
@@ -2011,7 +1945,6 @@ async function listUserTierLists(userId) {
const tierLists = rows.map((row) => ({ const tierLists = rows.map((row) => ({
id: row.id, id: row.id,
topicId: row.topic_id, topicId: row.topic_id,
gameId: row.topic_id,
title: row.title, title: row.title,
thumbnailSrc: row.thumbnail_src || '', thumbnailSrc: row.thumbnail_src || '',
createdAt: Number(row.created_at), createdAt: Number(row.created_at),
@@ -2037,7 +1970,7 @@ function uniqueTierListItems(poolItems) {
id: item.id, id: item.id,
src: item.src || '', src: item.src || '',
label: item.label || 'item', label: item.label || 'item',
origin: item.origin || 'game', origin: item.origin || 'template',
}) })
}) })
return Array.from(map.values()) return Array.from(map.values())
@@ -2057,17 +1990,17 @@ function getAutoThumbnailSrc(groups = [], pool = []) {
return fallbackItem?.src || '' return fallbackItem?.src || ''
} }
async function listAdminTierLists({ queryText = '', gameId = '', topicId = '', page = 1, limit = 50, currentUserId = '' } = {}) { async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limit = 50, currentUserId = '' } = {}) {
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200) const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
const normalizedPage = Math.max(Number(page) || 1, 1) const normalizedPage = Math.max(Number(page) || 1, 1)
const hasQuery = !!(queryText || '').trim() const hasQuery = !!(queryText || '').trim()
const resolvedTopicId = (topicId || gameId || '').trim() const resolvedTopicId = (topicId || '').trim()
const hasGameId = !!resolvedTopicId const hasTopicId = !!resolvedTopicId
const search = `%${(queryText || '').trim()}%` const search = `%${(queryText || '').trim()}%`
const whereParts = [] const whereParts = []
const params = [] const params = []
if (hasGameId) { if (hasTopicId) {
whereParts.push('t.topic_id = ?') whereParts.push('t.topic_id = ?')
params.push(resolvedTopicId) params.push(resolvedTopicId)
} }
@@ -2144,15 +2077,15 @@ async function listAdminTierLists({ queryText = '', gameId = '', topicId = '', p
} }
} }
async function summarizeAdminTierLists({ queryText = '', gameId = '', topicId = '' } = {}) { async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
const hasQuery = !!(queryText || '').trim() const hasQuery = !!(queryText || '').trim()
const resolvedTopicId = (topicId || gameId || '').trim() const resolvedTopicId = (topicId || '').trim()
const hasGameId = !!resolvedTopicId const hasTopicId = !!resolvedTopicId
const search = `%${(queryText || '').trim()}%` const search = `%${(queryText || '').trim()}%`
const whereParts = [] const whereParts = []
const params = [] const params = []
if (hasGameId) { if (hasTopicId) {
whereParts.push('t.topic_id = ?') whereParts.push('t.topic_id = ?')
params.push(resolvedTopicId) params.push(resolvedTopicId)
} }
@@ -2245,10 +2178,8 @@ async function createTemplateRequest({
type, type,
requesterId, requesterId,
sourceTierListId = '', sourceTierListId = '',
sourceGameId, sourceTopicId,
targetGameId = '', targetTopicId = '',
sourceTopicId = sourceGameId,
targetTopicId = targetGameId,
title, title,
description = '', description = '',
thumbnailSrc = '', thumbnailSrc = '',
@@ -2401,8 +2332,8 @@ async function updateTemplateRequestStatus({ id, status }) {
return findTemplateRequestById(id) return findTemplateRequestById(id)
} }
async function updateTemplateRequestTargetGame({ id, targetGameId }) { async function updateTemplateRequestTargetTopic({ id, targetTopicId }) {
await query('UPDATE template_requests SET target_topic_id = ?, updated_at = ? WHERE id = ?', [targetGameId || '', now(), id]) await query('UPDATE template_requests SET target_topic_id = ?, updated_at = ? WHERE id = ?', [targetTopicId || '', now(), id])
return findTemplateRequestById(id) return findTemplateRequestById(id)
} }
@@ -2452,8 +2383,7 @@ async function deleteCustomItems(ids) {
async function saveTierList({ async function saveTierList({
id, id,
authorId, authorId,
gameId, topicId,
topicId = gameId,
title, title,
thumbnailSrc = '', thumbnailSrc = '',
description, description,
@@ -2504,8 +2434,7 @@ async function duplicateTierListForUser({ tierList, targetUserId }) {
return saveTierList({ return saveTierList({
id: duplicateId, id: duplicateId,
authorId: targetUserId, authorId: targetUserId,
gameId: tierList.topicId || tierList.gameId, topicId: tierList.topicId,
topicId: tierList.topicId || tierList.gameId,
title: copyTitle, title: copyTitle,
thumbnailSrc: tierList.thumbnailSrc || '', thumbnailSrc: tierList.thumbnailSrc || '',
description: tierList.description || '', description: tierList.description || '',
@@ -2528,19 +2457,14 @@ async function unfavoriteTierList({ userId, tierListId }) {
await query('DELETE FROM favorite_tierlists WHERE user_id = ? AND tierlist_id = ?', [userId, tierListId]) await query('DELETE FROM favorite_tierlists WHERE user_id = ? AND tierlist_id = ?', [userId, tierListId])
} }
async function favoriteTopic({ userId, topicId, gameId = topicId }) { async function favoriteTopic({ userId, topicId }) {
const resolvedTopicId = topicId || gameId await query('INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at) VALUES (?, ?, ?)', [userId, topicId, now()])
await query('INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at) VALUES (?, ?, ?)', [userId, resolvedTopicId, now()])
} }
async function unfavoriteTopic({ userId, topicId, gameId = topicId }) { async function unfavoriteTopic({ userId, topicId }) {
const resolvedTopicId = topicId || gameId await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, topicId])
await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, resolvedTopicId])
} }
const favoriteGame = favoriteTopic
const unfavoriteGame = unfavoriteTopic
module.exports = { module.exports = {
DB_NAME, DB_NAME,
ensureData, ensureData,
@@ -2563,14 +2487,6 @@ module.exports = {
createTopic, createTopic,
updateTopicThumbnail, updateTopicThumbnail,
updateTopicVisibility, updateTopicVisibility,
listGames,
findGameById,
listGameItems,
findGameItemById,
getGameDetail,
createGame,
updateGameThumbnail,
updateGameVisibility,
findImageAssetByHash, findImageAssetByHash,
findImageAssetBySrc, findImageAssetBySrc,
findImageAssetById, findImageAssetById,
@@ -2594,15 +2510,8 @@ module.exports = {
deleteTopicItem, deleteTopicItem,
deleteTopic, deleteTopic,
updateTopicDisplayOrder, updateTopicDisplayOrder,
createGameItem,
updateGameItemLabel,
updateGameItemDisplayOrder,
countTierListsUsingGameItem,
updateCustomItemLabel, updateCustomItemLabel,
updateImageAssetLabel, updateImageAssetLabel,
deleteGameItem,
deleteGame,
updateGameDisplayOrder,
createCustomItem, createCustomItem,
findCustomItemById, findCustomItemById,
listCustomItems, listCustomItems,
@@ -2618,8 +2527,6 @@ module.exports = {
unfavoriteTopic, unfavoriteTopic,
favoriteTierList, favoriteTierList,
unfavoriteTierList, unfavoriteTierList,
favoriteGame,
unfavoriteGame,
deleteTierList, deleteTierList,
findCustomItemsByIds, findCustomItemsByIds,
deleteCustomItems, deleteCustomItems,
@@ -2629,5 +2536,5 @@ module.exports = {
findTemplateRequestById, findTemplateRequestById,
listAdminTemplateRequests, listAdminTemplateRequests,
updateTemplateRequestStatus, updateTemplateRequestStatus,
updateTemplateRequestTargetGame, updateTemplateRequestTargetTopic,
} }

View File

@@ -39,7 +39,7 @@ const {
listAdminTemplateRequests, listAdminTemplateRequests,
findTemplateRequestById, findTemplateRequestById,
updateTemplateRequestStatus, updateTemplateRequestStatus,
updateTemplateRequestTargetGame, updateTemplateRequestTargetTopic,
adminUpdateUser, adminUpdateUser,
adminUpdateUserPassword, adminUpdateUserPassword,
adminDeleteUser, adminDeleteUser,
@@ -55,8 +55,8 @@ const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState
const router = express.Router() const router = express.Router()
function getTemplateIdParam(req) { function getTemplateIdFromParams(req) {
return req.params.templateId || req.params.gameId || '' return req.params.templateId || ''
} }
function buildUploadFilename(file) { function buildUploadFilename(file) {
@@ -115,7 +115,7 @@ function canManageAdminRole(actingUser, primaryAdmin) {
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
} }
router.post(['/games', '/templates'], 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),
@@ -125,53 +125,53 @@ router.post(['/games', '/templates'], 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 findTopicById(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 template = await createTopic({ 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 updateTopicThumbnail(template.id, copiedThumb) await updateTopicThumbnail(template.id, copiedThumb)
} }
const savedTemplate = await findTopicById(template.id) const savedTemplate = await findTopicById(template.id)
res.json({ game: savedTemplate, template: savedTemplate }) res.json({ template: savedTemplate })
}) })
router.patch(['/games/:gameId', '/templates/:templateId'], 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 templateId = getTemplateIdParam(req) const templateId = getTemplateIdFromParams(req)
const template = await findTopicById(templateId) const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
const updated = await updateTopicVisibility(template.id, parsed.data.isPublic) const updated = await updateTopicVisibility(template.id, parsed.data.isPublic)
res.json({ game: updated, template: updated }) res.json({ template: updated })
}) })
router.patch(['/games/display-order', '/templates/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 templates = await listTopics('', { includePrivate: true }) const templates = await listTopics('', { includePrivate: true })
const validGameIds = new Set(templates.map((template) => template.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 updateTopicDisplayOrder(filteredIds) const updatedTemplates = await updateTopicDisplayOrder(filteredIds)
res.json({ games: updatedGames, templates: updatedGames }) res.json({ templates: updatedTemplates })
}) })
router.patch(['/games/:gameId/items/display-order', '/templates/:templateId/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 templateId = getTemplateIdParam(req) const templateId = getTemplateIdFromParams(req)
const template = await findTopicById(templateId) const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
@@ -179,15 +179,15 @@ router.patch(['/games/:gameId/items/display-order', '/templates/:templateId/item
res.json({ items }) res.json({ items })
}) })
router.post(['/games/:gameId/thumbnail', '/templates/:templateId/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 templateId = getTemplateIdParam(req) const templateId = getTemplateIdFromParams(req)
const template = await findTopicById(templateId) const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' }) 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',
@@ -195,13 +195,13 @@ router.post(['/games/:gameId/thumbnail', '/templates/:templateId/thumbnail'], re
}) })
const updated = await updateTopicThumbnail(templateId, optimized.src) const updated = await updateTopicThumbnail(templateId, optimized.src)
res.json({ game: updated, template: updated }) res.json({ template: updated })
}) })
router.post(['/games/:gameId/images', '/templates/:templateId/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 templateId = getTemplateIdParam(req) const templateId = getTemplateIdFromParams(req)
const template = await findTopicById(templateId) const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
@@ -214,7 +214,7 @@ router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireA
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',
@@ -233,37 +233,37 @@ router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireA
res.json({ item: items[0], items }) res.json({ item: items[0], items })
}) })
router.delete(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => { router.delete('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
const template = await findTopicById(getTemplateIdParam(req)) const template = await findTopicById(getTemplateIdFromParams(req))
if (!template) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
await deleteTopicItem(req.params.itemId) await deleteTopicItem(req.params.itemId)
res.json({ ok: true }) res.json({ ok: true })
}) })
router.get(['/games/:gameId/items/:itemId/usage', '/templates/:templateId/items/:itemId/usage'], requireAdmin, async (req, res) => { router.get('/templates/:templateId/items/:itemId/usage', requireAdmin, async (req, res) => {
const template = await findTopicById(getTemplateIdParam(req)) const template = await findTopicById(getTemplateIdFromParams(req))
if (!template) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
const item = await findTopicItemById(req.params.itemId) const item = await findTopicItemById(req.params.itemId)
if (!item || item.gameId !== template.id) return res.status(404).json({ error: 'not_found' }) if (!item || item.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
const usage = await countTierListsUsingTopicItem(req.params.itemId) const usage = await countTierListsUsingTopicItem(req.params.itemId)
res.json({ usage }) res.json({ usage })
}) })
router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => { 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 template = await findTopicById(getTemplateIdParam(req)) const template = await findTopicById(getTemplateIdFromParams(req))
if (!template) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
const updated = await updateTopicItemLabel(req.params.itemId, parsed.data.label) const updated = await updateTopicItemLabel(req.params.itemId, parsed.data.label)
if (!updated || updated.gameId !== template.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', '/templates/:templateId'], requireAdmin, async (req, res) => { router.delete('/templates/:templateId', requireAdmin, async (req, res) => {
const templateId = getTemplateIdParam(req) const templateId = getTemplateIdFromParams(req)
const template = await findTopicById(templateId) const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
await deleteTopic(templateId) await deleteTopic(templateId)
@@ -319,7 +319,6 @@ 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(''),
topicId: z.string().trim().max(120).optional().default(''), topicId: z.string().trim().max(120).optional().default(''),
gameId: 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),
}) })
@@ -328,8 +327,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
const result = await listAdminTierLists({ const result = await listAdminTierLists({
queryText: parsed.data.q, queryText: parsed.data.q,
topicId: parsed.data.topicId || parsed.data.gameId, topicId: parsed.data.topicId,
gameId: parsed.data.gameId,
page: parsed.data.page, page: parsed.data.page,
limit: parsed.data.limit, limit: parsed.data.limit,
currentUserId: req.session?.userId || '', currentUserId: req.session?.userId || '',
@@ -341,15 +339,13 @@ 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(''),
topicId: z.string().trim().max(120).optional().default(''), topicId: z.string().trim().max(120).optional().default(''),
gameId: 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,
topicId: parsed.data.topicId || parsed.data.gameId, topicId: parsed.data.topicId,
gameId: parsed.data.gameId,
}) })
res.json(result) res.json(result)
}) })
@@ -473,7 +469,7 @@ async function promoteLibraryItemToTemplateItem({ item, templateId }) {
}) })
} }
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 ''
@@ -511,7 +507,7 @@ async function promoteTierListItemsToTemplate({ tierList, templateId, itemIds =
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 createTopicItem({ await createTopicItem({
id: nanoid(), id: nanoid(),
@@ -535,7 +531,7 @@ async function promoteSnapshotItemsToTemplate({ items, templateId }) {
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 createTopicItem({ await createTopicItem({
@@ -580,13 +576,13 @@ function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}
async function createTemplateFromTierList({ tierList, templateId, templateName }) { async function createTemplateFromTierList({ tierList, templateId, templateName }) {
await createTopic({ id: templateId, name: templateName, 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 updateTopicThumbnail(templateId, 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 createTopicItem({ await createTopicItem({
id: nanoid(), id: nanoid(),
@@ -597,14 +593,14 @@ async function createTemplateFromTierList({ tierList, templateId, templateName }
) )
} }
return { game: await findTopicById(templateId), items: createdItems } return { template: await findTopicById(templateId), items: createdItems }
} }
async function createTemplateFromRequest({ templateRequest, templateId, templateName }) { async function createTemplateFromRequest({ templateRequest, templateId, templateName }) {
await createTopic({ id: templateId, name: templateName, 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 updateTopicThumbnail(templateId, copiedThumb) await updateTopicThumbnail(templateId, copiedThumb)
} }
@@ -613,7 +609,7 @@ async function createTemplateFromRequest({ templateRequest, templateId, template
templateId, templateId,
}) })
return { game: await findTopicById(templateId), 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) => {
@@ -635,7 +631,7 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
} }
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])
@@ -646,13 +642,13 @@ 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 template = await findTopicById(parsed.data.gameId) const template = await findTopicById(parsed.data.topicId)
if (!template) 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 templateItem = customItem ? null : await findTopicItemById(req.params.itemId) const templateItem = customItem ? null : await findTopicItemById(req.params.itemId)
@@ -675,14 +671,14 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
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 template = await findTopicById(parsed.data.gameId) const template = await findTopicById(parsed.data.topicId)
if (!template) 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' })
@@ -695,17 +691,17 @@ router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, re
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 findTopicById(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' })
@@ -715,7 +711,7 @@ router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (
...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,
}, },
templateId: parsed.data.gameId, templateId: parsed.data.topicId,
templateName: parsed.data.name, templateName: parsed.data.name,
}) })
res.json(result) res.json(result)
@@ -755,9 +751,9 @@ 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 template = await findTopicById(targetGameId) const template = await findTopicById(targetTopicId)
if (!template) return res.status(404).json({ error: 'game_not_found' }) if (!template) return res.status(404).json({ error: 'topic_not_found' })
const items = await promoteSnapshotItemsToTemplate({ const items = await promoteSnapshotItemsToTemplate({
items: templateRequest.items || [], items: templateRequest.items || [],
@@ -768,18 +764,18 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r
} }
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 findTopicById(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 createTemplateFromRequest({ const result = await createTemplateFromRequest({
templateRequest, templateRequest,
templateId: parsed.data.gameId, templateId: parsed.data.topicId,
templateName: 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' })
@@ -793,10 +789,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: '',
}) })
} }
@@ -808,9 +804,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' })
@@ -822,19 +818,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 template = await findTopicById(parsed.data.gameId) const template = await findTopicById(parsed.data.topicId)
if (!template) 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: template.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({}),
@@ -848,8 +844,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 template = await findTopicById(parsed.data.gameId) const template = await findTopicById(parsed.data.topicId)
if (!template) 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) {
@@ -865,7 +861,7 @@ router.post('/template-requests/:requestId/promote-items', requireAdmin, async (
} 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: template.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 || '',

View File

@@ -24,7 +24,7 @@ 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,6 @@ 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).optional(),
topicId: z.string().min(1).max(120).optional(), 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),
@@ -74,7 +73,7 @@ const templateRequestSchema = z.object({
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().superRefine((value, ctx) => { }).passthrough().superRefine((value, ctx) => {
if (!(value.topicId || value.gameId)) { if (!value.topicId) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['topicId'] }) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['topicId'] })
} }
}) })
@@ -84,14 +83,13 @@ const templateRequestSchema = 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).optional(),
topicId: z.string().min(1).optional(), 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(''),
@@ -114,17 +112,17 @@ 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) => { }).superRefine((value, ctx) => {
if (!(value.topicId || value.gameId)) { if (!value.topicId) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['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 topicId = typeof req.query.topicId === 'string' ? req.query.topicId : 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(topicId, req.session?.userId || '', queryText) const lists = await listPublicTierLists(topicId, req.session?.userId || '', queryText)
res.json({ tierLists: lists }) res.json({ tierLists: lists })
@@ -236,7 +234,7 @@ 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 || payload.gameId 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' })
@@ -262,9 +260,7 @@ 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: topicId,
sourceTopicId: topicId, sourceTopicId: topicId,
targetGameId: payload.type === 'update' ? topicId : '',
targetTopicId: payload.type === 'update' ? topicId : '', targetTopicId: payload.type === 'update' ? topicId : '',
title: payload.requestTitle, title: payload.requestTitle,
description: payload.requestDescription, description: payload.requestDescription,
@@ -287,7 +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 || payload.gameId const topicId = payload.topicId
const normalizedPool = payload.pool.map(normalizePoolItem) const normalizedPool = payload.pool.map(normalizePoolItem)
let existing = null let existing = null
@@ -298,8 +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.topicId || existing.gameId, topicId: existing.topicId,
topicId: existing.topicId || existing.gameId,
title: payload.title, title: payload.title,
thumbnailSrc: payload.thumbnailSrc || '', thumbnailSrc: payload.thumbnailSrc || '',
description: payload.description || '', description: payload.description || '',
@@ -318,7 +313,6 @@ 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: topicId,
topicId, topicId,
title: payload.title, title: payload.title,
thumbnailSrc: payload.thumbnailSrc || '', thumbnailSrc: payload.thumbnailSrc || '',

View File

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

View File

@@ -1,5 +1,49 @@
# 의사결정 이력 # 의사결정 이력
## 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 ## 2026-04-02 v1.4.21
- 백엔드에서 `topic/template` 응답을 내보내더라도 프런트가 계속 `game` 키만 읽으면 호환 레이어가 끝나지 않으므로, 이번 단계부터는 실제 사용자 화면과 관리자 저장 흐름도 새 키를 우선 읽게 맞추는 편이 맞다고 판단했다. - 백엔드에서 `topic/template` 응답을 내보내더라도 프런트가 계속 `game` 키만 읽으면 호환 레이어가 끝나지 않으므로, 이번 단계부터는 실제 사용자 화면과 관리자 저장 흐름도 새 키를 우선 읽게 맞추는 편이 맞다고 판단했다.
- 이 구간은 외부 API를 끊는 작업이 아니라 “프런트가 새 의미를 먼저 받아들이는 단계”이므로, 기존 `game` 키는 fallback으로만 남겨 두고 단계적으로 걷어내는 편이 가장 안전하다고 정리했다. - 이 구간은 외부 API를 끊는 작업이 아니라 “프런트가 새 의미를 먼저 받아들이는 단계”이므로, 기존 `game` 키는 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,6 +1,31 @@
# 할 일 및 이슈 # 할 일 및 이슈
## 단기 확인 ## 단기 확인
- `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한다. - `v1.4.21`에서 홈/주제 상세/에디터/나의 티어표/즐겨찾기/검색 결과/관리자 템플릿 생성이 `topic/template` 응답 키를 우선 읽도록 바뀌었으므로, 실제 브라우저에서 즐겨찾기 토글과 에디터 이동, 관리자 신규 템플릿 생성이 모두 정상인지 한 번 더 QA한다.
- 다음 단계에서는 실제 응답의 `game`, `gameId`, `gameName` 호환 키를 어디까지 남길지, 그리고 `/api/games` 호환 경로와 `games.js` 파일명을 언제 걷어낼지 최종 범위를 정한다. - 다음 단계에서는 실제 응답의 `game`, `gameId`, `gameName` 호환 키를 어디까지 남길지, 그리고 `/api/games` 호환 경로와 `games.js` 파일명을 언제 걷어낼지 최종 범위를 정한다.
- `v1.4.20`에서 백엔드 `db` export와 공개/관리자 라우트 내부 이름을 `topic/template` 기준으로 정리했으므로, 실제 브라우저와 관리자 화면에서 주제 목록/즐겨찾기/템플릿 생성/요청 반영 흐름이 모두 정상인지 한 번 더 QA한다. - `v1.4.20`에서 백엔드 `db` export와 공개/관리자 라우트 내부 이름을 `topic/template` 기준으로 정리했으므로, 실제 브라우저와 관리자 화면에서 주제 목록/즐겨찾기/템플릿 생성/요청 반영 흐름이 모두 정상인지 한 번 더 QA한다.
@@ -21,7 +46,6 @@
- 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다. - 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다.
- 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 API 이름층으로 바뀐 뒤에도 저장, 즐겨찾기, 템플릿 생성, 아이템 정렬 흐름이 자연스러운지 한 번 더 QA한다. - 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 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,59 @@
# 업데이트 로그 # 업데이트 로그
## 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 ## 2026-04-02 v1.4.21
- 프런트의 실제 소비 지점도 `topic/template` 응답 키를 우선 읽도록 옮겼다. 홈의 즐겨찾기 토글, 주제 상세 헤더, 티어표 편집기 템플릿 로딩, 나의 티어표/즐겨찾기/검색 결과의 에디터 이동이 이제 `topic`, `topicId`, `template`를 먼저 사용한다. - 프런트의 실제 소비 지점도 `topic/template` 응답 키를 우선 읽도록 옮겼다. 홈의 즐겨찾기 토글, 주제 상세 헤더, 티어표 편집기 템플릿 로딩, 나의 티어표/즐겨찾기/검색 결과의 에디터 이동이 이제 `topic`, `topicId`, `template`를 먼저 사용한다.
- 관리자 템플릿 공개 상태 저장과 신규 템플릿 생성 흐름도 `data.template`를 우선 읽고, 기존 `data.game`은 fallback으로만 남겨 프런트와 백엔드의 의미 이름이 한 단계 더 가까워지게 맞췄다. - 관리자 템플릿 공개 상태 저장과 신규 템플릿 생성 흐름도 `data.template`를 우선 읽고, 기존 `data.game`은 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)
@@ -76,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:

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,7 +122,7 @@ 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 v-if="!props.displayThumbnailUrl" class="thumbDropZone__iconWrap"> <div v-if="!props.displayThumbnailUrl" class="thumbDropZone__iconWrap">
@@ -132,15 +132,15 @@ function setThumbFileElement(el) {
</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 +215,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

@@ -53,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>
@@ -89,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>
@@ -109,7 +109,7 @@ const props = defineProps({
{{ {{
request.isHandling request.isHandling
? '이동중...' ? '이동중...'
: request.type === 'create' && (request.targetGameName || request.targetGameId) : request.type === 'create' && (request.targetTopicName || request.targetTopicId)
? '연결된 템플릿 열기' ? '연결된 템플릿 열기'
: '확인하기' : '확인하기'
}} }}
@@ -148,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>
@@ -170,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.promoteAdminTemplateItem(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.updateAdminTemplateDisplayOrder({ 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,34 +124,36 @@ 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.getTopic(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 {
@@ -160,8 +162,8 @@ export function useAdminGameManager({
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,22 +171,22 @@ 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 || data.game || {} 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: createdTemplate.id, topicId: createdTemplate.id,
}) })
activeTemplateRequest.value = { activeTemplateRequest.value = {
...activeTemplateRequest.value, ...activeTemplateRequest.value,
targetGameId: linkData.request?.targetGameId || createdTemplate.id, targetTopicId: linkData.request?.targetTopicId || createdTemplate.id,
targetGameName: linkData.request?.targetGameName || createdTemplate.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 || createdTemplate.id, targetTopicId: linkData.request?.targetTopicId || createdTemplate.id,
targetGameName: linkData.request?.targetGameName || createdTemplate.name || nextGameName, targetTopicName: linkData.request?.targetTopicName || createdTemplate.name || nextTopicName,
}) })
} }
} }
@@ -255,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,
}) })
} }
@@ -298,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) => {
@@ -324,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
} }
@@ -347,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 = '기본 아이템 순서 저장에 실패했어요.'
@@ -357,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

@@ -74,12 +74,10 @@ export const api = {
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 = '', topicId = '', 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)}&topicId=${encodeURIComponent(topicId || gameId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}` getAdminTierListStats: ({ q = '', topicId = '' } = {}) =>
), request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}`),
getAdminTierListStats: ({ q = '', topicId = '', gameId = '' } = {}) =>
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId || 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) =>
@@ -102,11 +100,11 @@ export const api = {
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 }),
createAdminTemplateFromTierList: (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) =>
@@ -175,24 +173,4 @@ export const api = {
deleteAdminCustomItem: (itemId) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}`, { method: 'DELETE' }), deleteAdminCustomItem: (itemId) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}`, { method: 'DELETE' }),
deleteAdminUnusedCustomItems: ({ q = '' } = {}) => deleteAdminUnusedCustomItems: ({ q = '' } = {}) =>
request(`/api/admin/custom-items?q=${encodeURIComponent(q)}`, { method: 'DELETE' }), request(`/api/admin/custom-items?q=${encodeURIComponent(q)}`, { method: 'DELETE' }),
listGames: () => request('/api/games'),
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }),
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }),
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
updateAdminGameItemDisplayOrder: (gameId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { method: 'PATCH', body: payload }),
updateAdminGame: (gameId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}`, { method: 'PATCH', body: payload }),
updateAdminGameItem: (gameId, itemId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
promoteAdminCustomItem: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
createAdminGameTemplateFromTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
listPublicTierLists: (gameId) =>
request(`/api/tierlists/public?topicId=${encodeURIComponent(gameId || '')}`),
searchPublicTierLists: (gameId, q = '') =>
request(`/api/tierlists/public?topicId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
} }

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,8 +16,7 @@ export function createRouter() {
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: '/', name: 'home', component: HomeView }, { path: '/', name: 'home', component: HomeView },
{ path: '/games/:gameId', redirect: (to) => `/topics/${encodeURIComponent(String(to.params.gameId || ''))}` }, { path: '/topics/:topicId', name: 'topicHub', component: TopicHubView },
{ path: '/topics/:topicId', name: 'topicHub', component: GameHubView },
{ path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView }, { path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView },
{ path: '/editor/:topicId/: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 },
@@ -26,7 +25,7 @@ export function createRouter() {
{ 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

@@ -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 = ''
@@ -788,7 +788,7 @@ async function selectAdminTemplate(templateId) {
async function refreshTemplates() { async function refreshTemplates() {
try { try {
const data = await api.listTopics() 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,
@@ -1167,17 +1167,17 @@ 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.updateAdminTemplate(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 || data.game || {} const nextTemplate = data.template || {}
selectedTemplate.value = { selectedTemplate.value = {
...selectedTemplate.value, ...selectedTemplate.value,
game: { template: {
...selectedTemplate.value.game, ...selectedTemplate.value.template,
...nextTemplate, ...nextTemplate,
}, },
} }
@@ -1188,17 +1188,17 @@ async function saveTemplateVisibility() {
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,
}, },
} }
@@ -1206,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,
}, },
} }
@@ -1278,9 +1278,9 @@ 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 {
@@ -1290,7 +1290,7 @@ async function removeTemplate() {
}) })
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()
@@ -1306,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'
@@ -1327,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
} }
@@ -1368,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,
@@ -1432,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) {
@@ -1454,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) {
@@ -1551,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) {
@@ -1567,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
} }
@@ -1597,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.createAdminTemplateFromTierList(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()
@@ -1631,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 ''
}) })
@@ -1700,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"
@@ -1739,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"
@@ -1823,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>
@@ -1981,7 +1981,7 @@ 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">
@@ -2046,34 +2046,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>
@@ -2095,7 +2095,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">
@@ -2223,24 +2223,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>
@@ -2305,11 +2305,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>
@@ -2583,14 +2583,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;
@@ -2601,32 +2601,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;
@@ -2873,16 +2873,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;
@@ -3048,37 +3048,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;
@@ -3101,11 +3101,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;
@@ -3330,7 +3330,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;
@@ -3493,7 +3493,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 {
@@ -4479,8 +4479,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.topicId || tierList.gameId, tierList.id)) router.push(editorPath(tierList.topicId, tierList.id))
} }
onMounted(loadFavorites) onMounted(loadFavorites)

View File

@@ -37,7 +37,7 @@ const templates = computed(() => {
async function loadTemplates() { async function loadTemplates() {
try { try {
const data = await api.listTopics() const data = await api.listTopics()
templateRecords.value = data.topics || data.games || [] templateRecords.value = data.topics || []
} catch (e) { } catch (e) {
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.' error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
} }
@@ -61,7 +61,7 @@ async function toggleFavorite(template, event) {
try { try {
loadingFavoriteId.value = template.id loadingFavoriteId.value = template.id
const res = template.isFavorited ? await api.unfavoriteTopic(template.id) : await api.favoriteTopic(template.id) const res = template.isFavorited ? await api.unfavoriteTopic(template.id) : await api.favoriteTopic(template.id)
templateRecords.value = templateRecords.value.map((entry) => (entry.id === template.id ? { ...entry, ...(res.topic || 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

@@ -60,7 +60,7 @@ onMounted(async () => {
}) })
function openList(t) { function openList(t) {
router.push(editorPath(t.topicId || 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.topicId || 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 || '',
@@ -899,12 +899,12 @@ onMounted(() => {
try { try {
const topicRes = await api.getTopic(templateId.value) const topicRes = await api.getTopic(templateId.value)
templateName.value = topicRes.topic?.name || topicRes.game?.name || templateId.value templateName.value = topicRes.topic?.name || templateId.value
const base = (topicRes.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('')
@@ -57,7 +57,7 @@ async function loadTierLists() {
api.getTopic(topicId.value), api.getTopic(topicId.value),
api.searchPublicTierListsByTopic(topicId.value, query.value), api.searchPublicTierListsByTopic(topicId.value, query.value),
]) ])
topicName.value = topicRes.topic?.name || topicRes.game?.name || '' topicName.value = topicRes.topic?.name || ''
brokenThumbnailIds.value = {} brokenThumbnailIds.value = {}
tierLists.value = listRes.tierLists || [] tierLists.value = listRes.tierLists || []
} catch (e) { } catch (e) {