Compare commits

...

7 Commits

13 changed files with 524 additions and 229 deletions

View File

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

View File

@@ -241,12 +241,28 @@ async function query(sql, params = []) {
} }
async function tableExists(name) { async function tableExists(name) {
const rows = await query('SHOW TABLES LIKE ?', [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 return rows.length > 0
} }
async function columnExists(tableName, columnName) { async function columnExists(tableName, columnName) {
const rows = await query(`SHOW COLUMNS FROM \`${tableName}\` LIKE ?`, [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 return rows.length > 0
} }
@@ -261,31 +277,9 @@ 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'))) { const legacyGamesExists = await tableExists('games')
await query('RENAME TABLE games TO topics') const legacyGameItemsExists = await tableExists('game_items')
} const legacyFavoriteGamesExists = await tableExists('favorite_games')
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 (
@@ -321,6 +315,14 @@ async function ensureSchema() {
await query('ALTER TABLE topics ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src') await query('ALTER TABLE topics ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
} }
if (legacyGamesExists) {
await query(`
INSERT IGNORE INTO topics (id, name, thumbnail_src, is_public, display_rank, created_at)
SELECT id, name, thumbnail_src, COALESCE(is_public, 1), display_rank, created_at
FROM games
`)
}
await query(` await query(`
CREATE TABLE IF NOT EXISTS topic_items ( CREATE TABLE IF NOT EXISTS topic_items (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
@@ -339,6 +341,17 @@ async function ensureSchema() {
await query('ALTER TABLE topic_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label') await query('ALTER TABLE topic_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label')
} }
if (legacyGameItemsExists) {
const legacyItemDisplayOrderColumns = await query("SHOW COLUMNS FROM game_items LIKE 'display_order'")
await query(
`
INSERT IGNORE INTO topic_items (id, topic_id, src, label, display_order, created_at)
SELECT id, game_id, src, label, ${legacyItemDisplayOrderColumns.length ? 'display_order' : 'NULL'}, created_at
FROM game_items
`
)
}
await query(` await query(`
CREATE TABLE IF NOT EXISTS custom_items ( CREATE TABLE IF NOT EXISTS custom_items (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
@@ -401,6 +414,14 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`) `)
if (legacyFavoriteGamesExists) {
await query(`
INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at)
SELECT user_id, game_id, created_at
FROM favorite_games
`)
}
await query(` await query(`
CREATE TABLE IF NOT EXISTS image_assets ( CREATE TABLE IF NOT EXISTS image_assets (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
@@ -470,13 +491,19 @@ 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_topic_id'") const hasSourceTopicId = await columnExists('template_requests', 'source_topic_id')
if (!templateRequestSourceGameColumns.length) { if (!hasSourceTopicId) {
await query("ALTER TABLE template_requests ADD COLUMN source_topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id") await query("ALTER TABLE template_requests ADD COLUMN source_topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
if (await columnExists('template_requests', 'source_game_id')) {
await query('UPDATE template_requests SET source_topic_id = source_game_id WHERE source_topic_id = ?', [FREEFORM_TOPIC_ID])
}
} }
const templateRequestTargetGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'target_topic_id'") const hasTargetTopicId = await columnExists('template_requests', 'target_topic_id')
if (!templateRequestTargetGameColumns.length) { if (!hasTargetTopicId) {
await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_topic_id") await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_topic_id")
if (await columnExists('template_requests', 'target_game_id')) {
await query("UPDATE template_requests SET target_topic_id = target_game_id WHERE target_topic_id = ''")
}
} }
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'") const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
if (!templateRequestStatusColumns.length) { if (!templateRequestStatusColumns.length) {
@@ -499,6 +526,13 @@ async function ensureSchema() {
if (!tierListThumbnailColumns.length) { if (!tierListThumbnailColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title") 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'") const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'")
if (!tierListShowNamesColumns.length) { if (!tierListShowNamesColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public") await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
@@ -703,7 +737,7 @@ async function adminDeleteUser(id) {
await query('DELETE FROM users WHERE id = ?', [id]) await query('DELETE FROM users WHERE id = ?', [id])
} }
async function listGames(currentUserId = '', options = {}) { async function listTopics(currentUserId = '', options = {}) {
const includePrivate = !!options.includePrivate const includePrivate = !!options.includePrivate
const rows = await query( const rows = await query(
` `
@@ -719,23 +753,23 @@ async function listGames(currentUserId = '', options = {}) {
`, `,
[FREEFORM_TOPIC_ID] [FREEFORM_TOPIC_ID]
) )
const games = rows.map(mapGameRow) const topics = rows.map(mapGameRow)
if (!currentUserId) return games.map((game) => ({ ...game, isFavorited: false })) if (!currentUserId) return topics.map((topic) => ({ ...topic, isFavorited: false }))
const favoriteRows = await query('SELECT topic_id FROM favorite_topics WHERE user_id = ?', [currentUserId]) const favoriteRows = await query('SELECT topic_id FROM favorite_topics WHERE user_id = ?', [currentUserId])
const favoriteSet = new Set(favoriteRows.map((row) => row.topic_id)) const favoriteSet = new Set(favoriteRows.map((row) => row.topic_id))
return games.map((game) => ({ return topics.map((topic) => ({
...game, ...topic,
isFavorited: favoriteSet.has(game.id), isFavorited: favoriteSet.has(topic.id),
})) }))
} }
async function findGameById(id) { async function findTopicById(id) {
const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1', [id]) const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1', [id])
return mapGameRow(rows[0]) return mapGameRow(rows[0])
} }
async function listGameItems(gameId) { async function listTopicItems(topicId) {
const rows = await query( const rows = await query(
` `
SELECT id, topic_id, src, label, display_order, created_at SELECT id, topic_id, src, label, display_order, created_at
@@ -747,24 +781,24 @@ async function listGameItems(gameId) {
created_at DESC, created_at DESC,
id DESC id DESC
`, `,
[gameId] [topicId]
) )
return rows.map(mapGameItemRow) return rows.map(mapGameItemRow)
} }
async function findGameItemById(itemId) { async function findTopicItemById(itemId) {
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId]) const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
return mapGameItemRow(rows[0]) return mapGameItemRow(rows[0])
} }
async function getGameDetail(gameId) { async function getTopicDetail(topicId) {
const game = await findGameById(gameId) const topic = await findTopicById(topicId)
if (!game) return null if (!topic) return null
const items = await listGameItems(gameId) const items = await listTopicItems(topicId)
return { game, items } return { topic, game: topic, items }
} }
async function createGame({ id, name, isPublic = true }) { async function createTopic({ id, name, isPublic = true }) {
await query('INSERT INTO topics (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,
@@ -773,17 +807,17 @@ async function createGame({ id, name, isPublic = true }) {
null, null,
now(), now(),
]) ])
return findGameById(id) return findTopicById(id)
} }
async function updateGameThumbnail(gameId, thumbnailSrc) { async function updateTopicThumbnail(topicId, thumbnailSrc) {
await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, gameId]) await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, topicId])
return findGameById(gameId) return findTopicById(topicId)
} }
async function updateGameVisibility(gameId, isPublic) { async function updateTopicVisibility(topicId, isPublic) {
await query('UPDATE topics SET is_public = ? WHERE id = ?', [isPublic ? 1 : 0, gameId]) await query('UPDATE topics SET is_public = ? WHERE id = ?', [isPublic ? 1 : 0, topicId])
return findGameById(gameId) return findTopicById(topicId)
} }
async function findImageAssetByHash(contentHash) { async function findImageAssetByHash(contentHash) {
@@ -1313,14 +1347,15 @@ async function clearImageOptimizationJobs({ month } = {}) {
const result = await query('DELETE FROM image_optimization_jobs') const result = await query('DELETE FROM image_optimization_jobs')
return Number(result.affectedRows || 0) return Number(result.affectedRows || 0)
} }
async function createGameItem({ id, gameId, src, label }) { async function createTopicItem({ id, topicId, gameId = topicId, src, label }) {
const createdAt = now() const createdAt = now()
const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM topic_items WHERE topic_id = ?', [gameId]) const resolvedTopicId = topicId || gameId
const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM topic_items WHERE topic_id = ?', [resolvedTopicId])
const nextDisplayOrder = const nextDisplayOrder =
minOrderRows[0]?.min_display_order == null ? 0 : Number(minOrderRows[0].min_display_order) - 1 minOrderRows[0]?.min_display_order == null ? 0 : Number(minOrderRows[0].min_display_order) - 1
await query('INSERT INTO topic_items (id, topic_id, src, label, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?)', [ await query('INSERT INTO topic_items (id, topic_id, src, label, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
id, id,
gameId, resolvedTopicId,
src, src,
label, label,
nextDisplayOrder, nextDisplayOrder,
@@ -1330,25 +1365,25 @@ async function createGameItem({ id, gameId, src, label }) {
return mapGameItemRow(rows[0]) return mapGameItemRow(rows[0])
} }
async function updateGameItemLabel(itemId, label) { async function updateTopicItemLabel(itemId, label) {
await query('UPDATE topic_items SET label = ? WHERE id = ?', [label, itemId]) await query('UPDATE topic_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId]) const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
return mapGameItemRow(rows[0]) return mapGameItemRow(rows[0])
} }
async function updateGameItemDisplayOrder(gameId, itemIds) { async function updateTopicItemDisplayOrder(topicId, itemIds) {
const normalizedIds = Array.from(new Set((itemIds || []).filter(Boolean))) const normalizedIds = Array.from(new Set((itemIds || []).filter(Boolean)))
const existingItems = await listGameItems(gameId) const existingItems = await listTopicItems(topicId)
const existingIdSet = new Set(existingItems.map((item) => item.id)) const existingIdSet = new Set(existingItems.map((item) => item.id))
const orderedIds = normalizedIds.filter((id) => existingIdSet.has(id)) const orderedIds = normalizedIds.filter((id) => existingIdSet.has(id))
const remainingIds = existingItems.map((item) => item.id).filter((id) => !orderedIds.includes(id)) const remainingIds = existingItems.map((item) => item.id).filter((id) => !orderedIds.includes(id))
const finalIds = [...orderedIds, ...remainingIds] const finalIds = [...orderedIds, ...remainingIds]
await Promise.all( await Promise.all(
finalIds.map((itemId, index) => query('UPDATE topic_items SET display_order = ? WHERE id = ? AND topic_id = ?', [index + 1, itemId, gameId])) finalIds.map((itemId, index) => query('UPDATE topic_items SET display_order = ? WHERE id = ? AND topic_id = ?', [index + 1, itemId, topicId]))
) )
return listGameItems(gameId) return listTopicItems(topicId)
} }
async function updateCustomItemLabel(itemId, label) { async function updateCustomItemLabel(itemId, label) {
@@ -1379,44 +1414,44 @@ async function updateImageAssetLabel(assetId, label) {
return mapImageAssetRow(rows[0]) return mapImageAssetRow(rows[0])
} }
async function deleteGameItem(itemId) { async function countTierListsUsingTopicItem(itemId) {
const gameItemRows = await query('SELECT topic_id FROM topic_items WHERE id = ? LIMIT 1', [itemId]) if (!itemId) return { totalCount: 0, publicCount: 0, privateCount: 0 }
const gameId = gameItemRows[0]?.topic_id
if (gameId) { const rows = await query(
const tierListRows = await query( `
` SELECT id, is_public, groups_json, pool_json
SELECT id, author_id, topic_id, title, description, is_public, groups_json, pool_json, created_at, updated_at FROM tierlists
FROM tierlists `
WHERE topic_id = ? )
`,
[gameId]
)
for (const row of tierListRows) { let totalCount = 0
const tierList = mapTierListRow(row) let publicCount = 0
const nextGroups = (tierList.groups || []).map((group) => ({ let privateCount = 0
...group,
itemIds: (group.itemIds || []).filter((id) => id !== itemId),
}))
const nextPool = (tierList.pool || []).filter((item) => item.id !== itemId)
await query( rows.forEach((row) => {
'UPDATE tierlists SET groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ?', const groups = parseJson(row.groups_json, [])
[serializeJson(nextGroups), serializeJson(nextPool), now(), tierList.id] const pool = parseJson(row.pool_json, [])
) const inGroups = groups.some((group) => (group?.itemIds || []).includes(itemId))
} const inPool = pool.some((item) => item?.id === itemId)
} if (!inGroups && !inPool) return
totalCount += 1
if (row.is_public) publicCount += 1
else privateCount += 1
})
return { totalCount, publicCount, privateCount }
}
async function deleteTopicItem(itemId) {
await query('DELETE FROM topic_items WHERE id = ?', [itemId]) await query('DELETE FROM topic_items WHERE id = ?', [itemId])
} }
async function deleteGame(gameId) { async function deleteTopic(topicId) {
await query('DELETE FROM topics WHERE id = ?', [gameId]) await query('DELETE FROM topics WHERE id = ?', [topicId])
} }
async function updateGameDisplayOrder(gameIds) { async function updateTopicDisplayOrder(topicIds) {
const normalizedIds = Array.from(new Set((gameIds || []).filter((id) => id && id !== FREEFORM_TOPIC_ID))).slice(0, 50) const normalizedIds = Array.from(new Set((topicIds || []).filter((id) => id && id !== FREEFORM_TOPIC_ID))).slice(0, 50)
await query('UPDATE topics SET display_rank = NULL WHERE id <> ?', [FREEFORM_TOPIC_ID]) await query('UPDATE topics SET display_rank = NULL WHERE id <> ?', [FREEFORM_TOPIC_ID])
@@ -1426,7 +1461,7 @@ async function updateGameDisplayOrder(gameIds) {
) )
) )
return listGames() return listTopics()
} }
async function createCustomItem({ id, ownerId, src, label }) { async function createCustomItem({ id, ownerId, src, label }) {
@@ -1441,6 +1476,22 @@ async function createCustomItem({ id, ownerId, src, label }) {
return { id, ownerId, src, label, origin: 'custom', createdAt } return { id, ownerId, src, label, origin: 'custom', createdAt }
} }
const listGames = listTopics
const findGameById = findTopicById
const listGameItems = listTopicItems
const findGameItemById = findTopicItemById
const getGameDetail = getTopicDetail
const createGame = createTopic
const updateGameThumbnail = updateTopicThumbnail
const updateGameVisibility = updateTopicVisibility
const createGameItem = createTopicItem
const updateGameItemLabel = updateTopicItemLabel
const updateGameItemDisplayOrder = updateTopicItemDisplayOrder
const countTierListsUsingGameItem = countTierListsUsingTopicItem
const deleteGameItem = deleteTopicItem
const deleteGame = deleteTopic
const updateGameDisplayOrder = updateTopicDisplayOrder
async function syncOwnedCustomItemLabels({ ownerId, items }) { async function syncOwnedCustomItemLabels({ ownerId, items }) {
const customItems = Array.from( const customItems = Array.from(
new Map( new Map(
@@ -2477,14 +2528,19 @@ async function unfavoriteTierList({ userId, tierListId }) {
await query('DELETE FROM favorite_tierlists WHERE user_id = ? AND tierlist_id = ?', [userId, tierListId]) await query('DELETE FROM favorite_tierlists WHERE user_id = ? AND tierlist_id = ?', [userId, tierListId])
} }
async function favoriteGame({ userId, gameId }) { async function favoriteTopic({ userId, topicId, gameId = topicId }) {
await query('INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at) VALUES (?, ?, ?)', [userId, gameId, now()]) const resolvedTopicId = topicId || gameId
await query('INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at) VALUES (?, ?, ?)', [userId, resolvedTopicId, now()])
} }
async function unfavoriteGame({ userId, gameId }) { async function unfavoriteTopic({ userId, topicId, gameId = topicId }) {
await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, gameId]) const resolvedTopicId = topicId || gameId
await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, resolvedTopicId])
} }
const favoriteGame = favoriteTopic
const unfavoriteGame = unfavoriteTopic
module.exports = { module.exports = {
DB_NAME, DB_NAME,
ensureData, ensureData,
@@ -2499,6 +2555,14 @@ module.exports = {
adminUpdateUser, adminUpdateUser,
adminUpdateUserPassword, adminUpdateUserPassword,
adminDeleteUser, adminDeleteUser,
listTopics,
findTopicById,
listTopicItems,
findTopicItemById,
getTopicDetail,
createTopic,
updateTopicThumbnail,
updateTopicVisibility,
listGames, listGames,
findGameById, findGameById,
listGameItems, listGameItems,
@@ -2523,9 +2587,17 @@ module.exports = {
clearImageOptimizationJobs, clearImageOptimizationJobs,
getImageAssetStats, getImageAssetStats,
cleanupMissingUploadReferences, cleanupMissingUploadReferences,
createTopicItem,
updateTopicItemLabel,
updateTopicItemDisplayOrder,
countTierListsUsingTopicItem,
deleteTopicItem,
deleteTopic,
updateTopicDisplayOrder,
createGameItem, createGameItem,
updateGameItemLabel, updateGameItemLabel,
updateGameItemDisplayOrder, updateGameItemDisplayOrder,
countTierListsUsingGameItem,
updateCustomItemLabel, updateCustomItemLabel,
updateImageAssetLabel, updateImageAssetLabel,
deleteGameItem, deleteGameItem,
@@ -2542,6 +2614,8 @@ module.exports = {
summarizeAdminTierLists, summarizeAdminTierLists,
findTierListById, findTierListById,
updateAdminTierListMeta, updateAdminTierListMeta,
favoriteTopic,
unfavoriteTopic,
favoriteTierList, favoriteTierList,
unfavoriteTierList, unfavoriteTierList,
favoriteGame, favoriteGame,

View File

@@ -7,23 +7,24 @@ const { z } = require('zod')
const { nanoid } = require('nanoid') const { nanoid } = require('nanoid')
const { const {
findUserById, findUserById,
findGameById, findTopicById,
findGameItemById, findTopicItemById,
listGameItems, listTopicItems,
findImageAssetById, findImageAssetById,
createGame, createTopic,
listGames, listTopics,
updateGameThumbnail, updateTopicThumbnail,
updateGameVisibility, updateTopicVisibility,
createGameItem, createTopicItem,
updateGameItemLabel, updateTopicItemLabel,
updateGameItemDisplayOrder, updateTopicItemDisplayOrder,
countTierListsUsingTopicItem,
updateCustomItemLabel, updateCustomItemLabel,
updateImageAssetLabel, updateImageAssetLabel,
deleteGameItem, deleteTopicItem,
deleteGame, deleteTopic,
deleteTierList, deleteTierList,
updateGameDisplayOrder, updateTopicDisplayOrder,
listCustomItems, listCustomItems,
findCustomItemById, findCustomItemById,
findUnusedCustomItems, findUnusedCustomItems,
@@ -123,15 +124,15 @@ router.post(['/games', '/templates'], requireAdmin, async (req, res) => {
}) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findGameById(parsed.data.id) const exists = await findTopicById(parsed.data.id)
if (exists) return res.status(409).json({ error: 'game_id_taken' }) if (exists) return res.status(409).json({ error: 'game_id_taken' })
const game = await createGame({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic }) const template = await createTopic({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
if (parsed.data.thumbnailSrc) { if (parsed.data.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc) const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
await updateGameThumbnail(game.id, copiedThumb) await updateTopicThumbnail(template.id, copiedThumb)
} }
const template = await findGameById(game.id) const savedTemplate = await findTopicById(template.id)
res.json({ game: template, template }) res.json({ game: savedTemplate, template: savedTemplate })
}) })
router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => { router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
@@ -142,10 +143,10 @@ router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const templateId = getTemplateIdParam(req) const templateId = getTemplateIdParam(req)
const game = await findGameById(templateId) const template = await findTopicById(templateId)
if (!game) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
const updated = await updateGameVisibility(game.id, parsed.data.isPublic) const updated = await updateTopicVisibility(template.id, parsed.data.isPublic)
res.json({ game: updated, template: updated }) res.json({ game: updated, template: updated })
}) })
@@ -156,10 +157,10 @@ router.patch(['/games/display-order', '/templates/display-order'], requireAdmin,
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const games = await listGames('', { includePrivate: true }) const templates = await listTopics('', { includePrivate: true })
const validGameIds = new Set(games.map((game) => game.id)) const validGameIds = new Set(templates.map((template) => template.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 updateTopicDisplayOrder(filteredIds)
res.json({ games: updatedGames, templates: updatedGames }) res.json({ games: updatedGames, templates: updatedGames })
}) })
@@ -171,18 +172,18 @@ router.patch(['/games/:gameId/items/display-order', '/templates/:templateId/item
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const templateId = getTemplateIdParam(req) const templateId = getTemplateIdParam(req)
const game = await findGameById(templateId) const template = await findTopicById(templateId)
if (!game) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
const items = await updateGameItemDisplayOrder(game.id, parsed.data.itemIds) const items = await updateTopicItemDisplayOrder(template.id, parsed.data.itemIds)
res.json({ items }) res.json({ items })
}) })
router.post(['/games/:gameId/thumbnail', '/templates/:templateId/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 templateId = getTemplateIdParam(req) const templateId = getTemplateIdParam(req)
const game = await findGameById(templateId) const template = await findTopicById(templateId)
if (!game) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
const optimized = await writeOptimizedImage({ const optimized = await writeOptimizedImage({
file: req.file, file: req.file,
@@ -193,7 +194,7 @@ router.post(['/games/:gameId/thumbnail', '/templates/:templateId/thumbnail'], re
quality: 84, quality: 84,
}) })
const updated = await updateGameThumbnail(templateId, optimized.src) const updated = await updateTopicThumbnail(templateId, optimized.src)
res.json({ game: updated, template: updated }) res.json({ game: updated, template: updated })
}) })
@@ -201,8 +202,8 @@ router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireA
const files = Array.isArray(req.files) ? req.files : [] const files = Array.isArray(req.files) ? req.files : []
if (!files.length) return res.status(400).json({ error: 'file_required' }) if (!files.length) return res.status(400).json({ error: 'file_required' })
const templateId = getTemplateIdParam(req) const templateId = getTemplateIdParam(req)
const game = await findGameById(templateId) const template = await findTopicById(templateId)
if (!game) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
const labelsRaw = req.body?.labels const labelsRaw = req.body?.labels
const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : [] const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : []
@@ -220,9 +221,9 @@ router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireA
quality: 84, quality: 84,
}) })
return createGameItem({ return createTopicItem({
id: nanoid(), id: nanoid(),
gameId: game.id, topicId: template.id,
src: optimized.src, src: optimized.src,
label: normalizedLabels[index] || buildItemLabelFromFilename(file), label: normalizedLabels[index] || buildItemLabelFromFilename(file),
}) })
@@ -233,30 +234,39 @@ router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireA
}) })
router.delete(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => { router.delete(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
const game = await findGameById(getTemplateIdParam(req)) const template = await findTopicById(getTemplateIdParam(req))
if (!game) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
await deleteGameItem(req.params.itemId) await deleteTopicItem(req.params.itemId)
res.json({ ok: true }) res.json({ ok: true })
}) })
router.get(['/games/:gameId/items/:itemId/usage', '/templates/:templateId/items/:itemId/usage'], requireAdmin, async (req, res) => {
const template = await findTopicById(getTemplateIdParam(req))
if (!template) return res.status(404).json({ error: 'not_found' })
const item = await findTopicItemById(req.params.itemId)
if (!item || item.gameId !== template.id) return res.status(404).json({ error: 'not_found' })
const usage = await countTierListsUsingTopicItem(req.params.itemId)
res.json({ usage })
})
router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/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(getTemplateIdParam(req)) const template = await findTopicById(getTemplateIdParam(req))
if (!game) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
const updated = await updateGameItemLabel(req.params.itemId, parsed.data.label) const updated = await updateTopicItemLabel(req.params.itemId, parsed.data.label)
if (!updated || updated.gameId !== game.id) return res.status(404).json({ error: 'not_found' }) if (!updated || updated.gameId !== template.id) return res.status(404).json({ error: 'not_found' })
res.json({ item: updated }) res.json({ item: updated })
}) })
router.delete(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => { router.delete(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
const templateId = getTemplateIdParam(req) const templateId = getTemplateIdParam(req)
const game = await findGameById(templateId) const template = await findTopicById(templateId)
if (!game) return res.status(404).json({ error: 'not_found' }) if (!template) return res.status(404).json({ error: 'not_found' })
await deleteGame(templateId) await deleteTopic(templateId)
res.json({ ok: true }) res.json({ ok: true })
}) })
@@ -276,7 +286,7 @@ router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
} }
if (parsed.data.sourceType === 'template') { if (parsed.data.sourceType === 'template') {
const updated = await updateGameItemLabel(itemId, parsed.data.label) const updated = await updateTopicItemLabel(itemId, parsed.data.label)
if (!updated) return res.status(404).json({ error: 'not_found' }) if (!updated) return res.status(404).json({ error: 'not_found' })
return res.json({ item: updated }) return res.json({ item: updated })
} }
@@ -454,10 +464,10 @@ async function removeCustomItemFiles(items) {
) )
} }
async function promoteLibraryItemToGameItem({ item, gameId }) { async function promoteLibraryItemToTemplateItem({ item, templateId }) {
return createGameItem({ return createTopicItem({
id: nanoid(), id: nanoid(),
gameId, topicId: templateId,
src: item.src || '', src: item.src || '',
label: item.label, label: item.label,
}) })
@@ -494,7 +504,7 @@ function uniqueTierListPoolItems(tierList) {
}) })
} }
async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) { async function promoteTierListItemsToTemplate({ tierList, templateId, itemIds = [] }) {
const allowedIds = new Set((itemIds || []).filter(Boolean)) const allowedIds = new Set((itemIds || []).filter(Boolean))
const sourceItems = uniqueTierListPoolItems(tierList).filter((item) => item.origin === 'custom') const sourceItems = uniqueTierListPoolItems(tierList).filter((item) => item.origin === 'custom')
const itemsToCopy = allowedIds.size ? sourceItems.filter((item) => allowedIds.has(item.id)) : sourceItems const itemsToCopy = allowedIds.size ? sourceItems.filter((item) => allowedIds.has(item.id)) : sourceItems
@@ -503,9 +513,9 @@ async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
for (const item of itemsToCopy) { for (const item of itemsToCopy) {
const copiedSrc = await copyUploadIntoGameAsset(item.src) const copiedSrc = await copyUploadIntoGameAsset(item.src)
createdItems.push( createdItems.push(
await createGameItem({ await createTopicItem({
id: nanoid(), id: nanoid(),
gameId, topicId: templateId,
src: copiedSrc, src: copiedSrc,
label: item.label, label: item.label,
}) })
@@ -515,8 +525,8 @@ async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
return createdItems return createdItems
} }
async function promoteSnapshotItemsToGame({ items, gameId }) { async function promoteSnapshotItemsToTemplate({ items, templateId }) {
const existingItems = await listGameItems(gameId) const existingItems = await listTopicItems(templateId)
const existingSrcs = new Set( const existingSrcs = new Set(
existingItems existingItems
.map((item) => (typeof item?.src === 'string' ? item.src.trim() : '')) .map((item) => (typeof item?.src === 'string' ? item.src.trim() : ''))
@@ -528,9 +538,9 @@ async function promoteSnapshotItemsToGame({ items, gameId }) {
const copiedSrc = await copyUploadIntoGameAsset(item.src) const copiedSrc = await copyUploadIntoGameAsset(item.src)
if (!copiedSrc || existingSrcs.has(copiedSrc)) continue if (!copiedSrc || existingSrcs.has(copiedSrc)) continue
createdItems.push( createdItems.push(
await createGameItem({ await createTopicItem({
id: nanoid(), id: nanoid(),
gameId, topicId: templateId,
src: copiedSrc, src: copiedSrc,
label: item.label, label: item.label,
}) })
@@ -567,43 +577,43 @@ function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}
}) })
} }
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) { async function createTemplateFromTierList({ tierList, templateId, templateName }) {
await createGame({ id: gameId, name: gameName, isPublic: false }) await createTopic({ id: templateId, name: templateName, isPublic: false })
if (tierList.thumbnailSrc) { if (tierList.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc) const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc)
await updateGameThumbnail(gameId, copiedThumb) await updateTopicThumbnail(templateId, copiedThumb)
} }
const createdItems = [] const createdItems = []
for (const item of uniqueTierListPoolItems(tierList)) { for (const item of uniqueTierListPoolItems(tierList)) {
const copiedSrc = await copyUploadIntoGameAsset(item.src) const copiedSrc = await copyUploadIntoGameAsset(item.src)
createdItems.push( createdItems.push(
await createGameItem({ await createTopicItem({
id: nanoid(), id: nanoid(),
gameId, topicId: templateId,
src: copiedSrc, src: copiedSrc,
label: item.label, label: item.label,
}) })
) )
} }
return { game: await findGameById(gameId), items: createdItems } return { game: await findTopicById(templateId), items: createdItems }
} }
async function createGameTemplateFromRequest({ templateRequest, gameId, gameName }) { async function createTemplateFromRequest({ templateRequest, templateId, templateName }) {
await createGame({ id: gameId, name: gameName, isPublic: false }) await createTopic({ id: templateId, name: templateName, isPublic: false })
if (templateRequest.thumbnailSrc) { if (templateRequest.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc) const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc)
await updateGameThumbnail(gameId, copiedThumb) await updateTopicThumbnail(templateId, copiedThumb)
} }
const items = await promoteSnapshotItemsToGame({ const items = await promoteSnapshotItemsToTemplate({
items: templateRequest.items || [], items: templateRequest.items || [],
gameId, templateId,
}) })
return { game: await findGameById(gameId), items } return { game: await findTopicById(templateId), items }
} }
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => { router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
@@ -620,7 +630,7 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
return res.json({ ok: true, sourceType: 'template-asset' }) return res.json({ ok: true, sourceType: 'template-asset' })
} }
await deleteGameItem(target.id) await deleteTopicItem(target.id)
return res.json({ ok: true, sourceType: 'template' }) return res.json({ ok: true, sourceType: 'template' })
} }
@@ -641,16 +651,16 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(parsed.data.gameId) const template = await findTopicById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' }) if (!template) return res.status(404).json({ error: 'game_not_found' })
const customItem = await findCustomItemById(req.params.itemId) const customItem = await findCustomItemById(req.params.itemId)
const gameItem = customItem ? null : await findGameItemById(req.params.itemId) const templateItem = customItem ? null : await findTopicItemById(req.params.itemId)
const assetItemId = String(req.params.itemId || '') const assetItemId = String(req.params.itemId || '')
const imageAsset = !customItem && !gameItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null const imageAsset = !customItem && !templateItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null
const sourceItem = const sourceItem =
customItem || customItem ||
gameItem || templateItem ||
(imageAsset (imageAsset
? { ? {
src: imageAsset.src || '', src: imageAsset.src || '',
@@ -659,7 +669,7 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
: null) : null)
if (!sourceItem) return res.status(404).json({ error: 'not_found' }) if (!sourceItem) return res.status(404).json({ error: 'not_found' })
const item = await promoteLibraryItemToGameItem({ item: sourceItem, gameId: game.id }) const item = await promoteLibraryItemToTemplateItem({ item: sourceItem, templateId: template.id })
res.json({ item }) res.json({ item })
}) })
@@ -671,15 +681,15 @@ router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, re
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(parsed.data.gameId) const template = await findTopicById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' }) if (!template) return res.status(404).json({ error: 'game_not_found' })
const tierList = await findTierListById(req.params.tierListId) const tierList = await findTierListById(req.params.tierListId)
if (!tierList) return res.status(404).json({ error: 'not_found' }) if (!tierList) return res.status(404).json({ error: 'not_found' })
const items = await promoteTierListItemsToGame({ const items = await promoteTierListItemsToTemplate({
tierList, tierList,
gameId: game.id, templateId: template.id,
itemIds: parsed.data.itemIds, itemIds: parsed.data.itemIds,
}) })
res.json({ items }) res.json({ items })
@@ -694,19 +704,19 @@ router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findGameById(parsed.data.gameId) const exists = await findTopicById(parsed.data.gameId)
if (exists) return res.status(409).json({ error: 'game_id_taken' }) if (exists) return res.status(409).json({ error: 'game_id_taken' })
const tierList = await findTierListById(req.params.tierListId) const tierList = await findTierListById(req.params.tierListId)
if (!tierList) return res.status(404).json({ error: 'not_found' }) if (!tierList) return res.status(404).json({ error: 'not_found' })
const result = await createGameTemplateFromTierList({ const result = await createTemplateFromTierList({
tierList: { tierList: {
...tierList, ...tierList,
pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool, pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool,
}, },
gameId: parsed.data.gameId, templateId: parsed.data.gameId,
gameName: parsed.data.name, templateName: parsed.data.name,
}) })
res.json(result) res.json(result)
}) })
@@ -746,12 +756,12 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r
if (templateRequest.type === 'update') { if (templateRequest.type === 'update') {
const targetGameId = templateRequest.targetGameId || templateRequest.sourceGameId const targetGameId = templateRequest.targetGameId || templateRequest.sourceGameId
const game = await findGameById(targetGameId) const template = await findTopicById(targetGameId)
if (!game) return res.status(404).json({ error: 'game_not_found' }) if (!template) return res.status(404).json({ error: 'game_not_found' })
const items = await promoteSnapshotItemsToGame({ const items = await promoteSnapshotItemsToTemplate({
items: templateRequest.items || [], items: templateRequest.items || [],
gameId: game.id, templateId: template.id,
}) })
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' }) const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
return res.json({ request, items }) return res.json({ request, items })
@@ -764,13 +774,13 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findGameById(parsed.data.gameId) const exists = await findTopicById(parsed.data.gameId)
if (exists) return res.status(409).json({ error: 'game_id_taken' }) if (exists) return res.status(409).json({ error: 'game_id_taken' })
const result = await createGameTemplateFromRequest({ const result = await createTemplateFromRequest({
templateRequest, templateRequest,
gameId: parsed.data.gameId, templateId: parsed.data.gameId,
gameName: parsed.data.name, templateName: parsed.data.name,
}) })
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' }) const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
res.json({ request, ...result }) res.json({ request, ...result })
@@ -812,12 +822,12 @@ router.post('/template-requests/:requestId/link-game', requireAdmin, async (req,
return res.status(409).json({ error: 'request_already_handled' }) return res.status(409).json({ error: 'request_already_handled' })
} }
const game = await findGameById(parsed.data.gameId) const template = await findTopicById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' }) if (!template) return res.status(404).json({ error: 'game_not_found' })
const request = await updateTemplateRequestTargetGame({ const request = await updateTemplateRequestTargetGame({
id: templateRequest.id, id: templateRequest.id,
targetGameId: game.id, targetGameId: template.id,
}) })
res.json({ request }) res.json({ request })
}) })
@@ -838,8 +848,8 @@ router.post('/template-requests/:requestId/promote-items', requireAdmin, async (
return res.status(409).json({ error: 'request_already_handled' }) return res.status(409).json({ error: 'request_already_handled' })
} }
const game = await findGameById(parsed.data.gameId) const template = await findTopicById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' }) if (!template) return res.status(404).json({ error: 'game_not_found' })
const promotableItems = pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels, parsed.data.itemSrcs) const promotableItems = pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels, parsed.data.itemSrcs)
if (!promotableItems.length) { if (!promotableItems.length) {
@@ -848,14 +858,14 @@ router.post('/template-requests/:requestId/promote-items', requireAdmin, async (
let items = [] let items = []
try { try {
items = await promoteSnapshotItemsToGame({ items = await promoteSnapshotItemsToTemplate({
items: promotableItems, items: promotableItems,
gameId: game.id, templateId: template.id,
}) })
} catch (error) { } catch (error) {
console.error('[admin] template request promote-items failed', { console.error('[admin] template request promote-items failed', {
requestId: templateRequest.id, requestId: templateRequest.id,
gameId: game.id, gameId: template.id,
itemCount: promotableItems.length, itemCount: promotableItems.length,
message: error?.message || 'unknown_error', message: error?.message || 'unknown_error',
code: error?.code || '', code: error?.code || '',

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,34 @@
# 업데이트 로그 # 업데이트 로그
## 2026-04-02 v1.4.20
- 백엔드 `db`와 라우트 내부 이름층을 한 단계 더 `topic` 기준으로 옮겼다. `listTopics / findTopicById / getTopicDetail / createTopic / updateTopicThumbnail / updateTopicVisibility`, `createTopicItem / updateTopicItemLabel / updateTopicItemDisplayOrder / deleteTopicItem / deleteTopic` 같은 이름을 실제 export로 추가하고, 기존 `game` 이름은 호환 alias로만 남겼다.
- 공개 주제 라우트는 이제 `listTopics`, `getTopicDetail`, `favoriteTopic` 기준으로 동작하고, 백엔드 진입점도 `gamesRoutes` 대신 `topicsRoutes`라는 이름으로 읽히도록 정리했다.
- 관리자 라우트 역시 핵심 템플릿 흐름에서 `findTopicById`, `createTopic`, `createTopicItem`, `promoteSnapshotItemsToTemplate`, `createTemplateFromTierList` 같은 의미 이름을 직접 사용하도록 바꿔, 실제 저장 스키마와 코드 언어가 더 가까워지게 맞췄다.
## 2026-04-02 v1.4.19
- 관리자 템플릿 기본 아이템 삭제는 이제 기존에 저장된 티어표의 그룹/대기풀 데이터를 건드리지 않고, 템플릿의 현재 기본 아이템 목록에서만 제거되도록 바꿨다. 그래서 이미 만들어진 티어표는 그대로 유지되고, 이후 새로 만드는 티어표에서만 해당 아이템이 빠진다.
- 삭제 전에는 이 아이템이 이미 저장된 티어표 몇 개에서 사용 중인지(공개/비공개 포함) 확인 문구를 먼저 보여주도록 바꿔, 운영자가 영향 범위를 알고 삭제할 수 있게 했다.
- 템플릿 썸네일이 이미 등록된 상태에서는 등록 아이콘이 겹쳐 보이지 않도록 정리했고, 기본 아이템 삭제 후 템플릿을 다시 불러와도 페이지가 맨 위로 튀지 않게 스크롤 위치를 복원하도록 보강했다.
## 2026-04-02 v1.4.18
- 관리자 템플릿 요청 카드 썸네일 클릭은 `window.open(...)`을 템플릿 이벤트 안에서 직접 호출하던 구조 때문에 브라우저 새 창 열기 시 `Cannot read properties of undefined (reading 'open')`가 날 수 있었고, 이를 제거해 앵커의 기본 새 창 동작만 사용하도록 정리했다.
## 2026-04-02 v1.4.17
- 주제 컬렉션에서 티어표 카드를 클릭할 때 `Maximum call stack size exceeded`가 나던 원인은 `editor` 레거시 redirect가 새 라우트와 동일한 URL 패턴을 다시 자기 자신에게 redirect하던 구조였고, 불필요한 `editor` redirect 레코드를 제거해 무한 라우팅 루프를 끊었다.
## 2026-04-02 v1.4.16
- 백엔드나 DB 장애가 났을 때 일반 화면에서 계속 `연결할 수 없어요` 식으로 보이던 흐름을 정리하고, `api` 공통 요청 계층에서 `db_init_failed` 같은 500과 네트워크 실패를 감지해 앱 전체를 점검/연결 확인 화면으로 전환하도록 바꿨다.
- 이제 데이터베이스 초기화 실패나 서버 내부 500은 `서비스 점검 중`, 네트워크 단절은 `서버 연결 확인 중`으로 구분되어 보이며, 사용자는 일반 페이지 대신 전용 안내 화면과 다시 시도 버튼을 보게 된다.
## 2026-04-02 v1.4.15
- `db_init_failed`의 직접 원인은 MariaDB에서 `SHOW TABLES LIKE ?`, `SHOW COLUMNS ... LIKE ?` 플레이스홀더를 허용하지 않던 부분이었고, 이를 `information_schema` 조회 기반으로 바꿔 실제 운영 DB에서도 `ensureData()`가 정상 통과되게 고쳤다.
- 중간 마이그레이션 상태에서 `template_requests.target_topic_id`가 이미 생긴 DB는 중복 컬럼 추가로 다시 실패할 수 있었으므로, 해당 확인도 `columnExists()` 기준으로 바꿔 부분 적용된 DB까지 안전하게 다시 기동되게 정리했다.
## 2026-04-02 v1.4.14
- `/games/:gameId`, `/editor/:gameId/...` 레거시 주소는 Vue Router alias 대신 redirect로 정리해, `topicId` 기준 라우트와 섞일 때 뜨던 param mismatch 경고를 제거했다.
- 운영 DB에서 바로 `RENAME/CHANGE`를 치던 초기 마이그레이션은 위험도가 높아, `topics / topic_items / favorite_topics / topic_id` 스키마를 안전하게 만들고 기존 `games` 계열 데이터를 복사해 오는 방식으로 바꿨다.
- DB 초기화 실패 시 원인을 바로 확인할 수 있도록 백엔드에서 `db_init_failed` 응답 전에 실제 에러를 서버 로그에 남기도록 보강했다.
## 2026-04-02 v1.4.13 ## 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`로 옮기게 했다. - 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`도 잠시 함께 유지했다. - 백엔드 저장/조회 쿼리는 이제 새 topic 스키마를 기준으로 동작하고, 응답에는 `topicId / topicName`을 기본으로 내려주되 기존 프런트가 바로 깨지지 않도록 `gameId / gameName`도 잠시 함께 유지했다.

View File

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

View File

@@ -125,7 +125,7 @@ function setThumbFileElement(el) {
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.game.name" /> <img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.game.name" />
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div> <div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
<div class="thumbDropZone__copy"> <div class="thumbDropZone__copy">
<div class="thumbDropZone__iconWrap"> <div v-if="!props.displayThumbnailUrl" class="thumbDropZone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" /> <SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" />
</div> </div>
<div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div> <div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>

View File

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

View File

@@ -1,25 +1,54 @@
import { toApiUrl } from './runtime' import { toApiUrl } from './runtime'
function emitBackendStatus(detail) {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent('tier-maker:backend-status', { detail }))
}
async function request(path, { method = 'GET', body, headers } = {}) { async function request(path, { method = 'GET', body, headers } = {}) {
const res = await fetch(toApiUrl(path), { let res
method, try {
credentials: 'include', res = await fetch(toApiUrl(path), {
headers: { method,
...(body ? { 'Content-Type': 'application/json' } : {}), credentials: 'include',
...(headers || {}), headers: {
}, ...(body ? { 'Content-Type': 'application/json' } : {}),
body: body ? JSON.stringify(body) : undefined, ...(headers || {}),
}) },
body: body ? JSON.stringify(body) : undefined,
})
} catch (error) {
emitBackendStatus({
state: 'offline',
message: '서버 연결을 확인할 수 없어 잠시 후 다시 시도해주세요.',
path,
})
throw error
}
const contentType = res.headers.get('content-type') || '' const contentType = res.headers.get('content-type') || ''
const data = contentType.includes('application/json') ? await res.json() : await res.text() const data = contentType.includes('application/json') ? await res.json() : await res.text()
if (!res.ok) { if (!res.ok) {
if (res.status >= 500 && data?.error === 'db_init_failed') {
emitBackendStatus({
state: 'maintenance',
message: '서비스 점검 중이거나 데이터베이스 초기화 중입니다. 잠시 후 다시 이용해주세요.',
path,
})
} else if (res.status >= 500) {
emitBackendStatus({
state: 'maintenance',
message: '서비스 내부 점검이 필요합니다. 잠시 후 다시 이용해주세요.',
path,
})
}
const err = new Error('request_failed') const err = new Error('request_failed')
err.status = res.status err.status = res.status
err.data = data err.data = data
throw err throw err
} }
emitBackendStatus({ state: 'online', path })
return data return data
} }

View File

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

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { Teleport, computed, inject, onMounted, onUnmounted, ref, watch } from 'vue' import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api' import { api } from '../lib/api'
import { editorPath } from '../lib/paths' import { editorPath } from '../lib/paths'
@@ -1215,7 +1215,25 @@ async function toggleSelectedTemplateVisibility(nextValue) {
async function removeTemplateItem(itemId) { async function removeTemplateItem(itemId) {
resetMessages() resetMessages()
if (!selectedTemplateId.value) return
try { try {
const usageRes = await fetch(
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}/usage`),
{
credentials: 'include',
}
)
if (!usageRes.ok) throw new Error('usage_failed')
const usageData = await usageRes.json()
const usage = usageData?.usage || { totalCount: 0, publicCount: 0, privateCount: 0 }
const impactMessage = usage.totalCount
? `이 아이템은 이미 저장된 티어표 ${usage.totalCount}개(공개 ${usage.publicCount}개, 비공개 ${usage.privateCount}개)에서 사용 중이에요.\n기존 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외됩니다.\n정말 삭제할까요?`
: '이 기본 아이템을 삭제할까요?\n기존 저장 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외됩니다.'
const ok = window.confirm(impactMessage)
if (!ok) return
const previousScrollY = window.scrollY
const res = await fetch( const res = await fetch(
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}`), toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}`),
{ {
@@ -1226,6 +1244,8 @@ async function removeTemplateItem(itemId) {
if (!res.ok) throw new Error('failed') if (!res.ok) throw new Error('failed')
await loadTemplate() await loadTemplate()
await nextTick()
window.scrollTo({ top: previousScrollY, behavior: 'auto' })
success.value = '템플릿 기본 아이템을 삭제했어요.' success.value = '템플릿 기본 아이템을 삭제했어요.'
} catch (e) { } catch (e) {
error.value = '템플릿 기본 아이템 삭제에 실패했어요.' error.value = '템플릿 기본 아이템 삭제에 실패했어요.'