Compare commits

..

6 Commits

11 changed files with 473 additions and 186 deletions

View File

@@ -74,6 +74,7 @@ app.use(async (req, res, next) => {
await ensureData()
next()
} catch (e) {
console.error('[backend] db init failed', e)
res.status(500).json({ error: 'db_init_failed' })
}
})

View File

@@ -8,7 +8,8 @@ const DB_USER = process.env.DB_USER || 'root'
const DB_PASSWORD = process.env.DB_PASSWORD || ''
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 FREEFORM_GAME_ID = 'freeform'
const FREEFORM_TOPIC_ID = 'freeform'
const FREEFORM_GAME_ID = FREEFORM_TOPIC_ID
let poolPromise = null
let initPromise = null
@@ -72,6 +73,8 @@ function mapGameRow(row) {
return {
id: row.id,
name: row.name,
topicId: row.id,
topicName: row.name,
thumbnailSrc: row.thumbnail_src || '',
isPublic: row.is_public == null ? true : !!row.is_public,
displayRank: row.display_rank == null ? null : Number(row.display_rank),
@@ -83,7 +86,8 @@ function mapGameItemRow(row) {
if (!row) return null
return {
id: row.id,
gameId: row.game_id,
topicId: row.topic_id,
gameId: row.topic_id,
src: row.src,
label: row.label,
displayOrder: row.display_order == null ? null : Number(row.display_order),
@@ -131,8 +135,10 @@ function mapTierListRow(row) {
authorName: getUserDisplayName(row),
authorAccountName: getUserAccountName(row),
authorAvatarSrc: row.avatar_src || '',
gameId: row.game_id,
gameName: row.game_name || '',
topicId: row.topic_id,
topicName: row.topic_name || '',
gameId: row.topic_id,
gameName: row.topic_name || '',
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
description: row.description || '',
@@ -159,13 +165,17 @@ function mapTemplateRequestRow(row) {
requesterAccountName: getUserAccountName(row),
requesterAvatarSrc: row.requester_avatar_src || '',
sourceTierListId: row.source_tierlist_id || '',
sourceGameId: row.source_game_id,
sourceGameName: row.source_game_name || '',
sourceTopicId: row.source_topic_id,
sourceTopicName: row.source_topic_name || '',
sourceGameId: row.source_topic_id,
sourceGameName: row.source_topic_name || '',
sourceTierListTitle: row.title_snapshot || '',
sourceDescription: row.description_snapshot || '',
thumbnailSrc: row.thumbnail_src_snapshot || '',
targetGameId: row.target_game_id || '',
targetGameName: row.target_game_name || '',
targetTopicId: row.target_topic_id || '',
targetTopicName: row.target_topic_name || '',
targetGameId: row.target_topic_id || '',
targetGameName: row.target_topic_name || '',
status: row.status,
items: parseJson(row.items_json, []),
snapshotGroups: parseJson(row.groups_json, []),
@@ -230,6 +240,32 @@ async function query(sql, params = []) {
return rows
}
async function tableExists(name) {
const rows = await query(
`
SELECT TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
LIMIT 1
`,
[DB_NAME, name]
)
return rows.length > 0
}
async function columnExists(tableName, columnName) {
const rows = await query(
`
SELECT COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?
LIMIT 1
`,
[DB_NAME, tableName, columnName]
)
return rows.length > 0
}
async function closePool() {
if (!poolPromise) return
const pool = await poolPromise
@@ -241,6 +277,10 @@ async function closePool() {
async function ensureSchema() {
if (initPromise) return initPromise
initPromise = (async () => {
const legacyGamesExists = await tableExists('games')
const legacyGameItemsExists = await tableExists('game_items')
const legacyFavoriteGamesExists = await tableExists('favorite_games')
await query(`
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(64) PRIMARY KEY,
@@ -254,7 +294,7 @@ async function ensureSchema() {
`)
await query(`
CREATE TABLE IF NOT EXISTS games (
CREATE TABLE IF NOT EXISTS topics (
id VARCHAR(120) PRIMARY KEY,
name VARCHAR(120) NOT NULL,
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
@@ -264,33 +304,52 @@ async function ensureSchema() {
) 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) {
await query('ALTER TABLE games 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('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')
}
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) {
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')
}
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(`
CREATE TABLE IF NOT EXISTS game_items (
CREATE TABLE IF NOT EXISTS topic_items (
id VARCHAR(64) PRIMARY KEY,
game_id VARCHAR(120) NOT NULL,
topic_id VARCHAR(120) NOT NULL,
src VARCHAR(255) NOT NULL,
label VARCHAR(120) NOT NULL,
display_order INT NULL DEFAULT NULL,
created_at BIGINT NOT NULL,
INDEX idx_game_items_game_id (game_id),
CONSTRAINT fk_game_items_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
INDEX idx_topic_items_topic_id (topic_id),
CONSTRAINT fk_topic_items_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
) 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) {
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')
}
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(`
@@ -309,7 +368,7 @@ async function ensureSchema() {
CREATE TABLE IF NOT EXISTS tierlists (
id VARCHAR(64) PRIMARY KEY,
author_id VARCHAR(64) NOT NULL,
game_id VARCHAR(120) NOT NULL,
topic_id VARCHAR(120) NOT NULL,
title VARCHAR(120) NOT NULL,
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
description TEXT NOT NULL,
@@ -324,10 +383,10 @@ async function ensureSchema() {
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
INDEX idx_tierlists_author_id (author_id),
INDEX idx_tierlists_game_id (game_id),
INDEX idx_tierlists_public_game_updated (is_public, game_id, updated_at),
INDEX idx_tierlists_topic_id (topic_id),
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_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
`)
@@ -344,17 +403,25 @@ async function ensureSchema() {
`)
await query(`
CREATE TABLE IF NOT EXISTS favorite_games (
CREATE TABLE IF NOT EXISTS favorite_topics (
user_id VARCHAR(64) NOT NULL,
game_id VARCHAR(120) NOT NULL,
topic_id VARCHAR(120) NOT NULL,
created_at BIGINT NOT NULL,
PRIMARY KEY (user_id, game_id),
INDEX idx_favorite_games_game_id (game_id),
CONSTRAINT fk_favorite_games_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
PRIMARY KEY (user_id, topic_id),
INDEX idx_favorite_topics_topic_id (topic_id),
CONSTRAINT fk_favorite_topics_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_favorite_topics_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
) 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(`
CREATE TABLE IF NOT EXISTS image_assets (
id VARCHAR(64) PRIMARY KEY,
@@ -399,8 +466,8 @@ async function ensureSchema() {
request_type VARCHAR(20) NOT NULL,
requester_id VARCHAR(64) NOT NULL,
source_tierlist_id VARCHAR(64) NOT NULL,
source_game_id VARCHAR(120) NOT NULL,
target_game_id VARCHAR(120) NOT NULL DEFAULT '',
source_topic_id VARCHAR(120) NOT NULL,
target_topic_id VARCHAR(120) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
title_snapshot VARCHAR(120) NOT NULL,
description_snapshot TEXT NOT NULL,
@@ -424,17 +491,23 @@ async function ensureSchema() {
if (!templateRequestTypeColumns.length) {
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'")
if (!templateRequestSourceGameColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN source_game_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
const hasSourceTopicId = await columnExists('template_requests', 'source_topic_id')
if (!hasSourceTopicId) {
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 templateRequestTargetGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'target_game_id'")
if (!templateRequestTargetGameColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN target_game_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_game_id")
const hasTargetTopicId = await columnExists('template_requests', 'target_topic_id')
if (!hasTargetTopicId) {
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'")
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'")
if (!templateRequestGroupsColumns.length) {
@@ -453,6 +526,13 @@ async function ensureSchema() {
if (!tierListThumbnailColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
}
const tierListTopicIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'topic_id'")
if (!tierListTopicIdColumns.length) {
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'")
if (!tierListShowNamesColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
@@ -478,19 +558,19 @@ async function ensureSchema() {
await query(
`
INSERT INTO games (id, name, thumbnail_src, created_at)
INSERT INTO topics (id, name, thumbnail_src, created_at)
VALUES (?, ?, ?, ?)
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) {
const createdAt = now()
await query(
`
INSERT INTO games (id, name, thumbnail_src, created_at)
INSERT INTO topics (id, name, thumbnail_src, created_at)
VALUES
(?, ?, ?, ?),
(?, ?, ?, ?)
@@ -500,7 +580,7 @@ async function ensureSchema() {
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
(?, ?, ?, ?, ?),
(?, ?, ?, ?, ?)
@@ -662,7 +742,7 @@ async function listGames(currentUserId = '', options = {}) {
const rows = await query(
`
SELECT id, name, thumbnail_src, is_public, display_rank, created_at
FROM games
FROM topics
WHERE id <> ?
${includePrivate ? '' : 'AND is_public = 1'}
ORDER BY
@@ -671,13 +751,13 @@ async function listGames(currentUserId = '', options = {}) {
created_at DESC,
name ASC
`,
[FREEFORM_GAME_ID]
[FREEFORM_TOPIC_ID]
)
const games = rows.map(mapGameRow)
if (!currentUserId) return games.map((game) => ({ ...game, isFavorited: false }))
const favoriteRows = await query('SELECT game_id FROM favorite_games WHERE user_id = ?', [currentUserId])
const favoriteSet = new Set(favoriteRows.map((row) => row.game_id))
const favoriteRows = await query('SELECT topic_id FROM favorite_topics WHERE user_id = ?', [currentUserId])
const favoriteSet = new Set(favoriteRows.map((row) => row.topic_id))
return games.map((game) => ({
...game,
isFavorited: favoriteSet.has(game.id),
@@ -685,16 +765,16 @@ async function listGames(currentUserId = '', options = {}) {
}
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])
}
async function listGameItems(gameId) {
const rows = await query(
`
SELECT id, game_id, src, label, display_order, created_at
FROM game_items
WHERE game_id = ?
SELECT id, topic_id, src, label, display_order, created_at
FROM topic_items
WHERE topic_id = ?
ORDER BY
CASE WHEN display_order IS NULL THEN 1 ELSE 0 END ASC,
display_order ASC,
@@ -707,7 +787,7 @@ async function listGameItems(gameId) {
}
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])
}
@@ -719,7 +799,7 @@ async function getGameDetail(gameId) {
}
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,
name,
'',
@@ -731,12 +811,12 @@ async function createGame({ id, name, isPublic = true }) {
}
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)
}
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)
}
@@ -838,8 +918,8 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"),
query("SELECT src FROM game_items WHERE src <> ''"),
query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT src FROM topic_items WHERE src <> ''"),
query("SELECT src FROM custom_items WHERE src <> ''"),
query("SELECT thumbnail_src, pool_json FROM tierlists"),
query("SELECT thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
@@ -891,8 +971,8 @@ async function listReferencedUploadUsage() {
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"),
query("SELECT src FROM game_items WHERE src <> ''"),
query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT src FROM topic_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_snapshot, items_json, board_items_json FROM template_requests"),
@@ -934,8 +1014,8 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
const [userResult, gameResult, gameItemResult, customItemResult] = await Promise.all([
query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
query('UPDATE games SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
query('UPDATE game_items SET src = ? WHERE 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 custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
])
@@ -1099,8 +1179,8 @@ async function cleanupMissingUploadReferences() {
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
query("SELECT id, avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT id, thumbnail_src FROM games WHERE thumbnail_src <> ''"),
query("SELECT id, src FROM game_items WHERE 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 custom_items WHERE src <> ''"),
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"),
@@ -1114,7 +1194,7 @@ async function cleanupMissingUploadReferences() {
for (const row of gameRows) {
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
}
@@ -1269,10 +1349,10 @@ async function clearImageOptimizationJobs({ month } = {}) {
}
async function createGameItem({ id, gameId, src, label }) {
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 =
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,
gameId,
src,
@@ -1280,13 +1360,13 @@ async function createGameItem({ id, gameId, src, label }) {
nextDisplayOrder,
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])
}
async function updateGameItemLabel(itemId, label) {
await query('UPDATE game_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])
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])
return mapGameItemRow(rows[0])
}
@@ -1299,7 +1379,7 @@ async function updateGameItemDisplayOrder(gameId, itemIds) {
const finalIds = [...orderedIds, ...remainingIds]
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)
@@ -1334,15 +1414,15 @@ async function updateImageAssetLabel(assetId, label) {
}
async function deleteGameItem(itemId) {
const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId])
const gameId = gameItemRows[0]?.game_id
const gameItemRows = await query('SELECT topic_id FROM topic_items WHERE id = ? LIMIT 1', [itemId])
const gameId = gameItemRows[0]?.topic_id
if (gameId) {
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
WHERE game_id = ?
WHERE topic_id = ?
`,
[gameId]
)
@@ -1362,21 +1442,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) {
await query('DELETE FROM games WHERE id = ?', [gameId])
await query('DELETE FROM topics WHERE id = ?', [gameId])
}
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(
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 +1518,9 @@ async function findCustomItemById(id) {
async function getCustomItemUsageMeta() {
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
LEFT JOIN games g ON g.id = t.game_id
LEFT JOIN topics tp ON tp.id = t.topic_id
`
)
const usageMap = new Map()
@@ -1465,13 +1545,13 @@ async function getCustomItemUsageMeta() {
}
})
if (!row.game_id) return
if (!row.topic_id) return
seenItemIds.forEach((itemId) => {
if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map())
linkedGamesMap.get(itemId).set(row.game_id, {
id: row.game_id,
name: row.game_name || row.game_id,
linkedGamesMap.get(itemId).set(row.topic_id, {
id: row.topic_id,
name: row.topic_name || row.topic_id,
})
})
})
@@ -1511,14 +1591,14 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
`
SELECT
gi.id,
gi.game_id,
gi.topic_id,
gi.src,
gi.label,
gi.created_at,
g.name AS game_name
FROM game_items gi
INNER JOIN games g ON g.id = gi.game_id
${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.game_id LIKE ? OR g.name LIKE ?' : ''}
tp.name AS topic_name
FROM topic_items gi
INNER JOIN topics tp ON tp.id = gi.topic_id
${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.topic_id LIKE ? OR tp.name LIKE ?' : ''}
ORDER BY gi.created_at DESC
`,
hasQuery ? [search, search, search, search] : []
@@ -1540,9 +1620,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
gameItemRows.forEach((row) => {
if (!row?.src) return
if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map())
templateLinkedBySrc.get(row.src).set(row.game_id, {
id: row.game_id,
name: row.game_name || row.game_id,
templateLinkedBySrc.get(row.src).set(row.topic_id, {
id: row.topic_id,
name: row.topic_name || row.topic_id,
})
})
@@ -1593,15 +1673,15 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
ownerName: row.game_name || row.game_id,
ownerName: row.topic_name || row.topic_id,
ownerEmail: '',
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
linkedGames: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
sourceType: 'template',
sourceLabel: '관리자 템플릿',
canDelete: true,
sourceGameId: row.game_id,
sourceGameName: row.game_name || row.game_id,
sourceGameId: row.topic_id,
sourceGameName: row.topic_name || row.topic_id,
}))
const baseItems = [...customItems, ...templateItems, ...assetLibraryItems]
@@ -1767,12 +1847,12 @@ function applyFavoriteMetaToTierLists(tierLists, favoriteStats) {
}))
}
async function listPublicTierLists(gameId, currentUserId = '', queryText = '') {
async function listPublicTierLists(topicId, currentUserId = '', queryText = '') {
const params = []
let whereClause = 'WHERE t.is_public = 1'
if (gameId) {
whereClause += ' AND t.game_id = ?'
params.push(gameId)
if (topicId) {
whereClause += ' AND t.topic_id = ?'
params.push(topicId)
}
if ((queryText || '').trim()) {
const search = `%${queryText.trim()}%`
@@ -1784,7 +1864,7 @@ async function listPublicTierLists(gameId, currentUserId = '', queryText = '') {
`
SELECT
t.id,
t.game_id,
t.topic_id,
t.title,
t.thumbnail_src,
t.created_at,
@@ -1804,7 +1884,8 @@ async function listPublicTierLists(gameId, currentUserId = '', queryText = '') {
const tierLists = rows.map((row) => ({
id: row.id,
gameId: row.game_id,
topicId: row.topic_id,
gameId: row.topic_id,
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
createdAt: Number(row.created_at),
@@ -1830,7 +1911,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
if ((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)
}
@@ -1846,8 +1927,8 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
SELECT
t.id,
t.author_id,
t.game_id,
g.name AS game_name,
t.topic_id,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
t.description,
@@ -1873,7 +1954,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
FROM favorite_tierlists f
INNER JOIN tierlists t ON t.id = f.tierlist_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}
${orderClause}
`,
@@ -1893,7 +1974,7 @@ async function listUserTierLists(userId) {
`
SELECT
t.id,
t.game_id,
t.topic_id,
t.title,
t.thumbnail_src,
t.created_at,
@@ -1912,7 +1993,8 @@ async function listUserTierLists(userId) {
const tierLists = rows.map((row) => ({
id: row.id,
gameId: row.game_id,
topicId: row.topic_id,
gameId: row.topic_id,
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
createdAt: Number(row.created_at),
@@ -1958,25 +2040,26 @@ function getAutoThumbnailSrc(groups = [], pool = []) {
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 normalizedPage = Math.max(Number(page) || 1, 1)
const hasQuery = !!(queryText || '').trim()
const hasGameId = !!(gameId || '').trim()
const resolvedTopicId = (topicId || gameId || '').trim()
const hasGameId = !!resolvedTopicId
const search = `%${(queryText || '').trim()}%`
const whereParts = []
const params = []
if (hasGameId) {
whereParts.push('t.game_id = ?')
params.push((gameId || '').trim())
whereParts.push('t.topic_id = ?')
params.push(resolvedTopicId)
}
if (hasQuery) {
whereParts.push(`(
t.title LIKE ?
OR g.name LIKE ?
OR g.id LIKE ?
OR tp.name LIKE ?
OR tp.id LIKE ?
OR u.email LIKE ?
OR u.nickname LIKE ?
)`)
@@ -1990,8 +2073,8 @@ async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit
SELECT
t.id,
t.author_id,
t.game_id,
g.name AS game_name,
t.topic_id,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
t.description,
@@ -2010,7 +2093,7 @@ async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit
u.avatar_src
FROM tierlists t
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}
ORDER BY t.updated_at DESC, t.created_at DESC
`,
@@ -2044,23 +2127,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 hasGameId = !!(gameId || '').trim()
const resolvedTopicId = (topicId || gameId || '').trim()
const hasGameId = !!resolvedTopicId
const search = `%${(queryText || '').trim()}%`
const whereParts = []
const params = []
if (hasGameId) {
whereParts.push('t.game_id = ?')
params.push((gameId || '').trim())
whereParts.push('t.topic_id = ?')
params.push(resolvedTopicId)
}
if (hasQuery) {
whereParts.push(`(
t.title LIKE ?
OR g.name LIKE ?
OR g.id LIKE ?
OR tp.name LIKE ?
OR tp.id LIKE ?
OR u.email LIKE ?
OR u.nickname LIKE ?
)`)
@@ -2073,7 +2157,7 @@ async function summarizeAdminTierLists({ queryText = '', gameId = '' } = {}) {
SELECT t.is_public
FROM tierlists t
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}
`,
params
@@ -2094,8 +2178,8 @@ async function findTierListById(id, currentUserId = '') {
SELECT
t.id,
t.author_id,
t.game_id,
g.name AS game_name,
t.topic_id,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
t.description,
@@ -2114,7 +2198,7 @@ async function findTierListById(id, currentUserId = '') {
u.avatar_src
FROM tierlists t
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 = ?
LIMIT 1
`,
@@ -2146,6 +2230,8 @@ async function createTemplateRequest({
sourceTierListId = '',
sourceGameId,
targetGameId = '',
sourceTopicId = sourceGameId,
targetTopicId = targetGameId,
title,
description = '',
thumbnailSrc = '',
@@ -2171,8 +2257,8 @@ async function createTemplateRequest({
request_type,
requester_id,
source_tierlist_id,
source_game_id,
target_game_id,
source_topic_id,
target_topic_id,
status,
title_snapshot,
description_snapshot,
@@ -2191,8 +2277,8 @@ async function createTemplateRequest({
type,
requesterId,
sourceTierListId || null,
sourceGameId,
targetGameId,
sourceTopicId,
targetTopicId,
title,
description,
thumbnailSrc,
@@ -2215,8 +2301,8 @@ async function findTemplateRequestById(id) {
tr.request_type,
tr.requester_id,
tr.source_tierlist_id,
tr.source_game_id,
tr.target_game_id,
tr.source_topic_id,
tr.target_topic_id,
tr.status,
tr.title_snapshot,
tr.description_snapshot,
@@ -2230,12 +2316,12 @@ async function findTemplateRequestById(id) {
u.nickname,
u.email,
u.avatar_src AS requester_avatar_src,
sg.name AS source_game_name,
tg.name AS target_game_name
sg.name AS source_topic_name,
tg.name AS target_topic_name
FROM template_requests tr
INNER JOIN users u ON u.id = tr.requester_id
LEFT JOIN games sg ON sg.id = tr.source_game_id
LEFT JOIN games tg ON tg.id = tr.target_game_id
LEFT JOIN topics sg ON sg.id = tr.source_topic_id
LEFT JOIN topics tg ON tg.id = tr.target_topic_id
WHERE tr.id = ?
LIMIT 1
`,
@@ -2257,8 +2343,8 @@ async function listAdminTemplateRequests({ status = 'pending', statuses = [] } =
tr.request_type,
tr.requester_id,
tr.source_tierlist_id,
tr.source_game_id,
tr.target_game_id,
tr.source_topic_id,
tr.target_topic_id,
tr.status,
tr.title_snapshot,
tr.description_snapshot,
@@ -2272,12 +2358,12 @@ async function listAdminTemplateRequests({ status = 'pending', statuses = [] } =
u.nickname,
u.email,
u.avatar_src AS requester_avatar_src,
sg.name AS source_game_name,
tg.name AS target_game_name
sg.name AS source_topic_name,
tg.name AS target_topic_name
FROM template_requests tr
INNER JOIN users u ON u.id = tr.requester_id
LEFT JOIN games sg ON sg.id = tr.source_game_id
LEFT JOIN games tg ON tg.id = tr.target_game_id
LEFT JOIN topics sg ON sg.id = tr.source_topic_id
LEFT JOIN topics tg ON tg.id = tr.target_topic_id
WHERE tr.status IN (${placeholders})
ORDER BY
CASE tr.status
@@ -2299,7 +2385,7 @@ async function updateTemplateRequestStatus({ id, status }) {
}
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)
}
@@ -2350,6 +2436,7 @@ async function saveTierList({
id,
authorId,
gameId,
topicId = gameId,
title,
thumbnailSrc = '',
description,
@@ -2383,11 +2470,11 @@ async function saveTierList({
await query(
`
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[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)
}
@@ -2400,7 +2487,8 @@ async function duplicateTierListForUser({ tierList, targetUserId }) {
return saveTierList({
id: duplicateId,
authorId: targetUserId,
gameId: tierList.gameId,
gameId: tierList.topicId || tierList.gameId,
topicId: tierList.topicId || tierList.gameId,
title: copyTitle,
thumbnailSrc: tierList.thumbnailSrc || '',
description: tierList.description || '',
@@ -2424,11 +2512,11 @@ async function unfavoriteTierList({ userId, tierListId }) {
}
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 }) {
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 = {

View File

@@ -308,6 +308,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
router.get('/tierlists', requireAdmin, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
topicId: z.string().trim().max(120).optional().default(''),
gameId: z.string().trim().max(120).optional().default(''),
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
@@ -317,6 +318,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
const result = await listAdminTierLists({
queryText: parsed.data.q,
topicId: parsed.data.topicId || parsed.data.gameId,
gameId: parsed.data.gameId,
page: parsed.data.page,
limit: parsed.data.limit,
@@ -328,6 +330,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
router.get('/tierlists/stats', requireAdmin, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
topicId: z.string().trim().max(120).optional().default(''),
gameId: z.string().trim().max(120).optional().default(''),
})
const parsed = schema.safeParse(req.query)
@@ -335,6 +338,7 @@ router.get('/tierlists/stats', requireAdmin, async (req, res) => {
const result = await summarizeAdminTierLists({
queryText: parsed.data.q,
topicId: parsed.data.topicId || parsed.data.gameId,
gameId: parsed.data.gameId,
})
res.json(result)

View File

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

View File

@@ -1,5 +1,27 @@
# 의사결정 이력
## 2026-04-02 v1.4.18
- 새 창 열기처럼 브라우저 기본 앵커가 충분한 동작은 템플릿 안에서 `window.open`을 직접 부르기보다, 기본 링크 동작에 맡기는 편이 더 단순하고 안전하다고 판단했다.
## 2026-04-02 v1.4.17
- `editor` 주소는 이전과 현재가 같은 URL 형태를 공유하므로, 여기까지 redirect를 두면 호환성이 아니라 자기 자신으로의 재해석만 반복하게 된다. 이 구간은 별도 레거시 레코드를 두지 않고 현재 라우트 하나로 수용하는 편이 맞다고 판단했다.
## 2026-04-02 v1.4.16
- 백엔드/DB 장애 상황을 단순 연결 실패처럼 보여주면 사용자가 원인을 잘못 이해하게 되므로, 네트워크 단절과 서버 점검/초기화 실패를 전역 UI에서 분리해서 안내하는 편이 맞다고 판단했다.
- 이런 장애 안내는 각 화면별 에러 문구를 따로 손보는 것보다 `api` 공통 계층에서 상태를 감지하고 `App` 셸이 한 번에 전환하는 구조가 재사용성과 유지보수 측면에서 더 안전하다고 정리했다.
## 2026-04-02 v1.4.15
- 실제 운영 DB에서 마지막 500 원인을 먼저 재현해본 결과, 스키마 설계보다 MariaDB의 `SHOW ... LIKE ?` 플레이스홀더 비호환과 부분 마이그레이션 상태 재진입 이슈가 핵심이었으므로, 이 단계에선 구조 변경보다 기동 안정성을 먼저 회복하는 편이 맞다고 판단했다.
- 마이그레이션 로직은 “처음 실행”뿐 아니라 “반쯤 적용된 상태에서 다시 실행”도 견뎌야 하므로, 컬럼 존재 확인과 조건 분기를 모두 공용 `information_schema` 검사로 모으는 편이 더 안전하다고 정리했다.
## 2026-04-02 v1.4.14
- 기존 `/games` 주소 호환은 alias보다 redirect가 더 맞다고 판단했다. 이번 단계에선 주소는 유지하되 라우트 파라미터 의미는 항상 `topicId`로 정규화해 Vue Router 경고와 내부 분기를 함께 줄였다.
- 운영 DB에 직접 `RENAME TABLE`과 컬럼 `CHANGE`를 거는 방식은 실제 환경에서 실패 여지가 커서, 마지막 스키마 전환도 새 topic 스키마를 먼저 만들고 기존 game 데이터를 복사하는 비파괴 마이그레이션이 더 안전하다고 정리했다.
## 2026-04-02 v1.4.13
- 사용자 표면과 API 이름층까지 `topic/template`로 옮긴 뒤에는, DB 스키마도 실제로 따라오게 해야 이후 유지보수 비용이 덜 쌓이므로 `games` 계열 실명을 `topics` 계열로 마이그레이션하는 편이 맞다고 판단했다.
- 다만 한 번에 응답 키까지 완전히 끊으면 프런트와 관리자 흐름이 너무 크게 흔들릴 수 있으므로, 이번 단계에서는 실제 저장 스키마는 `topic`으로 옮기고 응답의 `gameId / gameName`은 호환 키로 잠시 함께 유지하는 점진 마감이 가장 안전하다고 정리했다.
## 2026-04-02 v1.4.12
- 프런트 이름만 바꾸는 단계가 끝난 뒤에는, 백엔드도 새 `/api/topics`, `/api/admin/templates` 경로를 열고 기존 `/games`는 호환용으로 남기는 점진 전환이 가장 안전하다고 판단했다.

View File

@@ -1,8 +1,16 @@
# 할 일 및 이슈
## 단기 확인
- `v1.4.18`에서 관리자 템플릿 요청 카드 썸네일 클릭을 브라우저 기본 새 창 열기로 정리했으므로, 요청 썸네일 클릭 시 오류 없이 새 탭이 열리고 `전체 티어표 관리` 썸네일 모달 동작과도 섞이지 않는지 한 번 더 QA한다.
- `v1.4.17`에서 주제 컬렉션 카드 클릭 시 에디터 진입 무한 루프를 끊었으므로, 새 티어표 만들기/기존 티어표 열기/공유 링크 열기 세 흐름이 모두 정상 진입하는지 한 번 더 QA한다.
- `v1.4.16`에서 장애 전용 안내 화면을 붙였으므로, 실제로 `db_init_failed`와 네트워크 차단 상황에서 각각 `서비스 점검 중`, `서버 연결 확인 중` 화면이 기대대로 분기되는지 한 번 더 QA한다.
- `v1.4.15`에서 `ensureData()`가 실제 운영 DB 설정으로 `ok`까지 통과한 것은 확인했으므로, 이제는 브라우저에서 `/api/auth/me`, `/api/auth/meta`, `/api/topics` 500이 실제로 사라졌는지와 기존 세션 로그인 흐름이 복구됐는지 한 번 더 QA한다.
- `v1.4.14`부터는 DB 마이그레이션이 rename 대신 복사 기반으로 바뀌었으므로, 실제 운영 DB에서 서버 재시작 후 `topics` 계열 테이블과 `tierlists.topic_id`, `template_requests.source_topic_id/target_topic_id`가 기대대로 채워지는지 먼저 확인한다.
- 레거시 `/games/...``/editor/:gameId/...`는 redirect로 남겼으므로, 오래된 북마크 진입 후 주소가 `/topics/...`, `/editor/:topicId/...`로 자연스럽게 정규화되는지 한 번 더 QA한다.
- `v1.4.13`부터 DB 실명도 `topics / topic_items / favorite_topics / topic_id` 기준으로 옮겼으므로, 기존 운영 DB에서 서버 재시작 후 자동 마이그레이션이 한 번만 자연스럽게 수행되는지 먼저 확인한다.
- 백엔드 응답은 현재 `topicId / topicName``gameId / gameName`을 함께 내려주고 있으므로, 다음 단계에서는 실제 프런트/관리자에서 더 이상 `gameId` fallback이 필요 없는 지점을 확인해 호환 키 제거 순서를 정한다.
- 티어표 공개 목록, 관리자 전체 티어표 관리, 저장/요청 API는 `topicId`를 우선 받도록 바꿨으므로, 실제 브라우저에서 검색/저장/공유/관리자 필터가 모두 같은 파라미터 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
- `/api/topics`, `/api/admin/templates` alias를 연 뒤 프런트 호출도 새 경로로 옮겼으므로, 실제 브라우저에서 주제 목록/즐겨찾기/주제 상세/관리자 템플릿 관리가 모두 같은 세션으로 자연스럽게 동작하는지 한 번 더 QA한다.
- 다음 마지막 단계에서는 DB 스키마와 백엔드 함수/변수명까지 실제로 옮길지, 아니면 현재 alias 구조를 안정판으로 남길지 최종 결정한다.
- 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다.
- 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 API 이름층으로 바뀐 뒤에도 저장, 즐겨찾기, 템플릿 생성, 아이템 정렬 흐름이 자연스러운지 한 번 더 QA한다.
- `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다.

View File

@@ -1,5 +1,29 @@
# 업데이트 로그
## 2026-04-02 v1.4.18
- 관리자 템플릿 요청 카드 썸네일 클릭은 `window.open(...)`을 템플릿 이벤트 안에서 직접 호출하던 구조 때문에 브라우저 새 창 열기 시 `Cannot read properties of undefined (reading 'open')`가 날 수 있었고, 이를 제거해 앵커의 기본 새 창 동작만 사용하도록 정리했다.
## 2026-04-02 v1.4.17
- 주제 컬렉션에서 티어표 카드를 클릭할 때 `Maximum call stack size exceeded`가 나던 원인은 `editor` 레거시 redirect가 새 라우트와 동일한 URL 패턴을 다시 자기 자신에게 redirect하던 구조였고, 불필요한 `editor` redirect 레코드를 제거해 무한 라우팅 루프를 끊었다.
## 2026-04-02 v1.4.16
- 백엔드나 DB 장애가 났을 때 일반 화면에서 계속 `연결할 수 없어요` 식으로 보이던 흐름을 정리하고, `api` 공통 요청 계층에서 `db_init_failed` 같은 500과 네트워크 실패를 감지해 앱 전체를 점검/연결 확인 화면으로 전환하도록 바꿨다.
- 이제 데이터베이스 초기화 실패나 서버 내부 500은 `서비스 점검 중`, 네트워크 단절은 `서버 연결 확인 중`으로 구분되어 보이며, 사용자는 일반 페이지 대신 전용 안내 화면과 다시 시도 버튼을 보게 된다.
## 2026-04-02 v1.4.15
- `db_init_failed`의 직접 원인은 MariaDB에서 `SHOW TABLES LIKE ?`, `SHOW COLUMNS ... LIKE ?` 플레이스홀더를 허용하지 않던 부분이었고, 이를 `information_schema` 조회 기반으로 바꿔 실제 운영 DB에서도 `ensureData()`가 정상 통과되게 고쳤다.
- 중간 마이그레이션 상태에서 `template_requests.target_topic_id`가 이미 생긴 DB는 중복 컬럼 추가로 다시 실패할 수 있었으므로, 해당 확인도 `columnExists()` 기준으로 바꿔 부분 적용된 DB까지 안전하게 다시 기동되게 정리했다.
## 2026-04-02 v1.4.14
- `/games/:gameId`, `/editor/:gameId/...` 레거시 주소는 Vue Router alias 대신 redirect로 정리해, `topicId` 기준 라우트와 섞일 때 뜨던 param mismatch 경고를 제거했다.
- 운영 DB에서 바로 `RENAME/CHANGE`를 치던 초기 마이그레이션은 위험도가 높아, `topics / topic_items / favorite_topics / topic_id` 스키마를 안전하게 만들고 기존 `games` 계열 데이터를 복사해 오는 방식으로 바꿨다.
- DB 초기화 실패 시 원인을 바로 확인할 수 있도록 백엔드에서 `db_init_failed` 응답 전에 실제 에러를 서버 로그에 남기도록 보강했다.
## 2026-04-02 v1.4.13
- DB 실명 변경 마지막 단계로 `games / game_items / favorite_games``topics / topic_items / favorite_topics` 기준으로 자동 마이그레이션하도록 정리하고, `tierlists.game_id`, `template_requests.source_game_id/target_game_id`도 각각 `topic_id`, `source_topic_id/target_topic_id`로 옮기게 했다.
- 백엔드 저장/조회 쿼리는 이제 새 topic 스키마를 기준으로 동작하고, 응답에는 `topicId / topicName`을 기본으로 내려주되 기존 프런트가 바로 깨지지 않도록 `gameId / gameName`도 잠시 함께 유지했다.
- 티어표 공개 목록, 관리자 전체 티어표 관리, 티어표 저장/요청 API는 `topicId`를 우선 받도록 정리하고 기존 `gameId`는 호환 입력으로만 남겨, 외부 표면과 실제 저장 스키마가 한 단계 더 가까워지게 맞췄다.
## 2026-04-02 v1.4.12
- 백엔드에 `/api/topics``/api/admin/templates` alias 경로를 추가하고, 주제/템플릿 응답도 `topic/topics`, `template/templates` 키를 함께 내려주도록 정리했다.
- 프런트의 새 의미 이름은 이제 실제로도 `/api/topics`, `/api/admin/templates`를 타도록 연결해, 경로 이름과 호출 이름이 다시 어긋나지 않게 맞췄다.

View File

@@ -34,6 +34,8 @@ const isGuideModalOpen = ref(false)
const themeMode = ref('dark')
const guideStepIndex = ref(0)
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
const backendState = ref('online')
const backendMessage = ref('')
provide('rightRailOpen', rightRailOpen)
provide('localRightRailTarget', '#local-right-rail-root')
@@ -138,6 +140,7 @@ const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' :
const showSettingsThemePanel = computed(() => false && route.name === 'profile')
const showTopicViewToggle = computed(() => route.name === 'topicHub')
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
const leftBottomPrimaryAction = computed(() => {
if (!authReady.value) return null
if (route.name === 'home' && auth.user) {
@@ -251,6 +254,13 @@ function syncViewportWidth() {
viewportWidth.value = window.innerWidth
}
function handleBackendStatus(event) {
const state = event?.detail?.state
if (!state) return
backendState.value = state
backendMessage.value = typeof event?.detail?.message === 'string' ? event.detail.message : ''
}
function applyTheme(mode) {
themeMode.value = mode === 'light' ? 'light' : 'dark'
if (typeof document !== 'undefined') document.documentElement.dataset.theme = themeMode.value
@@ -270,6 +280,7 @@ onMounted(async () => {
await auth.refresh()
if (typeof window !== 'undefined') {
syncViewportWidth()
window.addEventListener('tier-maker:backend-status', handleBackendStatus)
window.addEventListener('resize', syncViewportWidth)
window.addEventListener('keydown', handleGlobalKeydown)
const leftSaved = window.localStorage.getItem('tier-maker:left-rail-collapsed')
@@ -292,6 +303,7 @@ function handleGlobalKeydown(event) {
onBeforeUnmount(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('tier-maker:backend-status', handleBackendStatus)
window.removeEventListener('resize', syncViewportWidth)
window.removeEventListener('keydown', handleGlobalKeydown)
}
@@ -400,6 +412,11 @@ function submitGlobalSearch() {
router.push(homePath(query))
}
function reloadApp() {
if (typeof window === 'undefined') return
window.location.reload()
}
</script>
@@ -414,7 +431,26 @@ function submitGlobalSearch() {
}"
:style="shellStyle"
>
<template v-if="isPreviewMode">
<template v-if="showBackendFallback">
<main class="backendFallback">
<section class="backendFallback__card">
<div class="backendFallback__eyebrow">{{ backendState === 'maintenance' ? 'Maintenance' : 'Connection' }}</div>
<h1 class="backendFallback__title">{{ backendState === 'maintenance' ? '서비스 점검 중' : '서버 연결 확인 중' }}</h1>
<p class="backendFallback__desc">
{{
backendMessage ||
(backendState === 'maintenance'
? '백엔드 또는 데이터베이스 작업으로 인해 잠시 이용이 어렵습니다. 잠시 후 다시 시도해주세요.'
: '네트워크 또는 서버 연결 상태를 확인한 뒤 다시 시도해주세요.')
}}
</p>
<div class="backendFallback__actions">
<button class="backendFallback__button" type="button" @click="reloadApp">다시 시도</button>
</div>
</section>
</main>
</template>
<template v-else-if="isPreviewMode">
<main class="appMain appMain--preview">
<RouterView />
</main>
@@ -660,6 +696,65 @@ function submitGlobalSearch() {
transition: grid-template-columns 220ms ease;
}
.backendFallback {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 32px;
background:
radial-gradient(circle at top, rgba(86, 153, 255, 0.14), transparent 38%),
var(--theme-shell-bg);
}
.backendFallback__card {
width: min(100%, 560px);
display: grid;
gap: 18px;
padding: 28px;
border-radius: 28px;
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
}
.backendFallback__eyebrow {
color: var(--theme-accent-strong);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.backendFallback__title {
margin: 0;
font-size: clamp(28px, 4vw, 42px);
line-height: 1.05;
letter-spacing: -0.04em;
}
.backendFallback__desc {
margin: 0;
color: var(--theme-text-muted);
font-size: 15px;
line-height: 1.7;
}
.backendFallback__actions {
display: flex;
justify-content: flex-start;
}
.backendFallback__button {
min-width: 128px;
padding: 12px 18px;
border-radius: 999px;
border: 1px solid rgba(98, 170, 255, 0.32);
background: rgba(98, 170, 255, 0.18);
color: var(--theme-text-strong);
font-weight: 700;
cursor: pointer;
}
.appShell--preview {
display: block;
}

View File

@@ -45,7 +45,6 @@ const props = defineProps({
:target="props.templateRequestSourceUrl(request) ? '_blank' : undefined"
:rel="props.templateRequestSourceUrl(request) ? 'noreferrer' : undefined"
:aria-disabled="!props.templateRequestSourceUrl(request)"
@click.prevent="props.templateRequestSourceUrl(request) && window.open(props.templateRequestSourceUrl(request), '_blank', 'noopener,noreferrer')"
>
<img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" draggable="false" />
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>

View File

@@ -1,25 +1,54 @@
import { toApiUrl } from './runtime'
function emitBackendStatus(detail) {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent('tier-maker:backend-status', { detail }))
}
async function request(path, { method = 'GET', body, headers } = {}) {
const res = await fetch(toApiUrl(path), {
method,
credentials: 'include',
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(headers || {}),
},
body: body ? JSON.stringify(body) : undefined,
})
let res
try {
res = await fetch(toApiUrl(path), {
method,
credentials: 'include',
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(headers || {}),
},
body: body ? JSON.stringify(body) : undefined,
})
} catch (error) {
emitBackendStatus({
state: 'offline',
message: '서버 연결을 확인할 수 없어 잠시 후 다시 시도해주세요.',
path,
})
throw error
}
const contentType = res.headers.get('content-type') || ''
const data = contentType.includes('application/json') ? await res.json() : await res.text()
if (!res.ok) {
if (res.status >= 500 && data?.error === 'db_init_failed') {
emitBackendStatus({
state: 'maintenance',
message: '서비스 점검 중이거나 데이터베이스 초기화 중입니다. 잠시 후 다시 이용해주세요.',
path,
})
} else if (res.status >= 500) {
emitBackendStatus({
state: 'maintenance',
message: '서비스 내부 점검이 필요합니다. 잠시 후 다시 이용해주세요.',
path,
})
}
const err = new Error('request_failed')
err.status = res.status
err.data = data
throw err
}
emitBackendStatus({ state: 'online', path })
return data
}
@@ -45,12 +74,12 @@ export const api = {
request(
`/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(
`/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 = '' } = {}) =>
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}`),
getAdminTierListStats: ({ q = '', topicId = '', gameId = '' } = {}) =>
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId || gameId)}`),
updateAdminTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
deleteAdminTierList: (tierListId) =>
@@ -112,9 +141,9 @@ export const api = {
deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
listPublicTierListsByTopic: (topicId) =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(topicId || '')}`),
request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}`),
searchPublicTierListsByTopic: (topicId, q = '') =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(topicId || '')}&q=${encodeURIComponent(q || '')}`),
request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}&q=${encodeURIComponent(q || '')}`),
searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`),
listMyTierLists: () => request('/api/tierlists/me'),
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>
@@ -163,7 +192,7 @@ export const api = {
createAdminGameTemplateFromTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
listPublicTierLists: (gameId) =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
request(`/api/tierlists/public?topicId=${encodeURIComponent(gameId || '')}`),
searchPublicTierLists: (gameId, q = '') =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
request(`/api/tierlists/public?topicId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
}

View File

@@ -16,9 +16,10 @@ export function createRouter() {
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomeView },
{ path: '/topics/:topicId', alias: ['/games/:gameId'], name: 'topicHub', component: GameHubView },
{ path: '/editor/:topicId/new', alias: ['/editor/:gameId/new'], name: 'newEditor', component: TierEditorView },
{ path: '/editor/:topicId/:tierListId', alias: ['/editor/:gameId/:tierListId'], name: 'editEditor', component: TierEditorView },
{ path: '/games/:gameId', redirect: (to) => `/topics/${encodeURIComponent(String(to.params.gameId || ''))}` },
{ path: '/topics/:topicId', name: 'topicHub', component: GameHubView },
{ path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView },
{ path: '/editor/:topicId/:tierListId', name: 'editEditor', component: TierEditorView },
{ path: '/login', name: 'login', component: LoginView },
{ path: '/me', name: 'me', component: MyTierListsView },
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },