Compare commits

...

3 Commits

16 changed files with 360 additions and 226 deletions

View File

@@ -80,6 +80,7 @@ app.use(async (req, res, next) => {
app.use('/api/auth', authRoutes) app.use('/api/auth', authRoutes)
app.use('/api/games', gamesRoutes) app.use('/api/games', gamesRoutes)
app.use('/api/topics', gamesRoutes)
app.use('/api/tierlists', tierListsRoutes) app.use('/api/tierlists', tierListsRoutes)
app.use('/api/admin', adminRoutes) app.use('/api/admin', adminRoutes)

View File

@@ -8,7 +8,8 @@ const DB_USER = process.env.DB_USER || 'root'
const DB_PASSWORD = process.env.DB_PASSWORD || '' 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_GAME_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
@@ -72,6 +73,8 @@ function mapGameRow(row) {
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
topicId: row.id,
topicName: row.name,
thumbnailSrc: row.thumbnail_src || '', thumbnailSrc: row.thumbnail_src || '',
isPublic: row.is_public == null ? true : !!row.is_public, isPublic: row.is_public == null ? true : !!row.is_public,
displayRank: row.display_rank == null ? null : Number(row.display_rank), displayRank: row.display_rank == null ? null : Number(row.display_rank),
@@ -83,7 +86,8 @@ function mapGameItemRow(row) {
if (!row) return null if (!row) return null
return { return {
id: row.id, id: row.id,
gameId: row.game_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),
@@ -131,8 +135,10 @@ function mapTierListRow(row) {
authorName: getUserDisplayName(row), authorName: getUserDisplayName(row),
authorAccountName: getUserAccountName(row), authorAccountName: getUserAccountName(row),
authorAvatarSrc: row.avatar_src || '', authorAvatarSrc: row.avatar_src || '',
gameId: row.game_id, topicId: row.topic_id,
gameName: row.game_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 || '',
@@ -159,13 +165,17 @@ function mapTemplateRequestRow(row) {
requesterAccountName: getUserAccountName(row), requesterAccountName: getUserAccountName(row),
requesterAvatarSrc: row.requester_avatar_src || '', requesterAvatarSrc: row.requester_avatar_src || '',
sourceTierListId: row.source_tierlist_id || '', sourceTierListId: row.source_tierlist_id || '',
sourceGameId: row.source_game_id, sourceTopicId: row.source_topic_id,
sourceGameName: row.source_game_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 || '',
targetGameId: row.target_game_id || '', targetTopicId: row.target_topic_id || '',
targetGameName: row.target_game_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, []),
@@ -230,6 +240,16 @@ async function query(sql, params = []) {
return rows return rows
} }
async function tableExists(name) {
const rows = await query('SHOW TABLES LIKE ?', [name])
return rows.length > 0
}
async function columnExists(tableName, columnName) {
const rows = await query(`SHOW COLUMNS FROM \`${tableName}\` LIKE ?`, [columnName])
return rows.length > 0
}
async function closePool() { async function closePool() {
if (!poolPromise) return if (!poolPromise) return
const pool = await poolPromise const pool = await poolPromise
@@ -241,6 +261,32 @@ async function closePool() {
async function ensureSchema() { async function ensureSchema() {
if (initPromise) return initPromise if (initPromise) return initPromise
initPromise = (async () => { initPromise = (async () => {
if ((await tableExists('games')) && !(await tableExists('topics'))) {
await query('RENAME TABLE games TO topics')
}
if ((await tableExists('game_items')) && !(await tableExists('topic_items'))) {
await query('RENAME TABLE game_items TO topic_items')
}
if ((await tableExists('favorite_games')) && !(await tableExists('favorite_topics'))) {
await query('RENAME TABLE favorite_games TO favorite_topics')
}
if ((await tableExists('tierlists')) && (await columnExists('tierlists', 'game_id')) && !(await columnExists('tierlists', 'topic_id'))) {
await query('ALTER TABLE tierlists CHANGE COLUMN game_id topic_id VARCHAR(120) NOT NULL')
}
if ((await tableExists('topic_items')) && (await columnExists('topic_items', 'game_id')) && !(await columnExists('topic_items', 'topic_id'))) {
await query('ALTER TABLE topic_items CHANGE COLUMN game_id topic_id VARCHAR(120) NOT NULL')
}
if ((await tableExists('favorite_topics')) && (await columnExists('favorite_topics', 'game_id')) && !(await columnExists('favorite_topics', 'topic_id'))) {
await query('ALTER TABLE favorite_topics CHANGE COLUMN game_id topic_id VARCHAR(120) NOT NULL')
}
if ((await tableExists('template_requests')) && (await columnExists('template_requests', 'source_game_id')) && !(await columnExists('template_requests', 'source_topic_id'))) {
await query('ALTER TABLE template_requests CHANGE COLUMN source_game_id source_topic_id VARCHAR(120) NOT NULL')
}
if ((await tableExists('template_requests')) && (await columnExists('template_requests', 'target_game_id')) && !(await columnExists('template_requests', 'target_topic_id'))) {
await query("ALTER TABLE template_requests CHANGE COLUMN target_game_id target_topic_id VARCHAR(120) NOT NULL DEFAULT ''")
}
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,
@@ -254,7 +300,7 @@ async function ensureSchema() {
`) `)
await query(` await query(`
CREATE TABLE IF NOT EXISTS games ( CREATE TABLE IF NOT EXISTS topics (
id VARCHAR(120) PRIMARY KEY, id VARCHAR(120) PRIMARY KEY,
name VARCHAR(120) NOT NULL, name VARCHAR(120) NOT NULL,
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '', thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
@@ -264,33 +310,33 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`) `)
const gameIsPublicColumns = await query("SHOW COLUMNS FROM games LIKE 'is_public'") const gameIsPublicColumns = await query("SHOW COLUMNS FROM topics LIKE 'is_public'")
if (!gameIsPublicColumns.length) { if (!gameIsPublicColumns.length) {
await query('ALTER TABLE games 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 games SET is_public = 1 WHERE is_public IS NULL') await query('UPDATE topics SET is_public = 1 WHERE is_public IS NULL')
} }
const displayRankColumns = await query("SHOW COLUMNS FROM games LIKE 'display_rank'") const displayRankColumns = await query("SHOW COLUMNS FROM topics LIKE 'display_rank'")
if (!displayRankColumns.length) { if (!displayRankColumns.length) {
await query('ALTER TABLE games 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')
} }
await query(` await query(`
CREATE TABLE IF NOT EXISTS game_items ( CREATE TABLE IF NOT EXISTS topic_items (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
game_id VARCHAR(120) NOT NULL, topic_id VARCHAR(120) NOT NULL,
src VARCHAR(255) NOT NULL, src VARCHAR(255) NOT NULL,
label VARCHAR(120) NOT NULL, label VARCHAR(120) NOT NULL,
display_order INT NULL DEFAULT NULL, display_order INT NULL DEFAULT NULL,
created_at BIGINT NOT NULL, created_at BIGINT NOT NULL,
INDEX idx_game_items_game_id (game_id), INDEX idx_topic_items_topic_id (topic_id),
CONSTRAINT fk_game_items_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE CONSTRAINT fk_topic_items_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`) `)
const gameItemDisplayOrderColumns = await query("SHOW COLUMNS FROM game_items LIKE 'display_order'") const gameItemDisplayOrderColumns = await query("SHOW COLUMNS FROM topic_items LIKE 'display_order'")
if (!gameItemDisplayOrderColumns.length) { if (!gameItemDisplayOrderColumns.length) {
await query('ALTER TABLE game_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')
} }
await query(` await query(`
@@ -309,7 +355,7 @@ async function ensureSchema() {
CREATE TABLE IF NOT EXISTS tierlists ( CREATE TABLE IF NOT EXISTS tierlists (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
author_id VARCHAR(64) NOT NULL, author_id VARCHAR(64) NOT NULL,
game_id VARCHAR(120) NOT NULL, topic_id VARCHAR(120) NOT NULL,
title VARCHAR(120) NOT NULL, title VARCHAR(120) NOT NULL,
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '', thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
description TEXT NOT NULL, description TEXT NOT NULL,
@@ -324,10 +370,10 @@ async function ensureSchema() {
created_at BIGINT NOT NULL, created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL, updated_at BIGINT NOT NULL,
INDEX idx_tierlists_author_id (author_id), INDEX idx_tierlists_author_id (author_id),
INDEX idx_tierlists_game_id (game_id), INDEX idx_tierlists_topic_id (topic_id),
INDEX idx_tierlists_public_game_updated (is_public, game_id, updated_at), INDEX idx_tierlists_public_topic_updated (is_public, topic_id, updated_at),
CONSTRAINT fk_tierlists_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_tierlists_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_tierlists_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE CONSTRAINT fk_tierlists_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`) `)
@@ -344,14 +390,14 @@ async function ensureSchema() {
`) `)
await query(` await query(`
CREATE TABLE IF NOT EXISTS favorite_games ( CREATE TABLE IF NOT EXISTS favorite_topics (
user_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL,
game_id VARCHAR(120) NOT NULL, topic_id VARCHAR(120) NOT NULL,
created_at BIGINT NOT NULL, created_at BIGINT NOT NULL,
PRIMARY KEY (user_id, game_id), PRIMARY KEY (user_id, topic_id),
INDEX idx_favorite_games_game_id (game_id), INDEX idx_favorite_topics_topic_id (topic_id),
CONSTRAINT fk_favorite_games_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_favorite_topics_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_favorite_games_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE CONSTRAINT fk_favorite_topics_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`) `)
@@ -399,8 +445,8 @@ async function ensureSchema() {
request_type VARCHAR(20) NOT NULL, request_type VARCHAR(20) NOT NULL,
requester_id VARCHAR(64) NOT NULL, requester_id VARCHAR(64) NOT NULL,
source_tierlist_id VARCHAR(64) NOT NULL, source_tierlist_id VARCHAR(64) NOT NULL,
source_game_id VARCHAR(120) NOT NULL, source_topic_id VARCHAR(120) NOT NULL,
target_game_id VARCHAR(120) NOT NULL DEFAULT '', target_topic_id VARCHAR(120) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'pending', status VARCHAR(20) NOT NULL DEFAULT 'pending',
title_snapshot VARCHAR(120) NOT NULL, title_snapshot VARCHAR(120) NOT NULL,
description_snapshot TEXT NOT NULL, description_snapshot TEXT NOT NULL,
@@ -424,17 +470,17 @@ async function ensureSchema() {
if (!templateRequestTypeColumns.length) { if (!templateRequestTypeColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN request_type VARCHAR(20) NOT NULL DEFAULT 'create' AFTER id") await query("ALTER TABLE template_requests ADD COLUMN request_type VARCHAR(20) NOT NULL DEFAULT 'create' AFTER id")
} }
const templateRequestSourceGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_game_id'") const templateRequestSourceGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_topic_id'")
if (!templateRequestSourceGameColumns.length) { if (!templateRequestSourceGameColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN source_game_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")
} }
const templateRequestTargetGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'target_game_id'") const templateRequestTargetGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'target_topic_id'")
if (!templateRequestTargetGameColumns.length) { if (!templateRequestTargetGameColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN target_game_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_game_id") await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_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) {
await query("ALTER TABLE template_requests ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER target_game_id") await query("ALTER TABLE template_requests ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER target_topic_id")
} }
const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'") const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'")
if (!templateRequestGroupsColumns.length) { if (!templateRequestGroupsColumns.length) {
@@ -478,19 +524,19 @@ async function ensureSchema() {
await query( await query(
` `
INSERT INTO games (id, name, thumbnail_src, created_at) INSERT INTO topics (id, name, thumbnail_src, created_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name) ON DUPLICATE KEY UPDATE name = VALUES(name)
`, `,
[FREEFORM_GAME_ID, '직접 티어표 만들기', '', now()] [FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', now()]
) )
const countRows = await query('SELECT COUNT(*) AS count FROM games') const countRows = await query('SELECT COUNT(*) AS count FROM topics')
if (Number(countRows[0]?.count || 0) <= 1) { if (Number(countRows[0]?.count || 0) <= 1) {
const createdAt = now() const createdAt = now()
await query( await query(
` `
INSERT INTO games (id, name, thumbnail_src, created_at) INSERT INTO topics (id, name, thumbnail_src, created_at)
VALUES VALUES
(?, ?, ?, ?), (?, ?, ?, ?),
(?, ?, ?, ?) (?, ?, ?, ?)
@@ -500,7 +546,7 @@ async function ensureSchema() {
await query( await query(
` `
INSERT INTO game_items (id, game_id, src, label, created_at) INSERT INTO topic_items (id, topic_id, src, label, created_at)
VALUES VALUES
(?, ?, ?, ?, ?), (?, ?, ?, ?, ?),
(?, ?, ?, ?, ?) (?, ?, ?, ?, ?)
@@ -662,7 +708,7 @@ async function listGames(currentUserId = '', options = {}) {
const rows = await query( const rows = await query(
` `
SELECT id, name, thumbnail_src, is_public, display_rank, created_at SELECT id, name, thumbnail_src, is_public, display_rank, created_at
FROM games FROM topics
WHERE id <> ? WHERE id <> ?
${includePrivate ? '' : 'AND is_public = 1'} ${includePrivate ? '' : 'AND is_public = 1'}
ORDER BY ORDER BY
@@ -671,13 +717,13 @@ async function listGames(currentUserId = '', options = {}) {
created_at DESC, created_at DESC,
name ASC name ASC
`, `,
[FREEFORM_GAME_ID] [FREEFORM_TOPIC_ID]
) )
const games = rows.map(mapGameRow) const games = rows.map(mapGameRow)
if (!currentUserId) return games.map((game) => ({ ...game, isFavorited: false })) if (!currentUserId) return games.map((game) => ({ ...game, isFavorited: false }))
const favoriteRows = await query('SELECT game_id FROM favorite_games WHERE user_id = ?', [currentUserId]) const favoriteRows = await query('SELECT topic_id FROM favorite_topics WHERE user_id = ?', [currentUserId])
const favoriteSet = new Set(favoriteRows.map((row) => row.game_id)) const favoriteSet = new Set(favoriteRows.map((row) => row.topic_id))
return games.map((game) => ({ return games.map((game) => ({
...game, ...game,
isFavorited: favoriteSet.has(game.id), isFavorited: favoriteSet.has(game.id),
@@ -685,16 +731,16 @@ async function listGames(currentUserId = '', options = {}) {
} }
async function findGameById(id) { async function findGameById(id) {
const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM games 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 mapGameRow(rows[0])
} }
async function listGameItems(gameId) { async function listGameItems(gameId) {
const rows = await query( const rows = await query(
` `
SELECT id, game_id, src, label, display_order, created_at SELECT id, topic_id, src, label, display_order, created_at
FROM game_items FROM topic_items
WHERE game_id = ? WHERE topic_id = ?
ORDER BY ORDER BY
CASE WHEN display_order IS NULL THEN 1 ELSE 0 END ASC, CASE WHEN display_order IS NULL THEN 1 ELSE 0 END ASC,
display_order ASC, display_order ASC,
@@ -707,7 +753,7 @@ async function listGameItems(gameId) {
} }
async function findGameItemById(itemId) { async function findGameItemById(itemId) {
const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_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 mapGameItemRow(rows[0])
} }
@@ -719,7 +765,7 @@ async function getGameDetail(gameId) {
} }
async function createGame({ id, name, isPublic = true }) { async function createGame({ id, name, isPublic = true }) {
await query('INSERT INTO games (id, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)', [ await query('INSERT INTO topics (id, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
id, id,
name, name,
'', '',
@@ -731,12 +777,12 @@ async function createGame({ id, name, isPublic = true }) {
} }
async function updateGameThumbnail(gameId, thumbnailSrc) { async function updateGameThumbnail(gameId, thumbnailSrc) {
await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, gameId]) await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, gameId])
return findGameById(gameId) return findGameById(gameId)
} }
async function updateGameVisibility(gameId, isPublic) { async function updateGameVisibility(gameId, isPublic) {
await query('UPDATE games SET is_public = ? WHERE id = ?', [isPublic ? 1 : 0, gameId]) await query('UPDATE topics SET is_public = ? WHERE id = ?', [isPublic ? 1 : 0, gameId])
return findGameById(gameId) return findGameById(gameId)
} }
@@ -838,8 +884,8 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([ const [userRows, gameRows, gameItemRows, 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 games WHERE thumbnail_src <> ''"), query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT src FROM game_items WHERE src <> ''"), query("SELECT src FROM topic_items WHERE src <> ''"),
query("SELECT src FROM custom_items WHERE src <> ''"), query("SELECT src FROM custom_items WHERE src <> ''"),
query("SELECT thumbnail_src, pool_json FROM tierlists"), query("SELECT thumbnail_src, pool_json FROM tierlists"),
query("SELECT thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"), query("SELECT thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
@@ -891,8 +937,8 @@ async function listReferencedUploadUsage() {
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([ const [userRows, gameRows, gameItemRows, 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 games WHERE thumbnail_src <> ''"), query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT src FROM game_items WHERE src <> ''"), query("SELECT src FROM topic_items WHERE src <> ''"),
query("SELECT src FROM custom_items WHERE src <> ''"), query("SELECT src FROM custom_items WHERE src <> ''"),
query("SELECT id, thumbnail_src, pool_json FROM tierlists"), query("SELECT id, thumbnail_src, pool_json FROM tierlists"),
query("SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"), query("SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
@@ -934,8 +980,8 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
const [userResult, gameResult, gameItemResult, customItemResult] = await Promise.all([ const [userResult, gameResult, gameItemResult, 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 games SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]), query('UPDATE topics SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
query('UPDATE game_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]),
]) ])
@@ -1099,8 +1145,8 @@ async function cleanupMissingUploadReferences() {
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([ const [userRows, gameRows, gameItemRows, 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 games WHERE thumbnail_src <> ''"), query("SELECT id, thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT id, src FROM game_items WHERE src <> ''"), query("SELECT id, src FROM topic_items WHERE src <> ''"),
query("SELECT id, src FROM custom_items WHERE src <> ''"), query("SELECT id, src FROM custom_items WHERE src <> ''"),
query("SELECT id, thumbnail_src, groups_json, pool_json FROM tierlists"), query("SELECT id, thumbnail_src, groups_json, pool_json FROM tierlists"),
query("SELECT id, thumbnail_src_snapshot, groups_json, items_json, board_items_json FROM template_requests"), query("SELECT id, thumbnail_src_snapshot, groups_json, items_json, board_items_json FROM template_requests"),
@@ -1114,7 +1160,7 @@ async function cleanupMissingUploadReferences() {
for (const row of gameRows) { for (const row of gameRows) {
if (await fileExistsForUploadSrc(row.thumbnail_src)) continue if (await fileExistsForUploadSrc(row.thumbnail_src)) continue
await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', ['', row.id]) await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', ['', row.id])
stats.clearedGameThumbnails += 1 stats.clearedGameThumbnails += 1
} }
@@ -1269,10 +1315,10 @@ async function clearImageOptimizationJobs({ month } = {}) {
} }
async function createGameItem({ id, gameId, src, label }) { async function createGameItem({ id, gameId, src, label }) {
const createdAt = now() const createdAt = now()
const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM game_items WHERE game_id = ?', [gameId]) const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM topic_items WHERE topic_id = ?', [gameId])
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 game_items (id, game_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,
gameId, gameId,
src, src,
@@ -1280,13 +1326,13 @@ async function createGameItem({ id, gameId, src, label }) {
nextDisplayOrder, nextDisplayOrder,
createdAt, createdAt,
]) ])
const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_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 mapGameItemRow(rows[0])
} }
async function updateGameItemLabel(itemId, label) { async function updateGameItemLabel(itemId, label) {
await query('UPDATE game_items SET label = ? WHERE id = ?', [label, itemId]) await query('UPDATE topic_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_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 mapGameItemRow(rows[0])
} }
@@ -1299,7 +1345,7 @@ async function updateGameItemDisplayOrder(gameId, itemIds) {
const finalIds = [...orderedIds, ...remainingIds] const finalIds = [...orderedIds, ...remainingIds]
await Promise.all( await Promise.all(
finalIds.map((itemId, index) => query('UPDATE game_items SET display_order = ? WHERE id = ? AND game_id = ?', [index + 1, itemId, gameId])) finalIds.map((itemId, index) => query('UPDATE topic_items SET display_order = ? WHERE id = ? AND topic_id = ?', [index + 1, itemId, gameId]))
) )
return listGameItems(gameId) return listGameItems(gameId)
@@ -1334,15 +1380,15 @@ async function updateImageAssetLabel(assetId, label) {
} }
async function deleteGameItem(itemId) { async function deleteGameItem(itemId) {
const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId]) const gameItemRows = await query('SELECT topic_id FROM topic_items WHERE id = ? LIMIT 1', [itemId])
const gameId = gameItemRows[0]?.game_id const gameId = gameItemRows[0]?.topic_id
if (gameId) { if (gameId) {
const tierListRows = await query( const tierListRows = await query(
` `
SELECT id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at SELECT id, author_id, topic_id, title, description, is_public, groups_json, pool_json, created_at, updated_at
FROM tierlists FROM tierlists
WHERE game_id = ? WHERE topic_id = ?
`, `,
[gameId] [gameId]
) )
@@ -1362,21 +1408,21 @@ async function deleteGameItem(itemId) {
} }
} }
await query('DELETE FROM game_items WHERE id = ?', [itemId]) await query('DELETE FROM topic_items WHERE id = ?', [itemId])
} }
async function deleteGame(gameId) { async function deleteGame(gameId) {
await query('DELETE FROM games WHERE id = ?', [gameId]) await query('DELETE FROM topics WHERE id = ?', [gameId])
} }
async function updateGameDisplayOrder(gameIds) { async function updateGameDisplayOrder(gameIds) {
const normalizedIds = Array.from(new Set((gameIds || []).filter((id) => id && id !== FREEFORM_GAME_ID))).slice(0, 50) const normalizedIds = Array.from(new Set((gameIds || []).filter((id) => id && id !== FREEFORM_TOPIC_ID))).slice(0, 50)
await query('UPDATE games SET display_rank = NULL WHERE id <> ?', [FREEFORM_GAME_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((gameId, index) =>
query('UPDATE games SET display_rank = ? WHERE id = ? AND id <> ?', [index + 1, gameId, FREEFORM_GAME_ID]) query('UPDATE topics SET display_rank = ? WHERE id = ? AND id <> ?', [index + 1, gameId, FREEFORM_TOPIC_ID])
) )
) )
@@ -1438,9 +1484,9 @@ async function findCustomItemById(id) {
async function getCustomItemUsageMeta() { async function getCustomItemUsageMeta() {
const rows = await query( const rows = await query(
` `
SELECT t.game_id, g.name AS game_name, t.groups_json, t.pool_json SELECT t.topic_id, tp.name AS topic_name, t.groups_json, t.pool_json
FROM tierlists t FROM tierlists t
LEFT JOIN games g ON g.id = t.game_id LEFT JOIN topics tp ON tp.id = t.topic_id
` `
) )
const usageMap = new Map() const usageMap = new Map()
@@ -1465,13 +1511,13 @@ async function getCustomItemUsageMeta() {
} }
}) })
if (!row.game_id) return if (!row.topic_id) return
seenItemIds.forEach((itemId) => { seenItemIds.forEach((itemId) => {
if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map()) if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map())
linkedGamesMap.get(itemId).set(row.game_id, { linkedGamesMap.get(itemId).set(row.topic_id, {
id: row.game_id, id: row.topic_id,
name: row.game_name || row.game_id, name: row.topic_name || row.topic_id,
}) })
}) })
}) })
@@ -1511,14 +1557,14 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
` `
SELECT SELECT
gi.id, gi.id,
gi.game_id, gi.topic_id,
gi.src, gi.src,
gi.label, gi.label,
gi.created_at, gi.created_at,
g.name AS game_name tp.name AS topic_name
FROM game_items gi FROM topic_items gi
INNER JOIN games g ON g.id = gi.game_id INNER JOIN topics tp ON tp.id = gi.topic_id
${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.game_id LIKE ? OR g.name LIKE ?' : ''} ${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.topic_id LIKE ? OR tp.name LIKE ?' : ''}
ORDER BY gi.created_at DESC ORDER BY gi.created_at DESC
`, `,
hasQuery ? [search, search, search, search] : [] hasQuery ? [search, search, search, search] : []
@@ -1540,9 +1586,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
gameItemRows.forEach((row) => { gameItemRows.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.game_id, { templateLinkedBySrc.get(row.src).set(row.topic_id, {
id: row.game_id, id: row.topic_id,
name: row.game_name || row.game_id, name: row.topic_name || row.topic_id,
}) })
}) })
@@ -1593,15 +1639,15 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
src: row.src, src: row.src,
label: row.label, label: row.label,
createdAt: Number(row.created_at), createdAt: Number(row.created_at),
ownerName: row.game_name || row.game_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()), linkedGames: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
sourceType: 'template', sourceType: 'template',
sourceLabel: '관리자 템플릿', sourceLabel: '관리자 템플릿',
canDelete: true, canDelete: true,
sourceGameId: row.game_id, sourceGameId: row.topic_id,
sourceGameName: row.game_name || row.game_id, sourceGameName: row.topic_name || row.topic_id,
})) }))
const baseItems = [...customItems, ...templateItems, ...assetLibraryItems] const baseItems = [...customItems, ...templateItems, ...assetLibraryItems]
@@ -1767,12 +1813,12 @@ function applyFavoriteMetaToTierLists(tierLists, favoriteStats) {
})) }))
} }
async function listPublicTierLists(gameId, currentUserId = '', queryText = '') { async function listPublicTierLists(topicId, currentUserId = '', queryText = '') {
const params = [] const params = []
let whereClause = 'WHERE t.is_public = 1' let whereClause = 'WHERE t.is_public = 1'
if (gameId) { if (topicId) {
whereClause += ' AND t.game_id = ?' whereClause += ' AND t.topic_id = ?'
params.push(gameId) params.push(topicId)
} }
if ((queryText || '').trim()) { if ((queryText || '').trim()) {
const search = `%${queryText.trim()}%` const search = `%${queryText.trim()}%`
@@ -1784,7 +1830,7 @@ async function listPublicTierLists(gameId, currentUserId = '', queryText = '') {
` `
SELECT SELECT
t.id, t.id,
t.game_id, t.topic_id,
t.title, t.title,
t.thumbnail_src, t.thumbnail_src,
t.created_at, t.created_at,
@@ -1804,7 +1850,8 @@ async function listPublicTierLists(gameId, currentUserId = '', queryText = '') {
const tierLists = rows.map((row) => ({ const tierLists = rows.map((row) => ({
id: row.id, id: row.id,
gameId: row.game_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),
@@ -1830,7 +1877,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
if ((queryText || '').trim()) { if ((queryText || '').trim()) {
const search = `%${queryText.trim()}%` const search = `%${queryText.trim()}%`
whereClause += ' AND (t.title LIKE ? OR g.name LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)' whereClause += ' AND (t.title LIKE ? OR tp.name LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)'
params.push(search, search, search, search) params.push(search, search, search, search)
} }
@@ -1846,8 +1893,8 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
SELECT SELECT
t.id, t.id,
t.author_id, t.author_id,
t.game_id, t.topic_id,
g.name AS game_name, tp.name AS topic_name,
t.title, t.title,
t.thumbnail_src, t.thumbnail_src,
t.description, t.description,
@@ -1873,7 +1920,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
FROM favorite_tierlists f FROM favorite_tierlists f
INNER JOIN tierlists t ON t.id = f.tierlist_id INNER JOIN tierlists t ON t.id = f.tierlist_id
INNER JOIN users u ON u.id = t.author_id INNER JOIN users u ON u.id = t.author_id
INNER JOIN games g ON g.id = t.game_id INNER JOIN topics tp ON tp.id = t.topic_id
${whereClause} ${whereClause}
${orderClause} ${orderClause}
`, `,
@@ -1893,7 +1940,7 @@ async function listUserTierLists(userId) {
` `
SELECT SELECT
t.id, t.id,
t.game_id, t.topic_id,
t.title, t.title,
t.thumbnail_src, t.thumbnail_src,
t.created_at, t.created_at,
@@ -1912,7 +1959,8 @@ async function listUserTierLists(userId) {
const tierLists = rows.map((row) => ({ const tierLists = rows.map((row) => ({
id: row.id, id: row.id,
gameId: row.game_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),
@@ -1958,25 +2006,26 @@ function getAutoThumbnailSrc(groups = [], pool = []) {
return fallbackItem?.src || '' return fallbackItem?.src || ''
} }
async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit = 50, currentUserId = '' } = {}) { async function listAdminTierLists({ queryText = '', gameId = '', 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 hasGameId = !!(gameId || '').trim() const resolvedTopicId = (topicId || gameId || '').trim()
const hasGameId = !!resolvedTopicId
const search = `%${(queryText || '').trim()}%` const search = `%${(queryText || '').trim()}%`
const whereParts = [] const whereParts = []
const params = [] const params = []
if (hasGameId) { if (hasGameId) {
whereParts.push('t.game_id = ?') whereParts.push('t.topic_id = ?')
params.push((gameId || '').trim()) params.push(resolvedTopicId)
} }
if (hasQuery) { if (hasQuery) {
whereParts.push(`( whereParts.push(`(
t.title LIKE ? t.title LIKE ?
OR g.name LIKE ? OR tp.name LIKE ?
OR g.id LIKE ? OR tp.id LIKE ?
OR u.email LIKE ? OR u.email LIKE ?
OR u.nickname LIKE ? OR u.nickname LIKE ?
)`) )`)
@@ -1990,8 +2039,8 @@ async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit
SELECT SELECT
t.id, t.id,
t.author_id, t.author_id,
t.game_id, t.topic_id,
g.name AS game_name, tp.name AS topic_name,
t.title, t.title,
t.thumbnail_src, t.thumbnail_src,
t.description, t.description,
@@ -2010,7 +2059,7 @@ async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit
u.avatar_src u.avatar_src
FROM tierlists t FROM tierlists t
INNER JOIN users u ON u.id = t.author_id INNER JOIN users u ON u.id = t.author_id
INNER JOIN games g ON g.id = t.game_id INNER JOIN topics tp ON tp.id = t.topic_id
${whereClause} ${whereClause}
ORDER BY t.updated_at DESC, t.created_at DESC ORDER BY t.updated_at DESC, t.created_at DESC
`, `,
@@ -2044,23 +2093,24 @@ async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit
} }
} }
async function summarizeAdminTierLists({ queryText = '', gameId = '' } = {}) { async function summarizeAdminTierLists({ queryText = '', gameId = '', topicId = '' } = {}) {
const hasQuery = !!(queryText || '').trim() const hasQuery = !!(queryText || '').trim()
const hasGameId = !!(gameId || '').trim() const resolvedTopicId = (topicId || gameId || '').trim()
const hasGameId = !!resolvedTopicId
const search = `%${(queryText || '').trim()}%` const search = `%${(queryText || '').trim()}%`
const whereParts = [] const whereParts = []
const params = [] const params = []
if (hasGameId) { if (hasGameId) {
whereParts.push('t.game_id = ?') whereParts.push('t.topic_id = ?')
params.push((gameId || '').trim()) params.push(resolvedTopicId)
} }
if (hasQuery) { if (hasQuery) {
whereParts.push(`( whereParts.push(`(
t.title LIKE ? t.title LIKE ?
OR g.name LIKE ? OR tp.name LIKE ?
OR g.id LIKE ? OR tp.id LIKE ?
OR u.email LIKE ? OR u.email LIKE ?
OR u.nickname LIKE ? OR u.nickname LIKE ?
)`) )`)
@@ -2073,7 +2123,7 @@ async function summarizeAdminTierLists({ queryText = '', gameId = '' } = {}) {
SELECT t.is_public SELECT t.is_public
FROM tierlists t FROM tierlists t
INNER JOIN users u ON u.id = t.author_id INNER JOIN users u ON u.id = t.author_id
INNER JOIN games g ON g.id = t.game_id INNER JOIN topics tp ON tp.id = t.topic_id
${whereClause} ${whereClause}
`, `,
params params
@@ -2094,8 +2144,8 @@ async function findTierListById(id, currentUserId = '') {
SELECT SELECT
t.id, t.id,
t.author_id, t.author_id,
t.game_id, t.topic_id,
g.name AS game_name, tp.name AS topic_name,
t.title, t.title,
t.thumbnail_src, t.thumbnail_src,
t.description, t.description,
@@ -2114,7 +2164,7 @@ async function findTierListById(id, currentUserId = '') {
u.avatar_src u.avatar_src
FROM tierlists t FROM tierlists t
INNER JOIN users u ON u.id = t.author_id INNER JOIN users u ON u.id = t.author_id
INNER JOIN games g ON g.id = t.game_id INNER JOIN topics tp ON tp.id = t.topic_id
WHERE t.id = ? WHERE t.id = ?
LIMIT 1 LIMIT 1
`, `,
@@ -2146,6 +2196,8 @@ async function createTemplateRequest({
sourceTierListId = '', sourceTierListId = '',
sourceGameId, sourceGameId,
targetGameId = '', targetGameId = '',
sourceTopicId = sourceGameId,
targetTopicId = targetGameId,
title, title,
description = '', description = '',
thumbnailSrc = '', thumbnailSrc = '',
@@ -2171,8 +2223,8 @@ async function createTemplateRequest({
request_type, request_type,
requester_id, requester_id,
source_tierlist_id, source_tierlist_id,
source_game_id, source_topic_id,
target_game_id, target_topic_id,
status, status,
title_snapshot, title_snapshot,
description_snapshot, description_snapshot,
@@ -2191,8 +2243,8 @@ async function createTemplateRequest({
type, type,
requesterId, requesterId,
sourceTierListId || null, sourceTierListId || null,
sourceGameId, sourceTopicId,
targetGameId, targetTopicId,
title, title,
description, description,
thumbnailSrc, thumbnailSrc,
@@ -2215,8 +2267,8 @@ async function findTemplateRequestById(id) {
tr.request_type, tr.request_type,
tr.requester_id, tr.requester_id,
tr.source_tierlist_id, tr.source_tierlist_id,
tr.source_game_id, tr.source_topic_id,
tr.target_game_id, tr.target_topic_id,
tr.status, tr.status,
tr.title_snapshot, tr.title_snapshot,
tr.description_snapshot, tr.description_snapshot,
@@ -2230,12 +2282,12 @@ async function findTemplateRequestById(id) {
u.nickname, u.nickname,
u.email, u.email,
u.avatar_src AS requester_avatar_src, u.avatar_src AS requester_avatar_src,
sg.name AS source_game_name, sg.name AS source_topic_name,
tg.name AS target_game_name tg.name AS target_topic_name
FROM template_requests tr FROM template_requests tr
INNER JOIN users u ON u.id = tr.requester_id INNER JOIN users u ON u.id = tr.requester_id
LEFT JOIN games sg ON sg.id = tr.source_game_id LEFT JOIN topics sg ON sg.id = tr.source_topic_id
LEFT JOIN games tg ON tg.id = tr.target_game_id LEFT JOIN topics tg ON tg.id = tr.target_topic_id
WHERE tr.id = ? WHERE tr.id = ?
LIMIT 1 LIMIT 1
`, `,
@@ -2257,8 +2309,8 @@ async function listAdminTemplateRequests({ status = 'pending', statuses = [] } =
tr.request_type, tr.request_type,
tr.requester_id, tr.requester_id,
tr.source_tierlist_id, tr.source_tierlist_id,
tr.source_game_id, tr.source_topic_id,
tr.target_game_id, tr.target_topic_id,
tr.status, tr.status,
tr.title_snapshot, tr.title_snapshot,
tr.description_snapshot, tr.description_snapshot,
@@ -2272,12 +2324,12 @@ async function listAdminTemplateRequests({ status = 'pending', statuses = [] } =
u.nickname, u.nickname,
u.email, u.email,
u.avatar_src AS requester_avatar_src, u.avatar_src AS requester_avatar_src,
sg.name AS source_game_name, sg.name AS source_topic_name,
tg.name AS target_game_name tg.name AS target_topic_name
FROM template_requests tr FROM template_requests tr
INNER JOIN users u ON u.id = tr.requester_id INNER JOIN users u ON u.id = tr.requester_id
LEFT JOIN games sg ON sg.id = tr.source_game_id LEFT JOIN topics sg ON sg.id = tr.source_topic_id
LEFT JOIN games tg ON tg.id = tr.target_game_id LEFT JOIN topics tg ON tg.id = tr.target_topic_id
WHERE tr.status IN (${placeholders}) WHERE tr.status IN (${placeholders})
ORDER BY ORDER BY
CASE tr.status CASE tr.status
@@ -2299,7 +2351,7 @@ async function updateTemplateRequestStatus({ id, status }) {
} }
async function updateTemplateRequestTargetGame({ id, targetGameId }) { async function updateTemplateRequestTargetGame({ id, targetGameId }) {
await query('UPDATE template_requests SET target_game_id = ?, updated_at = ? WHERE id = ?', [targetGameId || '', now(), id]) await query('UPDATE template_requests SET target_topic_id = ?, updated_at = ? WHERE id = ?', [targetGameId || '', now(), id])
return findTemplateRequestById(id) return findTemplateRequestById(id)
} }
@@ -2350,6 +2402,7 @@ async function saveTierList({
id, id,
authorId, authorId,
gameId, gameId,
topicId = gameId,
title, title,
thumbnailSrc = '', thumbnailSrc = '',
description, description,
@@ -2383,11 +2436,11 @@ async function saveTierList({
await query( await query(
` `
INSERT INTO tierlists ( INSERT INTO tierlists (
id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, icon_size, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at id, author_id, topic_id, title, thumbnail_src, description, is_public, show_character_names, icon_size, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
[nextId, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, Number(iconSize) || 80, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt] [nextId, authorId, topicId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, Number(iconSize) || 80, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
) )
return findTierListById(nextId, authorId) return findTierListById(nextId, authorId)
} }
@@ -2400,7 +2453,8 @@ async function duplicateTierListForUser({ tierList, targetUserId }) {
return saveTierList({ return saveTierList({
id: duplicateId, id: duplicateId,
authorId: targetUserId, authorId: targetUserId,
gameId: tierList.gameId, gameId: tierList.topicId || tierList.gameId,
topicId: tierList.topicId || tierList.gameId,
title: copyTitle, title: copyTitle,
thumbnailSrc: tierList.thumbnailSrc || '', thumbnailSrc: tierList.thumbnailSrc || '',
description: tierList.description || '', description: tierList.description || '',
@@ -2424,11 +2478,11 @@ async function unfavoriteTierList({ userId, tierListId }) {
} }
async function favoriteGame({ userId, gameId }) { async function favoriteGame({ userId, gameId }) {
await query('INSERT IGNORE INTO favorite_games (user_id, game_id, created_at) VALUES (?, ?, ?)', [userId, gameId, now()]) await query('INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at) VALUES (?, ?, ?)', [userId, gameId, now()])
} }
async function unfavoriteGame({ userId, gameId }) { async function unfavoriteGame({ userId, gameId }) {
await query('DELETE FROM favorite_games WHERE user_id = ? AND game_id = ?', [userId, gameId]) await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, gameId])
} }
module.exports = { module.exports = {

View File

@@ -54,6 +54,10 @@ const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState
const router = express.Router() const router = express.Router()
function getTemplateIdParam(req) {
return req.params.templateId || req.params.gameId || ''
}
function buildUploadFilename(file) { function buildUploadFilename(file) {
const ext = path.extname(file.originalname || '').toLowerCase() const ext = path.extname(file.originalname || '').toLowerCase()
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : '' const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
@@ -110,7 +114,7 @@ function canManageAdminRole(actingUser, primaryAdmin) {
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
} }
router.post('/games', requireAdmin, async (req, res) => { router.post(['/games', '/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),
@@ -126,24 +130,26 @@ router.post('/games', requireAdmin, async (req, res) => {
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc) const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
await updateGameThumbnail(game.id, copiedThumb) await updateGameThumbnail(game.id, copiedThumb)
} }
res.json({ game: await findGameById(game.id) }) const template = await findGameById(game.id)
res.json({ game: template, template })
}) })
router.patch('/games/:gameId', requireAdmin, async (req, res) => { router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
isPublic: z.boolean(), isPublic: z.boolean(),
}) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(req.params.gameId) const templateId = getTemplateIdParam(req)
const game = await findGameById(templateId)
if (!game) return res.status(404).json({ error: 'not_found' }) if (!game) return res.status(404).json({ error: 'not_found' })
const updated = await updateGameVisibility(game.id, parsed.data.isPublic) const updated = await updateGameVisibility(game.id, parsed.data.isPublic)
res.json({ game: updated }) res.json({ game: updated, template: updated })
}) })
router.patch('/games/display-order', requireAdmin, async (req, res) => { router.patch(['/games/display-order', '/templates/display-order'], requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
gameIds: z.array(z.string().min(1)).max(50), gameIds: z.array(z.string().min(1)).max(50),
}) })
@@ -154,26 +160,28 @@ router.patch('/games/display-order', requireAdmin, async (req, res) => {
const validGameIds = new Set(games.map((game) => game.id)) const validGameIds = new Set(games.map((game) => game.id))
const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId)) const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId))
const updatedGames = await updateGameDisplayOrder(filteredIds) const updatedGames = await updateGameDisplayOrder(filteredIds)
res.json({ games: updatedGames }) res.json({ games: updatedGames, templates: updatedGames })
}) })
router.patch('/games/:gameId/items/display-order', requireAdmin, async (req, res) => { router.patch(['/games/:gameId/items/display-order', '/templates/:templateId/items/display-order'], requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
itemIds: z.array(z.string().min(1)).min(1), itemIds: z.array(z.string().min(1)).min(1),
}) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(req.params.gameId) const templateId = getTemplateIdParam(req)
const game = await findGameById(templateId)
if (!game) return res.status(404).json({ error: 'not_found' }) if (!game) return res.status(404).json({ error: 'not_found' })
const items = await updateGameItemDisplayOrder(game.id, parsed.data.itemIds) const items = await updateGameItemDisplayOrder(game.id, parsed.data.itemIds)
res.json({ items }) res.json({ items })
}) })
router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => { router.post(['/games/:gameId/thumbnail', '/templates/:templateId/thumbnail'], requireAdmin, upload.single('thumbnail'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'file_required' }) if (!req.file) return res.status(400).json({ error: 'file_required' })
const game = await findGameById(req.params.gameId) const templateId = getTemplateIdParam(req)
const game = await findGameById(templateId)
if (!game) return res.status(404).json({ error: 'not_found' }) if (!game) return res.status(404).json({ error: 'not_found' })
const optimized = await writeOptimizedImage({ const optimized = await writeOptimizedImage({
@@ -185,14 +193,15 @@ router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail')
quality: 84, quality: 84,
}) })
const updated = await updateGameThumbnail(req.params.gameId, optimized.src) const updated = await updateGameThumbnail(templateId, optimized.src)
res.json({ game: updated }) res.json({ game: updated, template: updated })
}) })
router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), async (req, res) => { router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireAdmin, upload.array('images', 50), async (req, res) => {
const files = Array.isArray(req.files) ? req.files : [] const files = Array.isArray(req.files) ? req.files : []
if (!files.length) return res.status(400).json({ error: 'file_required' }) if (!files.length) return res.status(400).json({ error: 'file_required' })
const game = await findGameById(req.params.gameId) const templateId = getTemplateIdParam(req)
const game = await findGameById(templateId)
if (!game) return res.status(404).json({ error: 'not_found' }) if (!game) return res.status(404).json({ error: 'not_found' })
const labelsRaw = req.body?.labels const labelsRaw = req.body?.labels
@@ -223,19 +232,19 @@ router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), a
res.json({ item: items[0], items }) res.json({ item: items[0], items })
}) })
router.delete('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => { router.delete(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
const game = await findGameById(req.params.gameId) const game = await findGameById(getTemplateIdParam(req))
if (!game) return res.status(404).json({ error: 'not_found' }) if (!game) return res.status(404).json({ error: 'not_found' })
await deleteGameItem(req.params.itemId) await deleteGameItem(req.params.itemId)
res.json({ ok: true }) res.json({ ok: true })
}) })
router.patch('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => { router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
const schema = z.object({ label: z.string().trim().min(1).max(60) }) const schema = z.object({ label: z.string().trim().min(1).max(60) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(req.params.gameId) const game = await findGameById(getTemplateIdParam(req))
if (!game) return res.status(404).json({ error: 'not_found' }) if (!game) return res.status(404).json({ error: 'not_found' })
const updated = await updateGameItemLabel(req.params.itemId, parsed.data.label) const updated = await updateGameItemLabel(req.params.itemId, parsed.data.label)
@@ -243,10 +252,11 @@ router.patch('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
res.json({ item: updated }) res.json({ item: updated })
}) })
router.delete('/games/:gameId', requireAdmin, async (req, res) => { router.delete(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
const game = await findGameById(req.params.gameId) const templateId = getTemplateIdParam(req)
const game = await findGameById(templateId)
if (!game) return res.status(404).json({ error: 'not_found' }) if (!game) return res.status(404).json({ error: 'not_found' })
await deleteGame(req.params.gameId) await deleteGame(templateId)
res.json({ ok: true }) res.json({ ok: true })
}) })
@@ -298,6 +308,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
router.get('/tierlists', requireAdmin, async (req, res) => { router.get('/tierlists', requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
q: z.string().trim().max(120).optional().default(''), q: z.string().trim().max(120).optional().default(''),
topicId: z.string().trim().max(120).optional().default(''),
gameId: 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),
@@ -307,6 +318,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,
gameId: parsed.data.gameId, gameId: parsed.data.gameId,
page: parsed.data.page, page: parsed.data.page,
limit: parsed.data.limit, limit: parsed.data.limit,
@@ -318,6 +330,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
router.get('/tierlists/stats', requireAdmin, async (req, res) => { router.get('/tierlists/stats', requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
q: z.string().trim().max(120).optional().default(''), q: z.string().trim().max(120).optional().default(''),
topicId: z.string().trim().max(120).optional().default(''),
gameId: 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)
@@ -325,6 +338,7 @@ router.get('/tierlists/stats', requireAdmin, async (req, res) => {
const result = await summarizeAdminTierLists({ const result = await summarizeAdminTierLists({
queryText: parsed.data.q, queryText: parsed.data.q,
topicId: parsed.data.topicId || parsed.data.gameId,
gameId: parsed.data.gameId, gameId: parsed.data.gameId,
}) })
res.json(result) res.json(result)

View File

@@ -6,7 +6,7 @@ const router = express.Router()
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const games = await listGames(req.session?.userId || '', { includePrivate: !!req.session?.isAdmin }) const games = await listGames(req.session?.userId || '', { includePrivate: !!req.session?.isAdmin })
res.json({ games }) res.json({ games, topics: games })
}) })
router.post('/:gameId/favorite', requireAuth, async (req, res) => { router.post('/:gameId/favorite', requireAuth, async (req, res) => {
@@ -15,7 +15,7 @@ router.post('/:gameId/favorite', requireAuth, async (req, res) => {
await favoriteGame({ userId: req.session.userId, gameId: game.id }) await favoriteGame({ userId: req.session.userId, gameId: game.id })
const games = await listGames(req.session.userId) const games = await listGames(req.session.userId)
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: true } const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: true }
res.json({ game: updated }) res.json({ game: updated, topic: updated })
}) })
router.delete('/:gameId/favorite', requireAuth, async (req, res) => { router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
@@ -24,14 +24,14 @@ router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
await unfavoriteGame({ userId: req.session.userId, gameId: game.id }) await unfavoriteGame({ userId: req.session.userId, gameId: game.id })
const games = await listGames(req.session.userId) const games = await listGames(req.session.userId)
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: false } const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: false }
res.json({ game: updated }) res.json({ game: updated, topic: updated })
}) })
router.get('/:gameId', async (req, res) => { router.get('/:gameId', async (req, res) => {
const detail = await getGameDetail(req.params.gameId) const detail = await getGameDetail(req.params.gameId)
if (!detail) return res.status(404).json({ error: 'not_found' }) if (!detail) return res.status(404).json({ error: 'not_found' })
if (!detail.game.isPublic && !req.session?.isAdmin) return res.status(404).json({ error: 'not_found' }) if (!detail.game.isPublic && !req.session?.isAdmin) return res.status(404).json({ error: 'not_found' })
res.json({ game: detail.game, items: detail.items }) res.json({ game: detail.game, topic: detail.game, items: detail.items })
}) })
module.exports = router module.exports = router

View File

@@ -20,7 +20,7 @@ const { requireAuth } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage') const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
const router = express.Router() const router = express.Router()
const FREEFORM_GAME_ID = 'freeform' const FREEFORM_TOPIC_ID = 'freeform'
const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기' const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기'
function normalizePoolItem(item) { function normalizePoolItem(item) {
@@ -61,7 +61,8 @@ 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), gameId: 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),
thumbnailSrc: z.string().max(255).optional().default(''), thumbnailSrc: z.string().max(255).optional().default(''),
@@ -72,7 +73,11 @@ const templateRequestSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
name: z.string().min(1).max(16), name: z.string().min(1).max(16),
itemIds: z.array(z.string()).optional().default([]), itemIds: z.array(z.string()).optional().default([]),
}).passthrough() }).passthrough().superRefine((value, ctx) => {
if (!(value.topicId || value.gameId)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['topicId'] })
}
})
), ),
boardItems: z.array( boardItems: z.array(
z.object({ z.object({
@@ -86,7 +91,8 @@ const templateRequestSchema = z.object({
const tierListUpsertSchema = z.object({ const tierListUpsertSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
gameId: z.string().min(1), gameId: 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(''),
description: z.string().max(1000).optional().default(''), description: z.string().max(1000).optional().default(''),
@@ -111,12 +117,16 @@ const tierListUpsertSchema = z.object({
origin: z.enum(['game', 'custom']).default('game'), origin: z.enum(['game', 'custom']).default('game'),
}) })
), ),
}).superRefine((value, ctx) => {
if (!(value.topicId || value.gameId)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['topicId'] })
}
}) })
router.get('/public', async (req, res) => { router.get('/public', async (req, res) => {
const gameId = req.query.gameId const topicId = typeof req.query.topicId === 'string' ? req.query.topicId : req.query.gameId
const queryText = typeof req.query.q === 'string' ? req.query.q : '' const queryText = typeof req.query.q === 'string' ? req.query.q : ''
const lists = await listPublicTierLists(gameId, req.session?.userId || '', queryText) const lists = await listPublicTierLists(topicId, req.session?.userId || '', queryText)
res.json({ tierLists: lists }) res.json({ tierLists: lists })
}) })
@@ -226,14 +236,15 @@ router.post('/template-request', requireAuth, async (req, res) => {
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const payload = parsed.data const payload = parsed.data
const topicId = payload.topicId || payload.gameId
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem) const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom') const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' }) if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
if (payload.type === 'create') { if (payload.type === 'create') {
if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' }) if (topicId !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' })
} else if (payload.gameId === FREEFORM_GAME_ID) { } else if (topicId === FREEFORM_TOPIC_ID) {
return res.status(400).json({ error: 'game_template_required' }) return res.status(400).json({ error: 'topic_template_required' })
} }
let sourceTierList = null let sourceTierList = null
@@ -251,8 +262,10 @@ router.post('/template-request', requireAuth, async (req, res) => {
type: payload.type, type: payload.type,
requesterId: req.session.userId, requesterId: req.session.userId,
sourceTierListId: sourceTierList?.id || '', sourceTierListId: sourceTierList?.id || '',
sourceGameId: payload.gameId, sourceGameId: topicId,
targetGameId: payload.type === 'update' ? payload.gameId : '', sourceTopicId: topicId,
targetGameId: payload.type === 'update' ? topicId : '',
targetTopicId: payload.type === 'update' ? topicId : '',
title: payload.requestTitle, title: payload.requestTitle,
description: payload.requestDescription, description: payload.requestDescription,
thumbnailSrc: payload.thumbnailSrc || '', thumbnailSrc: payload.thumbnailSrc || '',
@@ -274,6 +287,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 normalizedPool = payload.pool.map(normalizePoolItem) const normalizedPool = payload.pool.map(normalizePoolItem)
let existing = null let existing = null
@@ -284,7 +298,8 @@ router.post('/', requireAuth, async (req, res) => {
const updated = await saveTierList({ const updated = await saveTierList({
id: existing.id, id: existing.id,
authorId: existing.authorId, authorId: existing.authorId,
gameId: existing.gameId, gameId: existing.topicId || existing.gameId,
topicId: existing.topicId || existing.gameId,
title: payload.title, title: payload.title,
thumbnailSrc: payload.thumbnailSrc || '', thumbnailSrc: payload.thumbnailSrc || '',
description: payload.description || '', description: payload.description || '',
@@ -303,7 +318,8 @@ router.post('/', requireAuth, async (req, res) => {
const created = await saveTierList({ const created = await saveTierList({
id: nanoid(), id: nanoid(),
authorId: req.session.userId, authorId: req.session.userId,
gameId: payload.gameId, gameId: topicId,
topicId,
title: payload.title, title: payload.title,
thumbnailSrc: payload.thumbnailSrc || '', thumbnailSrc: payload.thumbnailSrc || '',
description: payload.description || '', description: payload.description || '',

View File

@@ -1,5 +1,15 @@
# 의사결정 이력 # 의사결정 이력
## 2026-04-02 v1.4.13
- 사용자 표면과 API 이름층까지 `topic/template`로 옮긴 뒤에는, DB 스키마도 실제로 따라오게 해야 이후 유지보수 비용이 덜 쌓이므로 `games` 계열 실명을 `topics` 계열로 마이그레이션하는 편이 맞다고 판단했다.
- 다만 한 번에 응답 키까지 완전히 끊으면 프런트와 관리자 흐름이 너무 크게 흔들릴 수 있으므로, 이번 단계에서는 실제 저장 스키마는 `topic`으로 옮기고 응답의 `gameId / gameName`은 호환 키로 잠시 함께 유지하는 점진 마감이 가장 안전하다고 정리했다.
## 2026-04-02 v1.4.12
- 프런트 이름만 바꾸는 단계가 끝난 뒤에는, 백엔드도 새 `/api/topics`, `/api/admin/templates` 경로를 열고 기존 `/games`는 호환용으로 남기는 점진 전환이 가장 안전하다고 판단했다.
## 2026-04-02 v1.4.11
- 백엔드 `/api/games` 경로를 바로 바꾸기보다, 프런트 API 객체에서 먼저 `topic/template` 의미 이름을 제공하고 호출부를 옮기는 편이 위험이 훨씬 낮다고 판단했다.
## 2026-04-02 v1.4.10 ## 2026-04-02 v1.4.10
- 사용자 주소는 이미 `/topics`로 옮기기 시작했으므로, 라우트 이름과 기본 파라미터도 `topicHub / topicId` 기준으로 맞추고 기존 `gameId`는 호환 fallback으로만 남기는 편이 더 자연스럽다고 판단했다. - 사용자 주소는 이미 `/topics`로 옮기기 시작했으므로, 라우트 이름과 기본 파라미터도 `topicHub / topicId` 기준으로 맞추고 기존 `gameId`는 호환 fallback으로만 남기는 편이 더 자연스럽다고 판단했다.

View File

@@ -1,6 +1,12 @@
# 할 일 및 이슈 # 할 일 및 이슈
## 단기 확인 ## 단기 확인
- `v1.4.13`부터 DB 실명도 `topics / topic_items / favorite_topics / topic_id` 기준으로 옮겼으므로, 기존 운영 DB에서 서버 재시작 후 자동 마이그레이션이 한 번만 자연스럽게 수행되는지 먼저 확인한다.
- 백엔드 응답은 현재 `topicId / topicName``gameId / gameName`을 함께 내려주고 있으므로, 다음 단계에서는 실제 프런트/관리자에서 더 이상 `gameId` fallback이 필요 없는 지점을 확인해 호환 키 제거 순서를 정한다.
- 티어표 공개 목록, 관리자 전체 티어표 관리, 저장/요청 API는 `topicId`를 우선 받도록 바꿨으므로, 실제 브라우저에서 검색/저장/공유/관리자 필터가 모두 같은 파라미터 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
- `/api/topics`, `/api/admin/templates` alias를 연 뒤 프런트 호출도 새 경로로 옮겼으므로, 실제 브라우저에서 주제 목록/즐겨찾기/주제 상세/관리자 템플릿 관리가 모두 같은 세션으로 자연스럽게 동작하는지 한 번 더 QA한다.
- 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다.
- 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 API 이름층으로 바뀐 뒤에도 저장, 즐겨찾기, 템플릿 생성, 아이템 정렬 흐름이 자연스러운지 한 번 더 QA한다.
- `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다. - `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다.
- 다음 단계에서는 `api.getGame`, `listGames`, `favoriteGame`처럼 남아 있는 프런트 API 이름을 어느 수준까지 `topic/template` 의미로 감쌀지 정리한다. - 다음 단계에서는 `api.getGame`, `listGames`, `favoriteGame`처럼 남아 있는 프런트 API 이름을 어느 수준까지 `topic/template` 의미로 감쌀지 정리한다.
- 경로 헬퍼를 사용자 주요 화면에 연결했으므로, 로그인 리다이렉트·공유 프리뷰·복사본 이동·주제 복귀 흐름이 실제 브라우저에서 모두 같은 주소 체계로 자연스럽게 이어지는지 한 번 더 QA한다. - 경로 헬퍼를 사용자 주요 화면에 연결했으므로, 로그인 리다이렉트·공유 프리뷰·복사본 이동·주제 복귀 흐름이 실제 브라우저에서 모두 같은 주소 체계로 자연스럽게 이어지는지 한 번 더 QA한다.

View File

@@ -1,5 +1,18 @@
# 업데이트 로그 # 업데이트 로그
## 2026-04-02 v1.4.13
- DB 실명 변경 마지막 단계로 `games / game_items / favorite_games``topics / topic_items / favorite_topics` 기준으로 자동 마이그레이션하도록 정리하고, `tierlists.game_id`, `template_requests.source_game_id/target_game_id`도 각각 `topic_id`, `source_topic_id/target_topic_id`로 옮기게 했다.
- 백엔드 저장/조회 쿼리는 이제 새 topic 스키마를 기준으로 동작하고, 응답에는 `topicId / topicName`을 기본으로 내려주되 기존 프런트가 바로 깨지지 않도록 `gameId / gameName`도 잠시 함께 유지했다.
- 티어표 공개 목록, 관리자 전체 티어표 관리, 티어표 저장/요청 API는 `topicId`를 우선 받도록 정리하고 기존 `gameId`는 호환 입력으로만 남겨, 외부 표면과 실제 저장 스키마가 한 단계 더 가까워지게 맞췄다.
## 2026-04-02 v1.4.12
- 백엔드에 `/api/topics``/api/admin/templates` alias 경로를 추가하고, 주제/템플릿 응답도 `topic/topics`, `template/templates` 키를 함께 내려주도록 정리했다.
- 프런트의 새 의미 이름은 이제 실제로도 `/api/topics`, `/api/admin/templates`를 타도록 연결해, 경로 이름과 호출 이름이 다시 어긋나지 않게 맞췄다.
## 2026-04-02 v1.4.11
- 프런트 API 이름층을 한 단계 더 정리해 `listTopics / getTopic / favoriteTopic`, `updateAdminTemplate*`, `searchPublicTierListsByTopic` 같은 의미 기반 이름을 추가하고 실제 호출부도 이 기준으로 옮겼다.
- 백엔드 경로와 응답 구조는 그대로 유지한 채 프런트에서 읽는 이름만 먼저 바꿔, 다음 단계의 API/모델 리네이밍 부담을 더 줄였다.
## 2026-04-02 v1.4.10 ## 2026-04-02 v1.4.10
- 주제 상세 라우트 이름을 `topicHub`로, 기본 경로 파라미터를 `topicId`로 바꾸고 기존 `gameId` 주소는 alias로 유지했다. - 주제 상세 라우트 이름을 `topicHub`로, 기본 경로 파라미터를 `topicId`로 바꾸고 기존 `gameId` 주소는 alias로 유지했다.
- 앱 셸, 주제 상세, 티어표 편집기는 이제 내부에서 `topicId`를 우선 읽고, 레거시 주소로 들어온 경우에만 `gameId` fallback을 쓰도록 정리했다. - 앱 셸, 주제 상세, 티어표 편집기는 이제 내부에서 `topicId`를 우선 읽고, 레거시 주소로 들어온 경우에만 `gameId` fallback을 쓰도록 정리했다.

View File

@@ -167,7 +167,7 @@ export function useAdminCustomItems({
try { try {
item.isPromoting = true item.isPromoting = true
await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetTemplateId.value }) await api.promoteAdminTemplateItem(item.id, { gameId: 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

@@ -70,7 +70,7 @@ export function useAdminFeaturedGames({
async function saveFeaturedOrder() { async function saveFeaturedOrder() {
resetMessages() resetMessages()
try { try {
const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredTemplateIds.value }) const data = await api.updateAdminTemplateDisplayOrder({ gameIds: featuredTemplateIds.value })
templates.value = data.games || [] templates.value = data.games || []
featuredTemplateIds.value = templates.value featuredTemplateIds.value = templates.value
.filter((template) => template.displayRank != null) .filter((template) => template.displayRank != null)

View File

@@ -131,7 +131,7 @@ export function useAdminGameManager({
try { try {
isGameLoading.value = true isGameLoading.value = true
const data = await api.getGame(selectedTemplateId.value) const data = await api.getTopic(selectedTemplateId.value)
selectedTemplate.value = { selectedTemplate.value = {
...data, ...data,
items: (data.items || []).map((item) => ({ items: (data.items || []).map((item) => ({
@@ -155,7 +155,7 @@ export function useAdminGameManager({
const preserveUploadState = !!options.preserveUploadState const preserveUploadState = !!options.preserveUploadState
resetMessages() resetMessages()
try { try {
const res = await fetch(toApiUrl('/api/admin/games'), { const res = await fetch(toApiUrl('/api/admin/templates'), {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -283,7 +283,7 @@ export function useAdminGameManager({
fd.append('images', entry.file) fd.append('images', entry.file)
fd.append('labels', entry.label.trim()) fd.append('labels', entry.label.trim())
}) })
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}/images`), { const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/images`), {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: fd, body: fd,
@@ -336,7 +336,7 @@ export function useAdminGameManager({
if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) return if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) return
try { try {
const data = await api.updateAdminGameItemDisplayOrder(selectedTemplateId.value, { const data = await api.updateAdminTemplateItemDisplayOrder(selectedTemplateId.value, {
itemIds: selectedTemplate.value.items.map((item) => item.id), itemIds: selectedTemplate.value.items.map((item) => item.id),
}) })
selectedTemplate.value = { selectedTemplate.value = {

View File

@@ -30,27 +30,27 @@ export const api = {
login: ({ email, password }) => request('/api/auth/login', { method: 'POST', body: { email, password } }), login: ({ email, password }) => request('/api/auth/login', { method: 'POST', body: { email, password } }),
logout: () => request('/api/auth/logout', { method: 'POST' }), logout: () => request('/api/auth/logout', { method: 'POST' }),
listGames: () => request('/api/games'), listTopics: () => request('/api/topics'),
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`), getTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}`),
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }), favoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'POST' }),
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }), unfavoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'DELETE' }),
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }), updateAdminTemplateDisplayOrder: (payload) => request('/api/admin/templates/display-order', { method: 'PATCH', body: payload }),
updateAdminGameItemDisplayOrder: (gameId, payload) => updateAdminTemplateItemDisplayOrder: (templateId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { method: 'PATCH', body: payload }), request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/display-order`, { method: 'PATCH', body: payload }),
updateAdminGame: (gameId, payload) => updateAdminTemplate: (templateId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}`, { method: 'PATCH', body: payload }), request(`/api/admin/templates/${encodeURIComponent(templateId)}`, { method: 'PATCH', body: payload }),
updateAdminGameItem: (gameId, itemId, payload) => updateAdminTemplateItem: (templateId, itemId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }), request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) => listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) =>
request( request(
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}` `/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
), ),
listAdminTierLists: ({ q = '', gameId = '', page = 1, limit = 50 } = {}) => listAdminTierLists: ({ q = '', topicId = '', gameId = '', page = 1, limit = 50 } = {}) =>
request( request(
`/api/admin/tierlists?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}` `/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId || gameId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`
), ),
getAdminTierListStats: ({ q = '', gameId = '' } = {}) => getAdminTierListStats: ({ q = '', topicId = '', gameId = '' } = {}) =>
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(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) =>
@@ -66,13 +66,13 @@ export const api = {
cleanupAdminMissingImageReferences: () => request('/api/admin/image-assets/missing/cleanup', { method: 'POST', body: {} }), cleanupAdminMissingImageReferences: () => request('/api/admin/image-assets/missing/cleanup', { method: 'POST', body: {} }),
listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`), listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`),
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }), cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
promoteAdminCustomItem: (itemId, payload) => promoteAdminTemplateItem: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }), request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
updateAdminCustomItemLabel: (itemId, payload) => updateAdminCustomItemLabel: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }), request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
promoteAdminTierListItems: (tierListId, payload) => promoteAdminTierListItems: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }), request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
createAdminGameTemplateFromTierList: (tierListId, payload) => createAdminTemplateFromTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }), request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-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: {} }),
@@ -111,10 +111,10 @@ export const api = {
}, },
deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }), deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
listPublicTierLists: (gameId) => listPublicTierListsByTopic: (topicId) =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`), request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}`),
searchPublicTierLists: (gameId, q = '') => searchPublicTierListsByTopic: (topicId, q = '') =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`), request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}&q=${encodeURIComponent(q || '')}`),
searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`), searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`),
listMyTierLists: () => request('/api/tierlists/me'), listMyTierLists: () => request('/api/tierlists/me'),
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) => listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>
@@ -146,4 +146,24 @@ 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

@@ -787,7 +787,7 @@ async function selectAdminTemplate(templateId) {
async function refreshTemplates() { async function refreshTemplates() {
try { try {
const data = await api.listGames() const data = await api.listTopics()
templates.value = data.games || [] templates.value = data.games || []
featuredTemplateIds.value = templates.value featuredTemplateIds.value = templates.value
.filter((template) => template.displayRank != null) .filter((template) => template.displayRank != null)
@@ -1148,7 +1148,7 @@ async function uploadThumbnail() {
try { try {
const fd = new FormData() const fd = new FormData()
fd.append('thumbnail', thumbFile.value) fd.append('thumbnail', thumbFile.value)
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}/thumbnail`), { const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/thumbnail`), {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: fd, body: fd,
@@ -1170,7 +1170,7 @@ async function saveTemplateVisibility() {
if (!selectedTemplate.value?.game?.id) return if (!selectedTemplate.value?.game?.id) return
try { try {
gameVisibilitySaving.value = true gameVisibilitySaving.value = true
const data = await api.updateAdminGame(selectedTemplate.value.game.id, { const data = await api.updateAdminTemplate(selectedTemplate.value.game.id, {
isPublic: !!selectedTemplate.value.game.isPublic, isPublic: !!selectedTemplate.value.game.isPublic,
}) })
selectedTemplate.value = { selectedTemplate.value = {
@@ -1217,7 +1217,7 @@ async function removeTemplateItem(itemId) {
resetMessages() resetMessages()
try { try {
const res = await fetch( const res = await fetch(
toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}`), toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}`),
{ {
method: 'DELETE', method: 'DELETE',
credentials: 'include', credentials: 'include',
@@ -1244,7 +1244,7 @@ async function saveTemplateItemLabel(item) {
try { try {
item.isSavingLabel = true item.isSavingLabel = true
const data = await api.updateAdminGameItem(selectedTemplateId.value, item.id, { label: nextLabel }) const data = await api.updateAdminTemplateItem(selectedTemplateId.value, item.id, { label: nextLabel })
item.label = data.item.label item.label = data.item.label
item.draftLabel = data.item.label item.draftLabel = data.item.label
success.value = '기본 아이템 이름을 수정했어요.' success.value = '기본 아이템 이름을 수정했어요.'
@@ -1263,7 +1263,7 @@ async function removeTemplate() {
if (!ok) return if (!ok) return
try { try {
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}`), { const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}`), {
method: 'DELETE', method: 'DELETE',
credentials: 'include', credentials: 'include',
}) })
@@ -1589,7 +1589,7 @@ async function confirmTierListImport() {
return return
} }
const data = await api.createAdminGameTemplateFromTierList(tierList.id, { const data = await api.createAdminTemplateFromTierList(tierList.id, {
gameId: nextGameId, gameId: nextGameId,
name: nextGameName, name: nextGameName,
itemIds, itemIds,

View File

@@ -54,8 +54,8 @@ async function loadTierLists() {
isTopicLoading.value = true isTopicLoading.value = true
try { try {
const [gameRes, listRes] = await Promise.all([ const [gameRes, listRes] = await Promise.all([
api.getGame(topicId.value), api.getTopic(topicId.value),
api.searchPublicTierLists(topicId.value, query.value), api.searchPublicTierListsByTopic(topicId.value, query.value),
]) ])
topicName.value = gameRes.game?.name || '' topicName.value = gameRes.game?.name || ''
brokenThumbnailIds.value = {} brokenThumbnailIds.value = {}

View File

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

View File

@@ -898,7 +898,7 @@ onMounted(() => {
} }
try { try {
const gameRes = await api.getGame(templateId.value) const gameRes = await api.getTopic(templateId.value)
templateName.value = gameRes.game?.name || templateId.value templateName.value = gameRes.game?.name || templateId.value
const base = (gameRes.items || []).map((img) => ({ const base = (gameRes.items || []).map((img) => ({
id: img.id, id: img.id,