Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60cc5a72c5 | |||
| 8898ac24f9 | |||
| 9f6cb33bbd | |||
| b3575d59a6 | |||
| d0ebc97bc3 | |||
| 139f78bb89 |
@@ -80,7 +80,6 @@ app.use(async (req, res, next) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes)
|
app.use('/api/auth', authRoutes)
|
||||||
app.use('/api/games', topicsRoutes)
|
|
||||||
app.use('/api/topics', topicsRoutes)
|
app.use('/api/topics', topicsRoutes)
|
||||||
app.use('/api/tierlists', tierListsRoutes)
|
app.use('/api/tierlists', tierListsRoutes)
|
||||||
app.use('/api/admin', adminRoutes)
|
app.use('/api/admin', adminRoutes)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const DB_PASSWORD = process.env.DB_PASSWORD || ''
|
|||||||
const DB_NAME = process.env.DB_NAME || 'tier_cursor'
|
const DB_NAME = process.env.DB_NAME || 'tier_cursor'
|
||||||
const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10
|
const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10
|
||||||
const FREEFORM_TOPIC_ID = 'freeform'
|
const FREEFORM_TOPIC_ID = 'freeform'
|
||||||
const FREEFORM_GAME_ID = FREEFORM_TOPIC_ID
|
|
||||||
|
|
||||||
let poolPromise = null
|
let poolPromise = null
|
||||||
let initPromise = null
|
let initPromise = null
|
||||||
@@ -270,10 +269,6 @@ async function closePool() {
|
|||||||
async function ensureSchema() {
|
async function ensureSchema() {
|
||||||
if (initPromise) return initPromise
|
if (initPromise) return initPromise
|
||||||
initPromise = (async () => {
|
initPromise = (async () => {
|
||||||
const legacyGamesExists = await tableExists('games')
|
|
||||||
const legacyGameItemsExists = await tableExists('game_items')
|
|
||||||
const legacyFavoriteGamesExists = await tableExists('favorite_games')
|
|
||||||
|
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id VARCHAR(64) PRIMARY KEY,
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
@@ -308,14 +303,6 @@ async function ensureSchema() {
|
|||||||
await query('ALTER TABLE topics ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
|
await query('ALTER TABLE topics ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (legacyGamesExists) {
|
|
||||||
await query(`
|
|
||||||
INSERT IGNORE INTO topics (id, name, thumbnail_src, is_public, display_rank, created_at)
|
|
||||||
SELECT id, name, thumbnail_src, COALESCE(is_public, 1), display_rank, created_at
|
|
||||||
FROM games
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS topic_items (
|
CREATE TABLE IF NOT EXISTS topic_items (
|
||||||
id VARCHAR(64) PRIMARY KEY,
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
@@ -334,17 +321,6 @@ 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,
|
||||||
@@ -407,14 +383,6 @@ async function ensureSchema() {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
`)
|
`)
|
||||||
|
|
||||||
if (legacyFavoriteGamesExists) {
|
|
||||||
await query(`
|
|
||||||
INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at)
|
|
||||||
SELECT user_id, game_id, created_at
|
|
||||||
FROM favorite_games
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS image_assets (
|
CREATE TABLE IF NOT EXISTS image_assets (
|
||||||
id VARCHAR(64) PRIMARY KEY,
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
@@ -487,16 +455,10 @@ async function ensureSchema() {
|
|||||||
const hasSourceTopicId = await columnExists('template_requests', 'source_topic_id')
|
const hasSourceTopicId = await columnExists('template_requests', 'source_topic_id')
|
||||||
if (!hasSourceTopicId) {
|
if (!hasSourceTopicId) {
|
||||||
await query("ALTER TABLE template_requests ADD COLUMN source_topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
|
await query("ALTER TABLE template_requests ADD COLUMN source_topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
|
||||||
if (await columnExists('template_requests', 'source_game_id')) {
|
|
||||||
await query('UPDATE template_requests SET source_topic_id = source_game_id WHERE source_topic_id = ?', [FREEFORM_TOPIC_ID])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const hasTargetTopicId = await columnExists('template_requests', 'target_topic_id')
|
const hasTargetTopicId = await columnExists('template_requests', 'target_topic_id')
|
||||||
if (!hasTargetTopicId) {
|
if (!hasTargetTopicId) {
|
||||||
await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_topic_id")
|
await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_topic_id")
|
||||||
if (await columnExists('template_requests', 'target_game_id')) {
|
|
||||||
await query("UPDATE template_requests SET target_topic_id = target_game_id WHERE target_topic_id = ''")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
|
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
|
||||||
if (!templateRequestStatusColumns.length) {
|
if (!templateRequestStatusColumns.length) {
|
||||||
@@ -522,9 +484,6 @@ async function ensureSchema() {
|
|||||||
const tierListTopicIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'topic_id'")
|
const tierListTopicIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'topic_id'")
|
||||||
if (!tierListTopicIdColumns.length) {
|
if (!tierListTopicIdColumns.length) {
|
||||||
await query("ALTER TABLE tierlists ADD COLUMN topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER author_id")
|
await query("ALTER TABLE tierlists ADD COLUMN topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER author_id")
|
||||||
if (await columnExists('tierlists', 'game_id')) {
|
|
||||||
await query('UPDATE tierlists SET topic_id = game_id WHERE topic_id = ?', [FREEFORM_TOPIC_ID])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'")
|
const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'")
|
||||||
if (!tierListShowNamesColumns.length) {
|
if (!tierListShowNamesColumns.length) {
|
||||||
@@ -568,7 +527,7 @@ async function ensureSchema() {
|
|||||||
(?, ?, ?, ?),
|
(?, ?, ?, ?),
|
||||||
(?, ?, ?, ?)
|
(?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
['example-game', '예시 게임', '', createdAt, 'another-game', '다른 예시 게임', '', createdAt]
|
['example-topic', '예시 주제', '', createdAt, 'another-topic', '다른 예시 주제', '', createdAt]
|
||||||
)
|
)
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
@@ -580,12 +539,12 @@ async function ensureSchema() {
|
|||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
'img-1',
|
'img-1',
|
||||||
'example-game',
|
'example-topic',
|
||||||
'/uploads/seeds/example1.png',
|
'/uploads/seeds/example1.png',
|
||||||
'샘플 1',
|
'샘플 1',
|
||||||
createdAt,
|
createdAt,
|
||||||
'img-2',
|
'img-2',
|
||||||
'example-game',
|
'example-topic',
|
||||||
'/uploads/seeds/example2.png',
|
'/uploads/seeds/example2.png',
|
||||||
'샘플 2',
|
'샘플 2',
|
||||||
createdAt,
|
createdAt,
|
||||||
@@ -788,7 +747,7 @@ async function getTopicDetail(topicId) {
|
|||||||
const topic = await findTopicById(topicId)
|
const topic = await findTopicById(topicId)
|
||||||
if (!topic) return null
|
if (!topic) return null
|
||||||
const items = await listTopicItems(topicId)
|
const items = await listTopicItems(topicId)
|
||||||
return { topic, game: topic, items }
|
return { topic, template: topic, items }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTopic({ id, name, isPublic = true }) {
|
async function createTopic({ id, name, isPublic = true }) {
|
||||||
@@ -972,8 +931,8 @@ async function listReferencedUploadUsage() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
for (const row of userRows) addUsage(row.avatar_src, 'avatar')
|
for (const row of userRows) addUsage(row.avatar_src, 'avatar')
|
||||||
for (const row of gameRows) addUsage(row.thumbnail_src, 'game-thumbnail')
|
for (const row of gameRows) addUsage(row.thumbnail_src, 'topic-thumbnail')
|
||||||
for (const row of gameItemRows) addUsage(row.src, 'game-item')
|
for (const row of gameItemRows) addUsage(row.src, 'topic-item')
|
||||||
for (const row of customItemRows) addUsage(row.src, 'custom-item')
|
for (const row of customItemRows) addUsage(row.src, 'custom-item')
|
||||||
|
|
||||||
for (const row of tierListRows) {
|
for (const row of tierListRows) {
|
||||||
@@ -1193,7 +1152,7 @@ async function cleanupMissingUploadReferences() {
|
|||||||
|
|
||||||
for (const row of gameItemRows) {
|
for (const row of gameItemRows) {
|
||||||
if (await fileExistsForUploadSrc(row.src)) continue
|
if (await fileExistsForUploadSrc(row.src)) continue
|
||||||
await deleteGameItem(row.id)
|
await deleteTopicItem(row.id)
|
||||||
stats.deletedGameItems += 1
|
stats.deletedGameItems += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1468,22 +1427,6 @@ async function createCustomItem({ id, ownerId, src, label }) {
|
|||||||
return { id, ownerId, src, label, origin: 'custom', createdAt }
|
return { id, ownerId, src, label, origin: 'custom', createdAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
const listGames = listTopics
|
|
||||||
const findGameById = findTopicById
|
|
||||||
const listGameItems = listTopicItems
|
|
||||||
const findGameItemById = findTopicItemById
|
|
||||||
const getGameDetail = getTopicDetail
|
|
||||||
const createGame = createTopic
|
|
||||||
const updateGameThumbnail = updateTopicThumbnail
|
|
||||||
const updateGameVisibility = updateTopicVisibility
|
|
||||||
const createGameItem = createTopicItem
|
|
||||||
const updateGameItemLabel = updateTopicItemLabel
|
|
||||||
const updateGameItemDisplayOrder = updateTopicItemDisplayOrder
|
|
||||||
const countTierListsUsingGameItem = countTierListsUsingTopicItem
|
|
||||||
const deleteGameItem = deleteTopicItem
|
|
||||||
const deleteGame = deleteTopic
|
|
||||||
const updateGameDisplayOrder = updateTopicDisplayOrder
|
|
||||||
|
|
||||||
async function syncOwnedCustomItemLabels({ ownerId, items }) {
|
async function syncOwnedCustomItemLabels({ ownerId, items }) {
|
||||||
const customItems = Array.from(
|
const customItems = Array.from(
|
||||||
new Map(
|
new Map(
|
||||||
@@ -1533,7 +1476,7 @@ async function getCustomItemUsageMeta() {
|
|||||||
`
|
`
|
||||||
)
|
)
|
||||||
const usageMap = new Map()
|
const usageMap = new Map()
|
||||||
const linkedGamesMap = new Map()
|
const linkedTemplatesMap = new Map()
|
||||||
|
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
const groups = parseJson(row.groups_json, [])
|
const groups = parseJson(row.groups_json, [])
|
||||||
@@ -1557,8 +1500,8 @@ async function getCustomItemUsageMeta() {
|
|||||||
if (!row.topic_id) return
|
if (!row.topic_id) return
|
||||||
|
|
||||||
seenItemIds.forEach((itemId) => {
|
seenItemIds.forEach((itemId) => {
|
||||||
if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map())
|
if (!linkedTemplatesMap.has(itemId)) linkedTemplatesMap.set(itemId, new Map())
|
||||||
linkedGamesMap.get(itemId).set(row.topic_id, {
|
linkedTemplatesMap.get(itemId).set(row.topic_id, {
|
||||||
id: row.topic_id,
|
id: row.topic_id,
|
||||||
name: row.topic_name || row.topic_id,
|
name: row.topic_name || row.topic_id,
|
||||||
})
|
})
|
||||||
@@ -1567,7 +1510,7 @@ async function getCustomItemUsageMeta() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
usageMap,
|
usageMap,
|
||||||
linkedGamesMap: new Map(Array.from(linkedGamesMap.entries()).map(([itemId, gameMap]) => [itemId, Array.from(gameMap.values())])),
|
linkedTemplatesMap: new Map(Array.from(linkedTemplatesMap.entries()).map(([itemId, templateMap]) => [itemId, Array.from(templateMap.values())])),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1636,7 +1579,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
})
|
})
|
||||||
|
|
||||||
const customItems = customRows.map((row) => {
|
const customItems = customRows.map((row) => {
|
||||||
const linkedGames = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values())
|
const linkedTemplates = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values())
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
ownerId: row.owner_id,
|
ownerId: row.owner_id,
|
||||||
@@ -1646,7 +1589,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
ownerName: row.nickname || row.email,
|
ownerName: row.nickname || row.email,
|
||||||
ownerEmail: row.email,
|
ownerEmail: row.email,
|
||||||
usageCount: usageMeta.usageMap.get(row.id) || 0,
|
usageCount: usageMeta.usageMap.get(row.id) || 0,
|
||||||
linkedGames,
|
linkedTemplates,
|
||||||
sourceType: 'user',
|
sourceType: 'user',
|
||||||
sourceLabel: '사용자 업로드',
|
sourceLabel: '사용자 업로드',
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
@@ -1667,7 +1610,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
ownerName: '관리자 보관 자산',
|
ownerName: '관리자 보관 자산',
|
||||||
ownerEmail: '',
|
ownerEmail: '',
|
||||||
usageCount: 0,
|
usageCount: 0,
|
||||||
linkedGames: [],
|
linkedTemplates: [],
|
||||||
sourceType: 'template',
|
sourceType: 'template',
|
||||||
sourceLabel: '관리자 템플릿',
|
sourceLabel: '관리자 템플릿',
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
@@ -1685,7 +1628,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
ownerName: row.topic_name || row.topic_id,
|
ownerName: row.topic_name || row.topic_id,
|
||||||
ownerEmail: '',
|
ownerEmail: '',
|
||||||
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
|
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
|
||||||
linkedGames: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
|
linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
|
||||||
sourceType: 'template',
|
sourceType: 'template',
|
||||||
sourceLabel: '관리자 템플릿',
|
sourceLabel: '관리자 템플릿',
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
@@ -1704,7 +1647,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
const allItems = baseItems
|
const allItems = baseItems
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const siblings = groupedBySrc.get(item.src) || [item]
|
const siblings = groupedBySrc.get(item.src) || [item]
|
||||||
const linkedGames = new Map()
|
const linkedTemplates = new Map()
|
||||||
let userReferenceCount = 0
|
let userReferenceCount = 0
|
||||||
let templateReferenceCount = 0
|
let templateReferenceCount = 0
|
||||||
let assetReferenceCount = 0
|
let assetReferenceCount = 0
|
||||||
@@ -1713,8 +1656,8 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
if (entry.sourceType === 'user') userReferenceCount += 1
|
if (entry.sourceType === 'user') userReferenceCount += 1
|
||||||
else if (entry.isAssetLibraryItem) assetReferenceCount += 1
|
else if (entry.isAssetLibraryItem) assetReferenceCount += 1
|
||||||
else templateReferenceCount += 1
|
else templateReferenceCount += 1
|
||||||
;(entry.linkedGames || []).forEach((game) => {
|
;(entry.linkedTemplates || []).forEach((template) => {
|
||||||
if (game?.id) linkedGames.set(game.id, game)
|
if (template?.id) linkedTemplates.set(template.id, template)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1724,7 +1667,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
sharedUserReferenceCount: userReferenceCount,
|
sharedUserReferenceCount: userReferenceCount,
|
||||||
sharedTemplateReferenceCount: templateReferenceCount,
|
sharedTemplateReferenceCount: templateReferenceCount,
|
||||||
sharedAssetReferenceCount: assetReferenceCount,
|
sharedAssetReferenceCount: assetReferenceCount,
|
||||||
sharedLinkedGameCount: linkedGames.size,
|
sharedLinkedTemplateCount: linkedTemplates.size,
|
||||||
sharedEntries: siblings
|
sharedEntries: siblings
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
|
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
|
||||||
@@ -1738,7 +1681,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
sourceTopicId: entry.sourceTopicId || '',
|
sourceTopicId: entry.sourceTopicId || '',
|
||||||
sourceTopicName: entry.sourceTopicName || '',
|
sourceTopicName: entry.sourceTopicName || '',
|
||||||
usageCount: entry.usageCount || 0,
|
usageCount: entry.usageCount || 0,
|
||||||
linkedGames: entry.linkedGames || [],
|
linkedTemplates: entry.linkedTemplates || [],
|
||||||
isAssetLibraryItem: !!entry.isAssetLibraryItem,
|
isAssetLibraryItem: !!entry.isAssetLibraryItem,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
@@ -1752,7 +1695,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
case 'asset':
|
case 'asset':
|
||||||
return !!item.isAssetLibraryItem
|
return !!item.isAssetLibraryItem
|
||||||
case 'unused-user':
|
case 'unused-user':
|
||||||
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0
|
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedTemplates.length === 0
|
||||||
case 'unused-admin':
|
case 'unused-admin':
|
||||||
return !!item.isAssetLibraryItem
|
return !!item.isAssetLibraryItem
|
||||||
default:
|
default:
|
||||||
@@ -2027,7 +1970,7 @@ function uniqueTierListItems(poolItems) {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
src: item.src || '',
|
src: item.src || '',
|
||||||
label: item.label || 'item',
|
label: item.label || 'item',
|
||||||
origin: item.origin || 'game',
|
origin: item.origin || 'template',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return Array.from(map.values())
|
return Array.from(map.values())
|
||||||
@@ -2522,9 +2465,6 @@ async function unfavoriteTopic({ userId, topicId }) {
|
|||||||
await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, topicId])
|
await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, topicId])
|
||||||
}
|
}
|
||||||
|
|
||||||
const favoriteGame = favoriteTopic
|
|
||||||
const unfavoriteGame = unfavoriteTopic
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
DB_NAME,
|
DB_NAME,
|
||||||
ensureData,
|
ensureData,
|
||||||
@@ -2547,14 +2487,6 @@ module.exports = {
|
|||||||
createTopic,
|
createTopic,
|
||||||
updateTopicThumbnail,
|
updateTopicThumbnail,
|
||||||
updateTopicVisibility,
|
updateTopicVisibility,
|
||||||
listGames,
|
|
||||||
findGameById,
|
|
||||||
listGameItems,
|
|
||||||
findGameItemById,
|
|
||||||
getGameDetail,
|
|
||||||
createGame,
|
|
||||||
updateGameThumbnail,
|
|
||||||
updateGameVisibility,
|
|
||||||
findImageAssetByHash,
|
findImageAssetByHash,
|
||||||
findImageAssetBySrc,
|
findImageAssetBySrc,
|
||||||
findImageAssetById,
|
findImageAssetById,
|
||||||
@@ -2578,15 +2510,8 @@ module.exports = {
|
|||||||
deleteTopicItem,
|
deleteTopicItem,
|
||||||
deleteTopic,
|
deleteTopic,
|
||||||
updateTopicDisplayOrder,
|
updateTopicDisplayOrder,
|
||||||
createGameItem,
|
|
||||||
updateGameItemLabel,
|
|
||||||
updateGameItemDisplayOrder,
|
|
||||||
countTierListsUsingGameItem,
|
|
||||||
updateCustomItemLabel,
|
updateCustomItemLabel,
|
||||||
updateImageAssetLabel,
|
updateImageAssetLabel,
|
||||||
deleteGameItem,
|
|
||||||
deleteGame,
|
|
||||||
updateGameDisplayOrder,
|
|
||||||
createCustomItem,
|
createCustomItem,
|
||||||
findCustomItemById,
|
findCustomItemById,
|
||||||
listCustomItems,
|
listCustomItems,
|
||||||
@@ -2602,8 +2527,6 @@ module.exports = {
|
|||||||
unfavoriteTopic,
|
unfavoriteTopic,
|
||||||
favoriteTierList,
|
favoriteTierList,
|
||||||
unfavoriteTierList,
|
unfavoriteTierList,
|
||||||
favoriteGame,
|
|
||||||
unfavoriteGame,
|
|
||||||
deleteTierList,
|
deleteTierList,
|
||||||
findCustomItemsByIds,
|
findCustomItemsByIds,
|
||||||
deleteCustomItems,
|
deleteCustomItems,
|
||||||
@@ -2614,5 +2537,4 @@ module.exports = {
|
|||||||
listAdminTemplateRequests,
|
listAdminTemplateRequests,
|
||||||
updateTemplateRequestStatus,
|
updateTemplateRequestStatus,
|
||||||
updateTemplateRequestTargetTopic,
|
updateTemplateRequestTargetTopic,
|
||||||
updateTemplateRequestTargetGame: updateTemplateRequestTargetTopic,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState
|
|||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
function getTemplateIdParam(req) {
|
function getTemplateIdFromParams(req) {
|
||||||
return req.params.templateId || req.params.gameId || ''
|
return req.params.templateId || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUploadFilename(file) {
|
function buildUploadFilename(file) {
|
||||||
@@ -115,7 +115,7 @@ function canManageAdminRole(actingUser, primaryAdmin) {
|
|||||||
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
|
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post(['/games', '/templates'], requireAdmin, async (req, res) => {
|
router.post('/templates', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
name: z.string().min(1).max(60),
|
name: z.string().min(1).max(60),
|
||||||
@@ -125,7 +125,7 @@ router.post(['/games', '/templates'], requireAdmin, async (req, res) => {
|
|||||||
const parsed = schema.safeParse(req.body)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
const exists = await findTopicById(parsed.data.id)
|
const exists = await findTopicById(parsed.data.id)
|
||||||
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
|
||||||
const template = await createTopic({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
|
const template = await createTopic({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
|
||||||
if (parsed.data.thumbnailSrc) {
|
if (parsed.data.thumbnailSrc) {
|
||||||
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
|
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
|
||||||
@@ -135,14 +135,14 @@ router.post(['/games', '/templates'], requireAdmin, async (req, res) => {
|
|||||||
res.json({ template: savedTemplate })
|
res.json({ template: savedTemplate })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
|
router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
isPublic: z.boolean(),
|
isPublic: z.boolean(),
|
||||||
})
|
})
|
||||||
const parsed = schema.safeParse(req.body)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
const templateId = getTemplateIdParam(req)
|
const templateId = getTemplateIdFromParams(req)
|
||||||
const template = await findTopicById(templateId)
|
const template = await findTopicById(templateId)
|
||||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (
|
|||||||
res.json({ template: updated })
|
res.json({ template: updated })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch(['/games/display-order', '/templates/display-order'], requireAdmin, async (req, res) => {
|
router.patch('/templates/display-order', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
topicIds: z.array(z.string().min(1)).max(50),
|
topicIds: z.array(z.string().min(1)).max(50),
|
||||||
})
|
})
|
||||||
@@ -164,14 +164,14 @@ router.patch(['/games/display-order', '/templates/display-order'], requireAdmin,
|
|||||||
res.json({ templates: updatedTemplates })
|
res.json({ templates: updatedTemplates })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch(['/games/:gameId/items/display-order', '/templates/:templateId/items/display-order'], requireAdmin, async (req, res) => {
|
router.patch('/templates/:templateId/items/display-order', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
itemIds: z.array(z.string().min(1)).min(1),
|
itemIds: z.array(z.string().min(1)).min(1),
|
||||||
})
|
})
|
||||||
const parsed = schema.safeParse(req.body)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
const templateId = getTemplateIdParam(req)
|
const templateId = getTemplateIdFromParams(req)
|
||||||
const template = await findTopicById(templateId)
|
const template = await findTopicById(templateId)
|
||||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
@@ -179,15 +179,15 @@ router.patch(['/games/:gameId/items/display-order', '/templates/:templateId/item
|
|||||||
res.json({ items })
|
res.json({ items })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post(['/games/:gameId/thumbnail', '/templates/:templateId/thumbnail'], requireAdmin, upload.single('thumbnail'), async (req, res) => {
|
router.post('/templates/:templateId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||||
const templateId = getTemplateIdParam(req)
|
const templateId = getTemplateIdFromParams(req)
|
||||||
const template = await findTopicById(templateId)
|
const template = await findTopicById(templateId)
|
||||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
const optimized = await writeOptimizedImage({
|
const optimized = await writeOptimizedImage({
|
||||||
file: req.file,
|
file: req.file,
|
||||||
directory: 'games',
|
directory: 'topics',
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 1280,
|
height: 1280,
|
||||||
fit: 'inside',
|
fit: 'inside',
|
||||||
@@ -198,10 +198,10 @@ router.post(['/games/:gameId/thumbnail', '/templates/:templateId/thumbnail'], re
|
|||||||
res.json({ template: updated })
|
res.json({ template: updated })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireAdmin, upload.array('images', 50), async (req, res) => {
|
router.post('/templates/:templateId/images', requireAdmin, upload.array('images', 50), async (req, res) => {
|
||||||
const files = Array.isArray(req.files) ? req.files : []
|
const files = Array.isArray(req.files) ? req.files : []
|
||||||
if (!files.length) return res.status(400).json({ error: 'file_required' })
|
if (!files.length) return res.status(400).json({ error: 'file_required' })
|
||||||
const templateId = getTemplateIdParam(req)
|
const templateId = getTemplateIdFromParams(req)
|
||||||
const template = await findTopicById(templateId)
|
const template = await findTopicById(templateId)
|
||||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireA
|
|||||||
files.map(async (file, index) => {
|
files.map(async (file, index) => {
|
||||||
const optimized = await writeOptimizedImage({
|
const optimized = await writeOptimizedImage({
|
||||||
file,
|
file,
|
||||||
directory: 'games',
|
directory: 'topics',
|
||||||
width: 512,
|
width: 512,
|
||||||
height: 512,
|
height: 512,
|
||||||
fit: 'inside',
|
fit: 'inside',
|
||||||
@@ -233,15 +233,15 @@ router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireA
|
|||||||
res.json({ item: items[0], items })
|
res.json({ item: items[0], items })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
|
router.delete('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
|
||||||
const template = await findTopicById(getTemplateIdParam(req))
|
const template = await findTopicById(getTemplateIdFromParams(req))
|
||||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||||
await deleteTopicItem(req.params.itemId)
|
await deleteTopicItem(req.params.itemId)
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get(['/games/:gameId/items/:itemId/usage', '/templates/:templateId/items/:itemId/usage'], requireAdmin, async (req, res) => {
|
router.get('/templates/:templateId/items/:itemId/usage', requireAdmin, async (req, res) => {
|
||||||
const template = await findTopicById(getTemplateIdParam(req))
|
const template = await findTopicById(getTemplateIdFromParams(req))
|
||||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||||
const item = await findTopicItemById(req.params.itemId)
|
const item = await findTopicItemById(req.params.itemId)
|
||||||
if (!item || item.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
|
if (!item || item.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
|
||||||
@@ -249,12 +249,12 @@ router.get(['/games/:gameId/items/:itemId/usage', '/templates/:templateId/items/
|
|||||||
res.json({ usage })
|
res.json({ usage })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
|
router.patch('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({ label: z.string().trim().min(1).max(60) })
|
const schema = z.object({ label: z.string().trim().min(1).max(60) })
|
||||||
const parsed = schema.safeParse(req.body)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
const template = await findTopicById(getTemplateIdParam(req))
|
const template = await findTopicById(getTemplateIdFromParams(req))
|
||||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
const updated = await updateTopicItemLabel(req.params.itemId, parsed.data.label)
|
const updated = await updateTopicItemLabel(req.params.itemId, parsed.data.label)
|
||||||
@@ -262,8 +262,8 @@ router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:ite
|
|||||||
res.json({ item: updated })
|
res.json({ item: updated })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
|
router.delete('/templates/:templateId', requireAdmin, async (req, res) => {
|
||||||
const templateId = getTemplateIdParam(req)
|
const templateId = getTemplateIdFromParams(req)
|
||||||
const template = await findTopicById(templateId)
|
const template = await findTopicById(templateId)
|
||||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||||
await deleteTopic(templateId)
|
await deleteTopic(templateId)
|
||||||
@@ -593,7 +593,7 @@ async function createTemplateFromTierList({ tierList, templateId, templateName }
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { game: await findTopicById(templateId), items: createdItems }
|
return { template: await findTopicById(templateId), items: createdItems }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTemplateFromRequest({ templateRequest, templateId, templateName }) {
|
async function createTemplateFromRequest({ templateRequest, templateId, templateName }) {
|
||||||
@@ -609,7 +609,7 @@ async function createTemplateFromRequest({ templateRequest, templateId, template
|
|||||||
templateId,
|
templateId,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { game: await findTopicById(templateId), items }
|
return { template: await findTopicById(templateId), items }
|
||||||
}
|
}
|
||||||
|
|
||||||
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||||
@@ -631,7 +631,7 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
|
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
|
||||||
if (target.linkedGames.length > 0) return res.status(409).json({ error: 'item_linked' })
|
if (target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' })
|
||||||
if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
|
if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
|
||||||
|
|
||||||
const items = await findCustomItemsByIds([target.id])
|
const items = await findCustomItemsByIds([target.id])
|
||||||
@@ -691,7 +691,7 @@ router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, re
|
|||||||
res.json({ items })
|
res.json({ items })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (req, res) => {
|
router.post('/tierlists/:tierListId/create-template', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
topicId: z.string().trim().min(1).max(120),
|
topicId: z.string().trim().min(1).max(120),
|
||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
@@ -804,7 +804,7 @@ router.post('/template-requests/:requestId/review', requireAdmin, async (req, re
|
|||||||
res.json({ request })
|
res.json({ request })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/template-requests/:requestId/link-game', requireAdmin, async (req, res) => {
|
router.post('/template-requests/:requestId/link-template', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
topicId: z.string().trim().min(1).max(120),
|
topicId: z.string().trim().min(1).max(120),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const FREEFORM_TOPIC_ID = 'freeform'
|
|||||||
const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기'
|
const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기'
|
||||||
|
|
||||||
function normalizePoolItem(item) {
|
function normalizePoolItem(item) {
|
||||||
if (!item || item.origin !== 'game' || typeof item.src !== 'string') return item
|
if (!item || item.origin !== 'template' || typeof item.src !== 'string') return item
|
||||||
if (item.src.startsWith('/uploads/')) return item
|
if (item.src.startsWith('/uploads/')) return item
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -83,7 +83,7 @@ const templateRequestSchema = z.object({
|
|||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
src: z.string().min(1),
|
src: z.string().min(1),
|
||||||
label: z.string().min(1).max(60),
|
label: z.string().min(1).max(60),
|
||||||
origin: z.enum(['game', 'custom']).default('game'),
|
origin: z.enum(['template', 'custom']).default('template'),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -112,7 +112,7 @@ const tierListUpsertSchema = z.object({
|
|||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
src: z.string().min(1),
|
src: z.string().min(1),
|
||||||
label: z.string().min(1).max(60),
|
label: z.string().min(1).max(60),
|
||||||
origin: z.enum(['game', 'custom']).default('game'),
|
origin: z.enum(['template', 'custom']).default('template'),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
}).superRefine((value, ctx) => {
|
}).superRefine((value, ctx) => {
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.31
|
||||||
|
- 서비스가 아직 외부 공개 전이고 예전 북마크/예전 데이터베이스를 이어갈 필요가 없다는 전제가 확인되었으므로, 남겨둔 호환층을 유지하는 것보다 지금 마감 시점에 완전히 제거해 구조를 단순화하는 편이 맞다고 판단했다.
|
||||||
|
- 이 단계에서는 “기존 것도 읽어준다”보다 “현재 구조만 남긴다”가 더 중요한 목표가 되었으므로, redirect·legacy migration·`origin: 'game'` 허용까지 함께 정리해 실제 코드 검색에서 `game` 흔적을 0건으로 맞추는 방향으로 마감했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.30
|
||||||
|
- 로컬 MariaDB는 테스트용으로 새로 밀어도 된다는 전제가 확인되었으므로, 개발 환경에서는 기존 데이터를 끌고 가는 것보다 현재 스키마가 “빈 DB에서 바로 정상 부팅되는지”를 먼저 검증하는 편이 더 가치 있다고 판단했다.
|
||||||
|
- `origin: 'game'` 호환층은 즉시 제거하기보다, `ensureData()`에서 저장 데이터와 요청 스냅샷을 자동 정규화하게 만들어 두고 새 DB에서도 legacy 값이 생기지 않는 상태를 먼저 확인한 뒤 다음 제거 판단으로 넘기는 편이 가장 안전하다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.29
|
||||||
|
- `origin: 'game'`는 이미 저장된 티어표 데이터와 직접 맞물리므로, 이 단계에서는 새 데이터 기본값만 `template`로 옮기고 예전 값도 계속 받아주는 점진 호환이 가장 안전하다고 판단했다.
|
||||||
|
- 아이템 라이브러리의 `linkedGames`는 실제 의미가 템플릿 연결 정보이므로, 이 응답 키까지 `linkedTemplates`로 바꿔두는 편이 이후 관리자 유지보수에서 훨씬 덜 헷갈린다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.28
|
||||||
|
- 이 시점 이후 코드 검색에 남는 `game`는 대부분 레거시 데이터 마이그레이션, 옛 주소 redirect, 저장 데이터의 `origin` 호환처럼 의도된 층이므로, 무리하게 전부 0으로 만들기보다 기능을 깨뜨리지 않는 선에서 의미 있는 이름층만 더 줄이는 편이 맞다고 판단했다.
|
||||||
|
- 관리자 화면 내부 상태명(`selectedTemplate.game`, `isGameLoading`, `gameVisibilitySaving`)은 실제 기능 의미와 어긋나므로, QA 전에 한 번 더 `template` 기준으로 옮겨두는 편이 이후 유지보수에 더 유리하다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.27
|
||||||
|
- 공개/관리자 API 표면까지 `topic/template`로 정리된 뒤에는, 관리자 내부 상태 이름과 DB export alias에 남은 `game` 흔적도 계속 유지할 이유가 작아졌으므로 이 단계에서 함께 걷어내는 편이 맞다고 판단했다.
|
||||||
|
- 다만 외부에서 직접 참조할 수 있는 공개 북마크와 달리, `adminGames`, `game-admin`, `favoriteGame` 같은 이름은 내부 구현 용어라서 이번 단계에서 정리해도 위험이 낮다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.26
|
||||||
|
- `topic/template` 표면을 거의 마감한 시점에서는 관리자 API와 관리자 화면 경로까지 계속 `/games` alias를 유지하는 편보다, 실제 사용하는 `templates` 경로만 남기고 예전 관리자 주소는 redirect로만 정리하는 편이 더 일관되고 안전하다고 판단했다.
|
||||||
|
- 공개 사용자 북마크는 여전히 `/games -> /topics` redirect가 필요하지만, 백엔드 API의 `/api/games`까지 계속 유지할 이유는 작아졌으므로 이 단계에서 `/api/topics`만 남기는 편이 맞다고 정리했다.
|
||||||
|
|
||||||
## 2026-04-02 v1.4.25
|
## 2026-04-02 v1.4.25
|
||||||
- 이제 프런트와 백엔드 소비층이 `topic/template`를 기본으로 읽을 준비가 되었으므로, 응답과 payload에 `gameId / gameName` 호환 키를 오래 남기는 것보다 실제 표면을 먼저 정리하는 편이 더 낫다고 판단했다.
|
- 이제 프런트와 백엔드 소비층이 `topic/template`를 기본으로 읽을 준비가 되었으므로, 응답과 payload에 `gameId / gameName` 호환 키를 오래 남기는 것보다 실제 표면을 먼저 정리하는 편이 더 낫다고 판단했다.
|
||||||
- 다만 오래된 외부 링크까지 한 번에 끊는 건 위험하므로, 이번 단계에서는 데이터/응답/프런트 소비는 `topic`으로 마감하되 `/games/:gameId`와 관리자 route alias 같은 레거시 주소만 마지막 호환 레이어로 남기는 점진 종료가 가장 안전하다고 정리했다.
|
- 다만 오래된 외부 링크까지 한 번에 끊는 건 위험하므로, 이번 단계에서는 데이터/응답/프런트 소비는 `topic`으로 마감하되 `/games/:gameId`와 관리자 route alias 같은 레거시 주소만 마지막 호환 레이어로 남기는 점진 종료가 가장 안전하다고 정리했다.
|
||||||
|
|||||||
20
docs/map.md
20
docs/map.md
@@ -2,18 +2,18 @@
|
|||||||
|
|
||||||
## `/`
|
## `/`
|
||||||
- 화면 파일: `frontend/src/views/HomeView.vue`
|
- 화면 파일: `frontend/src/views/HomeView.vue`
|
||||||
- 역할: 데스크톱 기본 4열 게임 카드 라이브러리 대시보드, 상단 메인 썸네일과 `게임명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
|
- 역할: 데스크톱 기본 4열 주제 카드 라이브러리 대시보드, 상단 메인 썸네일과 `주제명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 주제 카드 클릭 이동, `직접 티어표 만들기` 진입
|
||||||
- 연동 API: `GET /api/games`
|
- 연동 API: `GET /api/topics`
|
||||||
|
|
||||||
## `/games/:gameId`
|
## `/topics/:topicId`
|
||||||
- 화면 파일: `frontend/src/views/GameHubView.vue`
|
- 화면 파일: `frontend/src/views/GameHubView.vue`
|
||||||
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
- 역할: 선택한 주제 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
|
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
|
||||||
|
|
||||||
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
|
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
|
||||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청
|
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청
|
||||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
|
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
|
||||||
|
|
||||||
## `/login`
|
## `/login`
|
||||||
- 화면 파일: `frontend/src/views/LoginView.vue`
|
- 화면 파일: `frontend/src/views/LoginView.vue`
|
||||||
@@ -37,8 +37,8 @@
|
|||||||
|
|
||||||
## `/admin`
|
## `/admin`
|
||||||
- 화면 파일: `frontend/src/views/AdminView.vue`
|
- 화면 파일: `frontend/src/views/AdminView.vue`
|
||||||
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
|
||||||
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
|
- 연동 API: `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId`
|
||||||
|
|
||||||
## `/profile`
|
## `/profile`
|
||||||
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
||||||
@@ -56,6 +56,6 @@
|
|||||||
- 로컬 DB 실행 설정: `docker-compose.yml`
|
- 로컬 DB 실행 설정: `docker-compose.yml`
|
||||||
- 로컬 MariaDB 가이드: `docs/local-mariadb.md`
|
- 로컬 MariaDB 가이드: `docs/local-mariadb.md`
|
||||||
- 인증 라우트: `backend/src/routes/auth.js`
|
- 인증 라우트: `backend/src/routes/auth.js`
|
||||||
- 게임 라우트: `backend/src/routes/games.js`
|
- 주제 라우트: `backend/src/routes/topics.js`
|
||||||
- 티어표 라우트: `backend/src/routes/tierlists.js`
|
- 티어표 라우트: `backend/src/routes/tierlists.js`
|
||||||
- 관리자 라우트: `backend/src/routes/admin.js`
|
- 관리자 라우트: `backend/src/routes/admin.js`
|
||||||
|
|||||||
23
docs/spec.md
23
docs/spec.md
@@ -114,12 +114,12 @@
|
|||||||
- `GET /api/auth/me`
|
- `GET /api/auth/me`
|
||||||
- `GET /api/auth/meta`
|
- `GET /api/auth/meta`
|
||||||
- `POST /api/auth/profile`
|
- `POST /api/auth/profile`
|
||||||
- 게임
|
- 주제
|
||||||
- `GET /api/games`
|
- `GET /api/topics`
|
||||||
- `GET /api/games/:gameId`
|
- `GET /api/topics/:topicId`
|
||||||
- 티어표
|
- 티어표
|
||||||
- `GET /api/tierlists/public`
|
- `GET /api/tierlists/public`
|
||||||
- `gameId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
|
- `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
|
||||||
- `GET /api/tierlists/me`
|
- `GET /api/tierlists/me`
|
||||||
- `GET /api/tierlists/favorites/me`
|
- `GET /api/tierlists/favorites/me`
|
||||||
- `GET /api/tierlists/:id`
|
- `GET /api/tierlists/:id`
|
||||||
@@ -131,17 +131,18 @@
|
|||||||
- `POST /api/tierlists/custom-items`
|
- `POST /api/tierlists/custom-items`
|
||||||
- `POST /api/tierlists`
|
- `POST /api/tierlists`
|
||||||
- 관리자
|
- 관리자
|
||||||
- `POST /api/admin/games`
|
- `POST /api/admin/templates`
|
||||||
- `POST /api/admin/games/:gameId/thumbnail`
|
- `POST /api/admin/templates/:templateId/thumbnail`
|
||||||
- `POST /api/admin/games/:gameId/images`
|
- `POST /api/admin/templates/:templateId/images`
|
||||||
- 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
|
- 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
|
||||||
- `PATCH /api/admin/games/:gameId/items/:itemId`
|
- `PATCH /api/admin/templates/:templateId/items/:itemId`
|
||||||
- `GET /api/admin/tierlists`
|
- `GET /api/admin/tierlists`
|
||||||
- `GET /api/admin/template-requests`
|
- `GET /api/admin/template-requests`
|
||||||
- `POST /api/admin/template-requests/:requestId/approve`
|
- `POST /api/admin/template-requests/:requestId/approve`
|
||||||
- `POST /api/admin/template-requests/:requestId/reject`
|
- `POST /api/admin/template-requests/:requestId/reject`
|
||||||
|
- `POST /api/admin/template-requests/:requestId/link-template`
|
||||||
- `POST /api/admin/tierlists/:tierListId/promote-items`
|
- `POST /api/admin/tierlists/:tierListId/promote-items`
|
||||||
- `POST /api/admin/tierlists/:tierListId/create-game-template`
|
- `POST /api/admin/tierlists/:tierListId/create-template`
|
||||||
- `GET /api/admin/custom-items`
|
- `GET /api/admin/custom-items`
|
||||||
- `POST /api/admin/custom-items/:itemId/promote`
|
- `POST /api/admin/custom-items/:itemId/promote`
|
||||||
- `DELETE /api/admin/custom-items/:itemId`
|
- `DELETE /api/admin/custom-items/:itemId`
|
||||||
@@ -150,8 +151,8 @@
|
|||||||
- `PATCH /api/admin/users/:userId`
|
- `PATCH /api/admin/users/:userId`
|
||||||
- `PATCH /api/admin/users/:userId/password`
|
- `PATCH /api/admin/users/:userId/password`
|
||||||
- `DELETE /api/admin/users/:userId`
|
- `DELETE /api/admin/users/:userId`
|
||||||
- `DELETE /api/admin/games/:gameId/items/:itemId`
|
- `DELETE /api/admin/templates/:templateId/items/:itemId`
|
||||||
- `DELETE /api/admin/games/:gameId`
|
- `DELETE /api/admin/templates/:templateId`
|
||||||
|
|
||||||
## 관리자 화면 메모
|
## 관리자 화면 메모
|
||||||
- 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다.
|
- 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다.
|
||||||
|
|||||||
17
docs/todo.md
17
docs/todo.md
@@ -1,8 +1,22 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 단기 확인
|
## 단기 확인
|
||||||
|
- `v1.4.31`에서 `/games` redirect와 legacy DB 마이그레이션까지 제거했으므로, 실제 QA에서는 오직 현재 주소(`/topics`, `/admin/templates`)와 새 DB 기준 흐름만 집중적으로 확인하면 된다.
|
||||||
|
- 현재 `backend/src`, `frontend/src` 기준 `game` 검색은 0건이므로, 이후 남는 확인 작업은 기능 QA와 운영 환경 배포 점검 쪽에만 집중한다.
|
||||||
|
- `v1.4.30`에서 빈 로컬 MariaDB 재초기화 검증까지 통과했으므로, 다음 실제 QA에서는 “기존 데이터가 있는 환경”에서 `ensureData()`가 저장 티어표와 템플릿 요청 스냅샷의 legacy origin을 정상 정규화하는지만 추가 확인하면 된다.
|
||||||
|
- 개발 환경 기준으로는 새 DB에서 `legacyTierItems=0`, `legacyRequestItems=0`가 확인됐으므로, 이후에는 `origin: 'game'` 호환 코드를 언제 완전히 제거할지 운영 데이터 기준으로만 판단하면 된다.
|
||||||
|
- `v1.4.29`에서 새 티어표 데이터 기본 origin을 `template`로 바꿨으므로, 저장 후 다시 열기/복사/요청 생성/관리자 가져오기 흐름에서 예전 데이터와 새 데이터가 함께 섞여도 정상 동작하는지 한 번 더 확인한다.
|
||||||
|
- 관리자 아이템 라이브러리 응답 키가 `linkedTemplates`로 정리됐으므로, 사용자 업로드 이미지 삭제 차단과 템플릿 이동 모달이 그대로 정상 동작하는지 확인한다.
|
||||||
|
- 현재 남아 있는 `game`는 레거시 redirect, DB 마이그레이션, 호환용 origin만 남겨둔 상태이므로, `v1.4` QA 후에는 이 레거시 층을 언제 제거할지 별도 마감 판단만 하면 된다.
|
||||||
|
- `v1.4.28`에서 관리자 템플릿 상세 상태와 기본 아이템 정렬 상태 이름을 `template` 기준으로 더 정리했으므로, 관리자 템플릿 선택/공개 전환/기본 아이템 정렬 저장이 그대로 정상인지 한 번 더 확인한다.
|
||||||
|
- 새 템플릿 썸네일/기본 아이템 업로드는 이제 `topics` 디렉터리로 저장되므로, 실제 업로드 후 최적화 작업 분류와 관리자 최근 작업 표시가 자연스럽게 보이는지 확인한다.
|
||||||
|
- 현재 코드 검색에 남는 `game`는 레거시 redirect, DB 마이그레이션, `origin: 'game'` 호환이 중심이므로, 이 층까지 실제로 없앨지 여부는 `v1.4` QA 후 안정성 기준으로 다시 판단한다.
|
||||||
|
- `v1.4.27`에서 관리자 내부 탭/라우트 이름과 DB alias export까지 더 정리했으므로, 관리자 템플릿 탭 이동, 커스텀 아이템에서 템플릿 관리로 점프, 템플릿 요청 확인하기 이동이 모두 정상인지 한 번 더 확인한다.
|
||||||
|
- `v1.4.26`에서 관리자 기본 경로를 `/admin/templates`로 바꾸고 `/api/admin/templates`만 남겼으므로, 관리자 진입/새로고침/뒤로가기와 템플릿 생성·썸네일 업로드·아이템 추가가 모두 정상인지 확인한다.
|
||||||
|
- `v1.4.26`에서 공개 API `/api/games`를 제거했으므로, 실제 서버 재시작 후 홈/주제 상세/티어표 편집기에서 `/api/topics`만으로 모두 정상 동작하는지 확인한다.
|
||||||
|
- 오래된 관리자 주소 `/admin/games`는 redirect만 남겼으므로, 북마크로 직접 진입해도 `/admin/templates`로 자연스럽게 바뀌는지 본다.
|
||||||
- `v1.4.25`에서 티어표/요청 응답의 `gameId / gameName` 호환 키를 실제로 제거했으므로, 브라우저에서 홈 목록, 주제 상세, 저장된 티어표 열기, 즐겨찾기, 검색 결과, 관리자 템플릿 요청/전체 티어표 관리가 모두 정상 동작하는지 한 번 더 QA한다.
|
- `v1.4.25`에서 티어표/요청 응답의 `gameId / gameName` 호환 키를 실제로 제거했으므로, 브라우저에서 홈 목록, 주제 상세, 저장된 티어표 열기, 즐겨찾기, 검색 결과, 관리자 템플릿 요청/전체 티어표 관리가 모두 정상 동작하는지 한 번 더 QA한다.
|
||||||
- `v1.4.25`에서 관리자 route query와 편집기 저장/request payload를 `topicId` 기준으로 옮겼으므로, `/admin/games?topicId=...`, `/admin/tierlists?mode=all&topicId=...`, 티어표 저장, 템플릿 요청, 추가 아이템 가져오기 흐름이 모두 정상인지 확인한다.
|
- `v1.4.25`에서 관리자 route query와 편집기 저장/request payload를 `topicId` 기준으로 옮겼으므로, `/admin/templates?topicId=...`, `/admin/tierlists?mode=all&topicId=...`, 티어표 저장, 템플릿 요청, 추가 아이템 가져오기 흐름이 모두 정상인지 확인한다.
|
||||||
- 남은 `gameId`는 의도적으로 유지한 레거시 주소 alias(`/games/:gameId`)와 관리자 alias route path뿐이므로, 오래된 외부 링크 진입 후 주소가 새 `topic` 체계로 자연스럽게 정규화되는지만 마지막으로 본다.
|
- 남은 `gameId`는 의도적으로 유지한 레거시 주소 alias(`/games/:gameId`)와 관리자 alias route path뿐이므로, 오래된 외부 링크 진입 후 주소가 새 `topic` 체계로 자연스럽게 정규화되는지만 마지막으로 본다.
|
||||||
- `v1.4.24`에서 공개 주제 API와 관리자 템플릿 API의 기본 응답 키를 더 줄였으므로, 실제 브라우저에서 홈 목록, 즐겨찾기 토글, 주제 상세, 티어표 편집기, 관리자 템플릿 공개 전환/생성이 모두 그대로 정상인지 한 번 더 QA한다.
|
- `v1.4.24`에서 공개 주제 API와 관리자 템플릿 API의 기본 응답 키를 더 줄였으므로, 실제 브라우저에서 홈 목록, 즐겨찾기 토글, 주제 상세, 티어표 편집기, 관리자 템플릿 공개 전환/생성이 모두 그대로 정상인지 한 번 더 QA한다.
|
||||||
- 다음 단계에서는 `mapTierListRow`, `mapTemplateRequestRow`, 관리자 route query, 저장 payload 입력 호환에 남아 있는 `gameId/gameName/sourceGameId/targetGameId`를 끝까지 걷어낼지 최종 결정한다.
|
- 다음 단계에서는 `mapTierListRow`, `mapTemplateRequestRow`, 관리자 route query, 저장 payload 입력 호환에 남아 있는 `gameId/gameName/sourceGameId/targetGameId`를 끝까지 걷어낼지 최종 결정한다.
|
||||||
@@ -30,7 +44,6 @@
|
|||||||
- 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다.
|
- 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다.
|
||||||
- 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 API 이름층으로 바뀐 뒤에도 저장, 즐겨찾기, 템플릿 생성, 아이템 정렬 흐름이 자연스러운지 한 번 더 QA한다.
|
- 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 API 이름층으로 바뀐 뒤에도 저장, 즐겨찾기, 템플릿 생성, 아이템 정렬 흐름이 자연스러운지 한 번 더 QA한다.
|
||||||
- `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다.
|
- `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||||
- 다음 단계에서는 `api.getGame`, `listGames`, `favoriteGame`처럼 남아 있는 프런트 API 이름을 어느 수준까지 `topic/template` 의미로 감쌀지 정리한다.
|
|
||||||
- 경로 헬퍼를 사용자 주요 화면에 연결했으므로, 로그인 리다이렉트·공유 프리뷰·복사본 이동·주제 복귀 흐름이 실제 브라우저에서 모두 같은 주소 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
|
- 경로 헬퍼를 사용자 주요 화면에 연결했으므로, 로그인 리다이렉트·공유 프리뷰·복사본 이동·주제 복귀 흐름이 실제 브라우저에서 모두 같은 주소 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||||
- 다음 단계에서는 `router/index.js`의 `gameHub`, `route.params.gameId`, `api.getGame`처럼 남아 있는 프런트 내부 이름도 어디까지 `topic/template` 의미로 감쌀지 범위를 더 좁힌다.
|
- 다음 단계에서는 `router/index.js`의 `gameHub`, `route.params.gameId`, `api.getGame`처럼 남아 있는 프런트 내부 이름도 어디까지 `topic/template` 의미로 감쌀지 범위를 더 좁힌다.
|
||||||
- 주제 상세 화면 제목은 ID fallback 대신 로딩 문구를 쓰도록 바꿨으므로, 직접 진입·뒤로가기·다른 주제로 연속 이동할 때 헤더가 깜빡이거나 이전 제목을 잠깐 유지하지 않는지 한 번 더 QA한다.
|
- 주제 상세 화면 제목은 ID fallback 대신 로딩 문구를 쓰도록 바꿨으므로, 직접 진입·뒤로가기·다른 주제로 연속 이동할 때 헤더가 깜빡이거나 이전 제목을 잠깐 유지하지 않는지 한 번 더 QA한다.
|
||||||
|
|||||||
@@ -1,5 +1,35 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.31
|
||||||
|
- 서비스가 아직 공개 전이고 예전 링크/예전 DB를 이어갈 필요가 없다는 전제에 맞춰, `/games` redirect와 관리자 `/admin/games` redirect, DB 레거시 마이그레이션 코드, legacy origin 정규화 코드를 실제로 제거했다.
|
||||||
|
- 티어표 저장/request schema도 이제 `origin: 'template' | 'custom'`만 받도록 정리했고, 관리자 최근 최적화 작업 분류 fallback에 남아 있던 `games` 처리도 걷어냈다.
|
||||||
|
- seed 데이터 ID까지 `example-topic`, `another-topic` 기준으로 바꿔, 현재 `backend/src`와 `frontend/src` 코드 검색에서 `game` 흔적이 0건인 상태까지 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.30
|
||||||
|
- `ensureData()` 단계에서 저장된 티어표 `pool_json`과 템플릿 요청 스냅샷(`items_json`, `board_items_json`) 안에 남아 있을 수 있는 `origin: 'game'` 값을 자동으로 `template`로 정리하도록 보강했다.
|
||||||
|
- 로컬 MariaDB를 비운 뒤 현재 스키마로 다시 올리는 검증도 함께 진행했고, 새 DB 기준으로 `topics=3`, `tierlists=0`, `legacyTierItems=0`, `legacyRequestItems=0` 상태까지 확인했다.
|
||||||
|
- 즉 현재 개발 환경에서는 새로 생성되거나 다시 초기화한 데이터에 `game` 기반 origin이 남지 않으며, 남은 `game` 코드는 레거시 redirect·DB 마이그레이션 감지·과거 데이터 호환층만 담당하게 됐다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.29
|
||||||
|
- 티어표 저장/request schema는 이제 새 데이터에서 `origin: 'template'`를 기본으로 쓰고, 예전 `origin: 'game'`도 계속 읽을 수 있게 호환 레이어를 남겼다.
|
||||||
|
- 관리자 아이템 라이브러리의 템플릿 연결 정보도 `linkedTemplates` 기준으로 정리해, 내부 응답/프런트 상태에 남아 있던 `linkedGames` 흔적을 제거했다.
|
||||||
|
- 현재 `game` 검색에 남는 것은 레거시 주소 redirect, DB 마이그레이션용 legacy 테이블/컬럼명, 과거 저장 데이터 호환용 `origin: 'game'`처럼 의도적으로 남겨둔 층만 남도록 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.28
|
||||||
|
- 관리자 템플릿 상세 상태(`selectedTemplate.game`)와 관련 응답 키를 `template` 기준으로 정리해, 내부 코드 검색에서 남던 `game` 흔적을 더 줄였다.
|
||||||
|
- 관리자 기본 아이템 정렬/로딩 상태 이름도 `templateItem*`, `isTemplateLoading`, `templateVisibilitySaving` 기준으로 바꾸고, 새 템플릿 자산 업로드는 `topics` 디렉터리로 저장되게 맞췄다.
|
||||||
|
- 현재 코드 검색에서 남는 `game`는 주로 레거시 주소 redirect(`/games/:gameId`), DB 마이그레이션용 legacy 테이블/컬럼명, 기존 저장 데이터와 맞춘 `origin: 'game'` 값처럼 의도적으로 남겨둔 호환층만 남도록 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.27
|
||||||
|
- 관리자 내부 탭/라우트 이름도 `template-admin`, `adminTemplates`, `/admin/templates` 기준으로 더 정리해, 화면 상태값과 라우트 이름에 남아 있던 `game-admin`, `adminGames` 흔적을 줄였다.
|
||||||
|
- 더 이상 참조되지 않는 DB alias export(`listGames`, `createGame`, `favoriteGame` 등)와 `updateTemplateRequestTargetGame` 별칭도 제거해, 백엔드 모듈 표면에서 남아 있던 레거시 `game` 이름층을 더 걷어냈다.
|
||||||
|
- 커스텀 아이템 모달 내부 클래스명도 `createTemplateButton` 기준으로 정리해, 관리자 코드 검색에서 남는 `createGame` 흔적을 줄였다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.26
|
||||||
|
- 관리자 API 레거시 `/games` alias를 걷어내고 `POST /api/admin/templates`, `.../templates/:templateId/...`만 남기도록 정리했다. 관리자 템플릿 연결/가져오기 액션도 `link-template`, `create-template` path로 바꿨다.
|
||||||
|
- 백엔드 공개 주제 라우트도 이제 `/api/topics`만 마운트하고, 오래된 `/api/games` 경로는 제거했다. 관리자 화면 URL 역시 `/admin/games` 대신 `/admin/templates`를 기본 경로로 쓰고, 예전 주소는 redirect만 남겼다.
|
||||||
|
- 문서의 API/화면 매핑도 현재 구조 기준으로 갱신해, `games` 중심 설명 대신 `topics / templates` 기준으로 읽히게 맞췄다.
|
||||||
|
|
||||||
## 2026-04-02 v1.4.25
|
## 2026-04-02 v1.4.25
|
||||||
- 티어표와 템플릿 요청 응답에서 `gameId / gameName / sourceGameId / targetGameId` 호환 키를 실제로 제거하고, 프런트 화면도 `topicId / topicName / sourceTopicId / targetTopicId`만 읽도록 정리했다.
|
- 티어표와 템플릿 요청 응답에서 `gameId / gameName / sourceGameId / targetGameId` 호환 키를 실제로 제거하고, 프런트 화면도 `topicId / topicName / sourceTopicId / targetTopicId`만 읽도록 정리했다.
|
||||||
- 관리자 전체 티어표 관리와 템플릿 요청 관리, 나의 티어표/즐겨찾기/검색 결과 이동, 티어표 편집기 저장·요청 payload도 `topicId` 기준으로 맞춰, 화면과 요청 바디에서 보이는 `game` 흔적을 더 줄였다.
|
- 관리자 전체 티어표 관리와 템플릿 요청 관리, 나의 티어표/즐겨찾기/검색 결과 이동, 티어표 편집기 저장·요청 payload도 `topicId` 기준으로 맞춰, 화면과 요청 바디에서 보이는 `game` 흔적을 더 줄였다.
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ const showRightRailAction = computed(() => false)
|
|||||||
const showSettingsGuideButton = computed(() => route.name === 'profile')
|
const showSettingsGuideButton = computed(() => route.name === 'profile')
|
||||||
const guideSteps = [
|
const guideSteps = [
|
||||||
{
|
{
|
||||||
id: 'select-game',
|
id: 'select-topic',
|
||||||
title: '주제 또는 양식 선택',
|
title: '주제 또는 양식 선택',
|
||||||
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ const props = defineProps({
|
|||||||
stagedRequestDraftCount: { type: Number, required: true },
|
stagedRequestDraftCount: { type: Number, required: true },
|
||||||
appliedRequestItemCount: { type: Number, required: true },
|
appliedRequestItemCount: { type: Number, required: true },
|
||||||
openTemplateCreateModal: { type: Function, required: true },
|
openTemplateCreateModal: { type: Function, required: true },
|
||||||
isGameLoading: { type: Boolean, required: true },
|
isTemplateLoading: { type: Boolean, required: true },
|
||||||
hasSelectedTemplate: { type: Boolean, required: true },
|
hasSelectedTemplate: { type: Boolean, required: true },
|
||||||
selectedTemplate: { type: Object, default: null },
|
selectedTemplate: { type: Object, default: null },
|
||||||
displayThumbnailUrl: { type: String, default: '' },
|
displayThumbnailUrl: { type: String, default: '' },
|
||||||
canApplyThumbnail: { type: Boolean, required: true },
|
canApplyThumbnail: { type: Boolean, required: true },
|
||||||
gameVisibilitySaving: { type: Boolean, required: true },
|
templateVisibilitySaving: { type: Boolean, required: true },
|
||||||
thumbFileInputRef: { type: Function, required: true },
|
thumbFileInputRef: { type: Function, required: true },
|
||||||
openThumbFilePicker: { type: Function, required: true },
|
openThumbFilePicker: { type: Function, required: true },
|
||||||
onThumb: { type: Function, required: true },
|
onThumb: { type: Function, required: true },
|
||||||
@@ -41,14 +41,14 @@ const props = defineProps({
|
|||||||
removeUploadDraft: { type: Function, required: true },
|
removeUploadDraft: { type: Function, required: true },
|
||||||
hasTemplateItemOrderChanges: { type: Boolean, required: true },
|
hasTemplateItemOrderChanges: { type: Boolean, required: true },
|
||||||
saveTemplateItemOrder: { type: Function, required: true },
|
saveTemplateItemOrder: { type: Function, required: true },
|
||||||
gameItemListRef: { type: Function, required: true },
|
templateItemListRef: { type: Function, required: true },
|
||||||
saveTemplateItemLabel: { type: Function, required: true },
|
saveTemplateItemLabel: { type: Function, required: true },
|
||||||
removeTemplateItem: { type: Function, required: true },
|
removeTemplateItem: { type: Function, required: true },
|
||||||
selectedTemplateId: { type: String, default: '' },
|
selectedTemplateId: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
function setGameItemListElement(el) {
|
function setGameItemListElement(el) {
|
||||||
props.gameItemListRef(el)
|
props.templateItemListRef(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setThumbFileElement(el) {
|
function setThumbFileElement(el) {
|
||||||
@@ -102,7 +102,7 @@ function setThumbFileElement(el) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="props.isGameLoading" class="panel panel--empty">
|
<div v-if="props.isTemplateLoading" class="panel panel--empty">
|
||||||
<div class="emptyState">
|
<div class="emptyState">
|
||||||
<div class="emptyState__title">템플릿 정보를 불러오는 중이에요.</div>
|
<div class="emptyState__title">템플릿 정보를 불러오는 중이에요.</div>
|
||||||
<div class="emptyState__desc">선택한 템플릿의 썸네일과 기본 아이템을 곧 표시합니다.</div>
|
<div class="emptyState__desc">선택한 템플릿의 썸네일과 기본 아이템을 곧 표시합니다.</div>
|
||||||
@@ -122,7 +122,7 @@ function setThumbFileElement(el) {
|
|||||||
@dragleave="props.onThumbDragLeave"
|
@dragleave="props.onThumbDragLeave"
|
||||||
@drop="props.onThumbDrop"
|
@drop="props.onThumbDrop"
|
||||||
>
|
>
|
||||||
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.game.name" />
|
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.template.name" />
|
||||||
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
|
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
|
||||||
<div class="thumbDropZone__copy">
|
<div class="thumbDropZone__copy">
|
||||||
<div v-if="!props.displayThumbnailUrl" class="thumbDropZone__iconWrap">
|
<div v-if="!props.displayThumbnailUrl" class="thumbDropZone__iconWrap">
|
||||||
@@ -134,10 +134,10 @@ function setThumbFileElement(el) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="gameSettingsCard__body">
|
<div class="gameSettingsCard__body">
|
||||||
<div class="panel__title">템플릿 설정</div>
|
<div class="panel__title">템플릿 설정</div>
|
||||||
<div class="gameSettingsCard__meta">{{ props.selectedTemplate.game.name }} · {{ props.selectedTemplate.game.id }}</div>
|
<div class="gameSettingsCard__meta">{{ props.selectedTemplate.template.name }} · {{ props.selectedTemplate.template.id }}</div>
|
||||||
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.gameVisibilitySaving }">
|
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.templateVisibilitySaving }">
|
||||||
<input :checked="!!props.selectedTemplate.game.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" />
|
<input :checked="!!props.selectedTemplate.template.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" />
|
||||||
<span class="toggleSwitch__label">{{ props.selectedTemplate.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
|
<span class="toggleSwitch__label">{{ props.selectedTemplate.template.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
|
||||||
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="gameSettingsCard__actions">
|
<div class="gameSettingsCard__actions">
|
||||||
@@ -216,8 +216,8 @@ function setThumbFileElement(el) {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
|
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
|
||||||
<div v-else :ref="setGameItemListElement" class="thumbGrid">
|
<div v-else :ref="setGameItemListElement" class="thumbGrid">
|
||||||
<div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-game-item-id="item.id">
|
<div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-template-item-id="item.id">
|
||||||
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
|
<img class="thumb thumb--template" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
|
||||||
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
|
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
|
||||||
<div class="thumbCard__actions">
|
<div class="thumbCard__actions">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function useAdminCustomItems({
|
|||||||
|
|
||||||
function openCustomItemDeleteModal(item) {
|
function openCustomItemDeleteModal(item) {
|
||||||
if (!item) return
|
if (!item) return
|
||||||
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
|
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
|
||||||
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ export function useAdminCustomItems({
|
|||||||
function jumpToTemplateAdmin(templateId) {
|
function jumpToTemplateAdmin(templateId) {
|
||||||
if (!templateId) return
|
if (!templateId) return
|
||||||
closeCustomItemModal()
|
closeCustomItemModal()
|
||||||
setTab('game-admin')
|
setTab('template-admin')
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
selectAdminTemplate(templateId)
|
selectAdminTemplate(templateId)
|
||||||
})
|
})
|
||||||
@@ -109,7 +109,7 @@ export function useAdminCustomItems({
|
|||||||
async function removeCustomItem(item = modalTargetCustomItem.value) {
|
async function removeCustomItem(item = modalTargetCustomItem.value) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
if (!item) return
|
if (!item) return
|
||||||
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
|
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
|
||||||
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ export function useAdminGameManager({
|
|||||||
thumbFile,
|
thumbFile,
|
||||||
itemPreviewUrls,
|
itemPreviewUrls,
|
||||||
itemFileInput,
|
itemFileInput,
|
||||||
gameItemListEl,
|
templateItemListEl,
|
||||||
gameItemSortable,
|
templateItemSortable,
|
||||||
savedGameItemOrderIds,
|
savedTemplateItemOrderIds,
|
||||||
isGameLoading,
|
isTemplateLoading,
|
||||||
activeTemplateRequest,
|
activeTemplateRequest,
|
||||||
templateRequests,
|
templateRequests,
|
||||||
customItemModalOpen,
|
customItemModalOpen,
|
||||||
@@ -49,21 +49,21 @@ export function useAdminGameManager({
|
|||||||
return src.split('/').pop() || item.file?.name || 'item'
|
return src.split('/').pop() || item.file?.name || 'item'
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroyGameItemSortable() {
|
function destroyTemplateItemSortable() {
|
||||||
if (gameItemSortable.value) {
|
if (templateItemSortable.value) {
|
||||||
gameItemSortable.value.destroy()
|
templateItemSortable.value.destroy()
|
||||||
gameItemSortable.value = null
|
templateItemSortable.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncGameItemSortable() {
|
async function syncTemplateItemSortable() {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
destroyGameItemSortable()
|
destroyTemplateItemSortable()
|
||||||
if (!gameItemListEl.value || !selectedTemplate.value?.items?.length) return
|
if (!templateItemListEl.value || !selectedTemplate.value?.items?.length) return
|
||||||
|
|
||||||
gameItemSortable.value = Sortable.create(gameItemListEl.value, {
|
templateItemSortable.value = Sortable.create(templateItemListEl.value, {
|
||||||
animation: 160,
|
animation: 160,
|
||||||
draggable: '[data-game-item-id]',
|
draggable: '[data-template-item-id]',
|
||||||
forceFallback: true,
|
forceFallback: true,
|
||||||
fallbackOnBody: false,
|
fallbackOnBody: false,
|
||||||
filter: '[data-no-drag]',
|
filter: '[data-no-drag]',
|
||||||
@@ -124,31 +124,30 @@ export function useAdminGameManager({
|
|||||||
|
|
||||||
if (!selectedTemplateId.value) {
|
if (!selectedTemplateId.value) {
|
||||||
selectedTemplate.value = null
|
selectedTemplate.value = null
|
||||||
savedGameItemOrderIds.value = []
|
savedTemplateItemOrderIds.value = []
|
||||||
destroyGameItemSortable()
|
destroyTemplateItemSortable()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isGameLoading.value = true
|
isTemplateLoading.value = true
|
||||||
const data = await api.getTopic(selectedTemplateId.value)
|
const data = await api.getTopic(selectedTemplateId.value)
|
||||||
const loadedTemplate = data.template || data.topic || null
|
const loadedTemplate = data.template || data.topic || null
|
||||||
selectedTemplate.value = {
|
selectedTemplate.value = {
|
||||||
...data,
|
...data,
|
||||||
game: loadedTemplate,
|
|
||||||
template: loadedTemplate,
|
template: loadedTemplate,
|
||||||
items: (data.items || []).map((item) => ({
|
items: (data.items || []).map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
draftLabel: item.label,
|
draftLabel: item.label,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
|
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||||
await syncGameItemSortable()
|
await syncTemplateItemSortable()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
selectedTemplate.value = null
|
selectedTemplate.value = null
|
||||||
error.value = '템플릿 정보를 불러오지 못했어요.'
|
error.value = '템플릿 정보를 불러오지 못했어요.'
|
||||||
} finally {
|
} finally {
|
||||||
isGameLoading.value = false
|
isTemplateLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +173,7 @@ export function useAdminGameManager({
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const createdTemplate = data.template || {}
|
const createdTemplate = data.template || {}
|
||||||
if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) {
|
if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) {
|
||||||
const linkData = await api.linkAdminTemplateRequestGame(activeTemplateRequest.value.id, {
|
const linkData = await api.linkAdminTemplateRequestTemplate(activeTemplateRequest.value.id, {
|
||||||
topicId: createdTemplate.id,
|
topicId: createdTemplate.id,
|
||||||
})
|
})
|
||||||
activeTemplateRequest.value = {
|
activeTemplateRequest.value = {
|
||||||
@@ -350,8 +349,8 @@ export function useAdminGameManager({
|
|||||||
draftLabel: item.label,
|
draftLabel: item.label,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
|
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||||
await syncGameItemSortable()
|
await syncTemplateItemSortable()
|
||||||
success.value = '기본 아이템 순서를 저장했어요.'
|
success.value = '기본 아이템 순서를 저장했어요.'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '기본 아이템 순서 저장에 실패했어요.'
|
error.value = '기본 아이템 순서 저장에 실패했어요.'
|
||||||
@@ -360,8 +359,8 @@ export function useAdminGameManager({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
requestItemFilename,
|
requestItemFilename,
|
||||||
destroyGameItemSortable,
|
destroyTemplateItemSortable,
|
||||||
syncGameItemSortable,
|
syncTemplateItemSortable,
|
||||||
mergeRequestItemsIntoDrafts,
|
mergeRequestItemsIntoDrafts,
|
||||||
removeUploadDraft,
|
removeUploadDraft,
|
||||||
loadTemplate,
|
loadTemplate,
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function useAdminTemplateRequests({
|
|||||||
Object.assign(request, syncedRequest)
|
Object.assign(request, syncedRequest)
|
||||||
request.status = syncedRequest.status || 'reviewing'
|
request.status = syncedRequest.status || 'reviewing'
|
||||||
updateActiveTemplateRequest(syncedRequest)
|
updateActiveTemplateRequest(syncedRequest)
|
||||||
setTab('game-admin')
|
setTab('template-admin')
|
||||||
|
|
||||||
if (request.type === 'create') {
|
if (request.type === 'create') {
|
||||||
const linkedTopicId = syncedRequest.targetTopicId || ''
|
const linkedTopicId = syncedRequest.targetTopicId || ''
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ export const api = {
|
|||||||
promoteAdminTierListItems: (tierListId, payload) =>
|
promoteAdminTierListItems: (tierListId, payload) =>
|
||||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
|
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
|
||||||
createAdminTemplateFromTierList: (tierListId, payload) =>
|
createAdminTemplateFromTierList: (tierListId, payload) =>
|
||||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
|
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-template`, { method: 'POST', body: payload }),
|
||||||
startAdminTemplateRequestReview: (requestId) =>
|
startAdminTemplateRequestReview: (requestId) =>
|
||||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/review`, { method: 'POST', body: {} }),
|
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/review`, { method: 'POST', body: {} }),
|
||||||
linkAdminTemplateRequestGame: (requestId, payload) =>
|
linkAdminTemplateRequestTemplate: (requestId, payload) =>
|
||||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/link-game`, { method: 'POST', body: payload }),
|
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/link-template`, { method: 'POST', body: payload }),
|
||||||
promoteAdminTemplateRequestItems: (requestId, payload) =>
|
promoteAdminTemplateRequestItems: (requestId, payload) =>
|
||||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/promote-items`, { method: 'POST', body: payload }),
|
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/promote-items`, { method: 'POST', body: payload }),
|
||||||
completeAdminTemplateRequest: (requestId) =>
|
completeAdminTemplateRequest: (requestId) =>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export function createRouter() {
|
|||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', name: 'home', component: HomeView },
|
{ path: '/', name: 'home', component: HomeView },
|
||||||
{ path: '/games/:gameId', redirect: (to) => `/topics/${encodeURIComponent(String(to.params.gameId || ''))}` },
|
|
||||||
{ path: '/topics/:topicId', name: 'topicHub', component: GameHubView },
|
{ path: '/topics/:topicId', name: 'topicHub', component: GameHubView },
|
||||||
{ path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView },
|
{ path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView },
|
||||||
{ path: '/editor/:topicId/:tierListId', name: 'editEditor', component: TierEditorView },
|
{ path: '/editor/:topicId/:tierListId', name: 'editEditor', component: TierEditorView },
|
||||||
@@ -26,7 +25,7 @@ export function createRouter() {
|
|||||||
{ path: '/search', name: 'search', component: SearchResultsView },
|
{ path: '/search', name: 'search', component: SearchResultsView },
|
||||||
{ path: '/admin', redirect: '/admin/featured' },
|
{ path: '/admin', redirect: '/admin/featured' },
|
||||||
{ path: '/admin/featured', name: 'adminFeatured', component: AdminView },
|
{ path: '/admin/featured', name: 'adminFeatured', component: AdminView },
|
||||||
{ path: '/admin/games', name: 'adminGames', component: AdminView },
|
{ path: '/admin/templates', name: 'adminTemplates', component: AdminView },
|
||||||
{ path: '/admin/items', name: 'adminItems', component: AdminView },
|
{ path: '/admin/items', name: 'adminItems', component: AdminView },
|
||||||
{ path: '/admin/tierlists', name: 'adminTierlists', component: AdminView },
|
{ path: '/admin/tierlists', name: 'adminTierlists', component: AdminView },
|
||||||
{ path: '/admin/users', name: 'adminUsers', component: AdminView },
|
{ path: '/admin/users', name: 'adminUsers', component: AdminView },
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const selectedTemplateId = ref('')
|
|||||||
const selectedTemplate = ref(null)
|
const selectedTemplate = ref(null)
|
||||||
const featuredTemplateIds = ref([])
|
const featuredTemplateIds = ref([])
|
||||||
const templatePickerModalOpen = ref(false)
|
const templatePickerModalOpen = ref(false)
|
||||||
const templatePickerMode = ref('game-admin')
|
const templatePickerMode = ref('template-admin')
|
||||||
const templatePickerQuery = ref('')
|
const templatePickerQuery = ref('')
|
||||||
const templatePickerSort = ref('recent')
|
const templatePickerSort = ref('recent')
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ const success = ref('')
|
|||||||
const newTemplateId = ref('')
|
const newTemplateId = ref('')
|
||||||
const newTemplateName = ref('')
|
const newTemplateName = ref('')
|
||||||
const newTemplateIsPublic = ref(false)
|
const newTemplateIsPublic = ref(false)
|
||||||
const gameVisibilitySaving = ref(false)
|
const templateVisibilitySaving = ref(false)
|
||||||
|
|
||||||
const uploadFiles = ref([])
|
const uploadFiles = ref([])
|
||||||
const uploadItemDrafts = ref([])
|
const uploadItemDrafts = ref([])
|
||||||
@@ -122,12 +122,12 @@ const itemFileInput = ref(null)
|
|||||||
const thumbFileInput = ref(null)
|
const thumbFileInput = ref(null)
|
||||||
const featuredListEl = ref(null)
|
const featuredListEl = ref(null)
|
||||||
const featuredSortable = ref(null)
|
const featuredSortable = ref(null)
|
||||||
const gameItemListEl = ref(null)
|
const templateItemListEl = ref(null)
|
||||||
const gameItemSortable = ref(null)
|
const templateItemSortable = ref(null)
|
||||||
let gameItemSortableSyncTimer = null
|
let templateItemSortableSyncTimer = null
|
||||||
const savedGameItemOrderIds = ref([])
|
const savedTemplateItemOrderIds = ref([])
|
||||||
const userAvatarInputs = ref({})
|
const userAvatarInputs = ref({})
|
||||||
const isGameLoading = ref(false)
|
const isTemplateLoading = ref(false)
|
||||||
const templateCreateModalOpen = ref(false)
|
const templateCreateModalOpen = ref(false)
|
||||||
const previousBodyOverflow = ref('')
|
const previousBodyOverflow = ref('')
|
||||||
|
|
||||||
@@ -144,20 +144,20 @@ function setThumbFileInputRef(el) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleGameItemSortableSync() {
|
function scheduleGameItemSortableSync() {
|
||||||
if (gameItemSortableSyncTimer) {
|
if (templateItemSortableSyncTimer) {
|
||||||
clearTimeout(gameItemSortableSyncTimer)
|
clearTimeout(templateItemSortableSyncTimer)
|
||||||
gameItemSortableSyncTimer = null
|
templateItemSortableSyncTimer = null
|
||||||
}
|
}
|
||||||
if (!gameItemListEl.value || !selectedTemplate.value?.items?.length) return
|
if (!templateItemListEl.value || !selectedTemplate.value?.items?.length) return
|
||||||
|
|
||||||
gameItemSortableSyncTimer = setTimeout(() => {
|
templateItemSortableSyncTimer = setTimeout(() => {
|
||||||
gameItemSortableSyncTimer = null
|
templateItemSortableSyncTimer = null
|
||||||
syncGameItemSortable()
|
syncTemplateItemSortable()
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setGameItemListRef(el) {
|
function setGameItemListRef(el) {
|
||||||
gameItemListEl.value = el
|
templateItemListEl.value = el
|
||||||
if (!el) return
|
if (!el) return
|
||||||
scheduleGameItemSortableSync()
|
scheduleGameItemSortableSync()
|
||||||
}
|
}
|
||||||
@@ -175,7 +175,7 @@ function normalizeAdminSrc(src) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.game?.id)
|
const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.template?.id)
|
||||||
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value)
|
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value)
|
||||||
const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedTemplateId.value)
|
const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedTemplateId.value)
|
||||||
const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length)
|
const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length)
|
||||||
@@ -188,7 +188,7 @@ const appliedRequestItemCount = computed(() => {
|
|||||||
})
|
})
|
||||||
const hasTemplateItemOrderChanges = computed(() => {
|
const hasTemplateItemOrderChanges = computed(() => {
|
||||||
const currentIds = (selectedTemplate.value?.items || []).map((item) => item.id)
|
const currentIds = (selectedTemplate.value?.items || []).map((item) => item.id)
|
||||||
return currentIds.join('|') !== savedGameItemOrderIds.value.join('|')
|
return currentIds.join('|') !== savedTemplateItemOrderIds.value.join('|')
|
||||||
})
|
})
|
||||||
const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value)))
|
const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value)))
|
||||||
const adminTierListPageCount = computed(() => Math.max(1, Math.ceil(adminTierListTotal.value / adminTierListLimit.value)))
|
const adminTierListPageCount = computed(() => Math.max(1, Math.ceil(adminTierListTotal.value / adminTierListLimit.value)))
|
||||||
@@ -215,7 +215,7 @@ const customItemTargetTemplate = computed(() => templates.value.find((template)
|
|||||||
const importModalItemCount = computed(() => importModalItems.value.length)
|
const importModalItemCount = computed(() => importModalItems.value.length)
|
||||||
const activeTabTitle = computed(() => {
|
const activeTabTitle = computed(() => {
|
||||||
if (activeTab.value === 'featured') return '목록 관리'
|
if (activeTab.value === 'featured') return '목록 관리'
|
||||||
if (activeTab.value === 'game-admin') return '템플릿 관리'
|
if (activeTab.value === 'template-admin') return '템플릿 관리'
|
||||||
if (activeTab.value === 'items') return '아이템 관리'
|
if (activeTab.value === 'items') return '아이템 관리'
|
||||||
if (activeTab.value === 'tierlists') {
|
if (activeTab.value === 'tierlists') {
|
||||||
return tierlistsMode.value === 'requests' ? '템플릿 요청 관리' : '전체 티어표 관리'
|
return tierlistsMode.value === 'requests' ? '템플릿 요청 관리' : '전체 티어표 관리'
|
||||||
@@ -226,7 +226,7 @@ const activeTabDescription = computed(() => {
|
|||||||
if (activeTab.value === 'featured') {
|
if (activeTab.value === 'featured') {
|
||||||
return '홈 화면 상단에 고정 노출되는 템플릿 순서를 따로 관리합니다.'
|
return '홈 화면 상단에 고정 노출되는 템플릿 순서를 따로 관리합니다.'
|
||||||
}
|
}
|
||||||
if (activeTab.value === 'game-admin') {
|
if (activeTab.value === 'template-admin') {
|
||||||
return '템플릿 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
|
return '템플릿 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
|
||||||
}
|
}
|
||||||
if (activeTab.value === 'items') {
|
if (activeTab.value === 'items') {
|
||||||
@@ -251,7 +251,7 @@ const adminOverviewStats = computed(() => {
|
|||||||
{ label: '추가 가능', value: `${Math.max(0, 50 - featuredTemplateIds.value.length)}` },
|
{ label: '추가 가능', value: `${Math.max(0, 50 - featuredTemplateIds.value.length)}` },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
if (activeTab.value === 'game-admin') {
|
if (activeTab.value === 'template-admin') {
|
||||||
return [
|
return [
|
||||||
{ label: '전체 템플릿', value: `${templates.value.length}` },
|
{ label: '전체 템플릿', value: `${templates.value.length}` },
|
||||||
{ label: '티어표 전체', value: `${selectedTemplateTierListStats.value.total || 0}` },
|
{ label: '티어표 전체', value: `${selectedTemplateTierListStats.value.total || 0}` },
|
||||||
@@ -305,14 +305,14 @@ const isAnyModalOpen = computed(
|
|||||||
)
|
)
|
||||||
const adminRouteNameByTab = {
|
const adminRouteNameByTab = {
|
||||||
featured: 'adminFeatured',
|
featured: 'adminFeatured',
|
||||||
'game-admin': 'adminGames',
|
'template-admin': 'adminTemplates',
|
||||||
items: 'adminItems',
|
items: 'adminItems',
|
||||||
tierlists: 'adminTierlists',
|
tierlists: 'adminTierlists',
|
||||||
users: 'adminUsers',
|
users: 'adminUsers',
|
||||||
}
|
}
|
||||||
|
|
||||||
function tabFromAdminRoute(name) {
|
function tabFromAdminRoute(name) {
|
||||||
if (name === 'adminGames') return 'game-admin'
|
if (name === 'adminTemplates') return 'template-admin'
|
||||||
if (name === 'adminItems') return 'items'
|
if (name === 'adminItems') return 'items'
|
||||||
if (name === 'adminTierlists') return 'tierlists'
|
if (name === 'adminTierlists') return 'tierlists'
|
||||||
if (name === 'adminUsers') return 'users'
|
if (name === 'adminUsers') return 'users'
|
||||||
@@ -375,12 +375,12 @@ onUnmounted(() => {
|
|||||||
if (typeof document !== 'undefined') document.body.style.overflow = previousBodyOverflow.value || ''
|
if (typeof document !== 'undefined') document.body.style.overflow = previousBodyOverflow.value || ''
|
||||||
clearPreviewUrl('item')
|
clearPreviewUrl('item')
|
||||||
clearPreviewUrl('thumb')
|
clearPreviewUrl('thumb')
|
||||||
if (gameItemSortableSyncTimer) {
|
if (templateItemSortableSyncTimer) {
|
||||||
clearTimeout(gameItemSortableSyncTimer)
|
clearTimeout(templateItemSortableSyncTimer)
|
||||||
gameItemSortableSyncTimer = null
|
templateItemSortableSyncTimer = null
|
||||||
}
|
}
|
||||||
destroyFeaturedSortable()
|
destroyFeaturedSortable()
|
||||||
destroyGameItemSortable()
|
destroyTemplateItemSortable()
|
||||||
})
|
})
|
||||||
|
|
||||||
function clearPreviewUrl(kind) {
|
function clearPreviewUrl(kind) {
|
||||||
@@ -423,7 +423,7 @@ watch(
|
|||||||
() => route.name,
|
() => route.name,
|
||||||
(name) => {
|
(name) => {
|
||||||
activeTab.value = tabFromAdminRoute(name)
|
activeTab.value = tabFromAdminRoute(name)
|
||||||
if (name === 'adminGames') {
|
if (name === 'adminTemplates') {
|
||||||
const nextTopicId = typeof route.query.topicId === 'string' ? route.query.topicId : ''
|
const nextTopicId = typeof route.query.topicId === 'string' ? route.query.topicId : ''
|
||||||
if (nextTopicId && nextTopicId !== selectedTemplateId.value) {
|
if (nextTopicId && nextTopicId !== selectedTemplateId.value) {
|
||||||
selectedTemplateId.value = nextTopicId
|
selectedTemplateId.value = nextTopicId
|
||||||
@@ -446,13 +446,13 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => selectedTemplateId.value,
|
() => selectedTemplateId.value,
|
||||||
(templateId) => {
|
(templateId) => {
|
||||||
if (route.name !== 'adminGames') return
|
if (route.name !== 'adminTemplates') return
|
||||||
syncAdminRouteQuery({ topicId: templateId || undefined })
|
syncAdminRouteQuery({ topicId: templateId || undefined })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => selectedTemplate.value?.game?.id || '',
|
() => selectedTemplate.value?.template?.id || '',
|
||||||
async (templateId) => {
|
async (templateId) => {
|
||||||
await refreshSelectedTemplateTierListStats(templateId)
|
await refreshSelectedTemplateTierListStats(templateId)
|
||||||
},
|
},
|
||||||
@@ -481,7 +481,7 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => activeTab.value,
|
() => activeTab.value,
|
||||||
async (tab) => {
|
async (tab) => {
|
||||||
if (tab === 'game-admin' && selectedTemplateId.value && !selectedTemplate.value?.game?.id) {
|
if (tab === 'template-admin' && selectedTemplateId.value && !selectedTemplate.value?.template?.id) {
|
||||||
await loadTemplate()
|
await loadTemplate()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -524,7 +524,7 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [selectedTemplate.value?.game?.id || '', selectedTemplate.value?.items?.length || 0, !!gameItemListEl.value],
|
() => [selectedTemplate.value?.template?.id || '', selectedTemplate.value?.items?.length || 0, !!templateItemListEl.value],
|
||||||
([templateId, itemCount, hasListEl]) => {
|
([templateId, itemCount, hasListEl]) => {
|
||||||
if (!templateId || !itemCount || !hasListEl) return
|
if (!templateId || !itemCount || !hasListEl) return
|
||||||
scheduleGameItemSortableSync()
|
scheduleGameItemSortableSync()
|
||||||
@@ -571,7 +571,7 @@ function formatImageJobSourceCategory(category) {
|
|||||||
return '커스텀 아이템'
|
return '커스텀 아이템'
|
||||||
case 'tierlists':
|
case 'tierlists':
|
||||||
return '티어표 썸네일'
|
return '티어표 썸네일'
|
||||||
case 'games':
|
case 'topics':
|
||||||
return '주제/템플릿 이미지'
|
return '주제/템플릿 이미지'
|
||||||
case 'avatars':
|
case 'avatars':
|
||||||
return '프로필 아바타'
|
return '프로필 아바타'
|
||||||
@@ -619,7 +619,7 @@ const imageDiagnosticsCards = computed(() => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
const visibleLinkedTemplates = computed(() =>
|
const visibleLinkedTemplates = computed(() =>
|
||||||
(modalTargetCustomItem.value?.linkedGames || []).filter((template) => template?.id && template.id !== 'freeform')
|
(modalTargetCustomItem.value?.linkedTemplates || []).filter((template) => template?.id && template.id !== 'freeform')
|
||||||
)
|
)
|
||||||
const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean)))
|
const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean)))
|
||||||
|
|
||||||
@@ -732,7 +732,7 @@ function setTab(tab) {
|
|||||||
const nextRouteName = adminRouteNameByTab[tab]
|
const nextRouteName = adminRouteNameByTab[tab]
|
||||||
if (nextRouteName && route.name !== nextRouteName) {
|
if (nextRouteName && route.name !== nextRouteName) {
|
||||||
const nextQuery =
|
const nextQuery =
|
||||||
tab === 'game-admin'
|
tab === 'template-admin'
|
||||||
? { topicId: selectedTemplateId.value || undefined }
|
? { topicId: selectedTemplateId.value || undefined }
|
||||||
: tab === 'tierlists' && tierlistsMode.value === 'all'
|
: tab === 'tierlists' && tierlistsMode.value === 'all'
|
||||||
? { mode: 'all' }
|
? { mode: 'all' }
|
||||||
@@ -931,8 +931,8 @@ const {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
destroyGameItemSortable,
|
destroyTemplateItemSortable,
|
||||||
syncGameItemSortable,
|
syncTemplateItemSortable,
|
||||||
mergeRequestItemsIntoDrafts,
|
mergeRequestItemsIntoDrafts,
|
||||||
removeUploadDraft,
|
removeUploadDraft,
|
||||||
loadTemplate,
|
loadTemplate,
|
||||||
@@ -953,10 +953,10 @@ const {
|
|||||||
thumbFile,
|
thumbFile,
|
||||||
itemPreviewUrls,
|
itemPreviewUrls,
|
||||||
itemFileInput,
|
itemFileInput,
|
||||||
gameItemListEl,
|
templateItemListEl,
|
||||||
gameItemSortable,
|
templateItemSortable,
|
||||||
savedGameItemOrderIds,
|
savedTemplateItemOrderIds,
|
||||||
isGameLoading,
|
isTemplateLoading,
|
||||||
activeTemplateRequest,
|
activeTemplateRequest,
|
||||||
templateRequests,
|
templateRequests,
|
||||||
customItemModalOpen,
|
customItemModalOpen,
|
||||||
@@ -1167,17 +1167,17 @@ async function uploadThumbnail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveTemplateVisibility() {
|
async function saveTemplateVisibility() {
|
||||||
if (!selectedTemplate.value?.game?.id) return
|
if (!selectedTemplate.value?.template?.id) return
|
||||||
try {
|
try {
|
||||||
gameVisibilitySaving.value = true
|
templateVisibilitySaving.value = true
|
||||||
const data = await api.updateAdminTemplate(selectedTemplate.value.game.id, {
|
const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, {
|
||||||
isPublic: !!selectedTemplate.value.game.isPublic,
|
isPublic: !!selectedTemplate.value.template.isPublic,
|
||||||
})
|
})
|
||||||
const nextTemplate = data.template || {}
|
const nextTemplate = data.template || {}
|
||||||
selectedTemplate.value = {
|
selectedTemplate.value = {
|
||||||
...selectedTemplate.value,
|
...selectedTemplate.value,
|
||||||
game: {
|
template: {
|
||||||
...selectedTemplate.value.game,
|
...selectedTemplate.value.template,
|
||||||
...nextTemplate,
|
...nextTemplate,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1188,17 +1188,17 @@ async function saveTemplateVisibility() {
|
|||||||
error.value = '템플릿 공개 상태를 저장하지 못했어요.'
|
error.value = '템플릿 공개 상태를 저장하지 못했어요.'
|
||||||
return false
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
gameVisibilitySaving.value = false
|
templateVisibilitySaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleSelectedTemplateVisibility(nextValue) {
|
async function toggleSelectedTemplateVisibility(nextValue) {
|
||||||
if (!selectedTemplate.value?.game?.id || gameVisibilitySaving.value) return
|
if (!selectedTemplate.value?.template?.id || templateVisibilitySaving.value) return
|
||||||
const previous = !!selectedTemplate.value.game.isPublic
|
const previous = !!selectedTemplate.value.template.isPublic
|
||||||
selectedTemplate.value = {
|
selectedTemplate.value = {
|
||||||
...selectedTemplate.value,
|
...selectedTemplate.value,
|
||||||
game: {
|
template: {
|
||||||
...selectedTemplate.value.game,
|
...selectedTemplate.value.template,
|
||||||
isPublic: !!nextValue,
|
isPublic: !!nextValue,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1206,8 +1206,8 @@ async function toggleSelectedTemplateVisibility(nextValue) {
|
|||||||
if (!saved) {
|
if (!saved) {
|
||||||
selectedTemplate.value = {
|
selectedTemplate.value = {
|
||||||
...selectedTemplate.value,
|
...selectedTemplate.value,
|
||||||
game: {
|
template: {
|
||||||
...selectedTemplate.value.game,
|
...selectedTemplate.value.template,
|
||||||
isPublic: previous,
|
isPublic: previous,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1278,9 +1278,9 @@ async function saveTemplateItemLabel(item) {
|
|||||||
|
|
||||||
async function removeTemplate() {
|
async function removeTemplate() {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
if (!selectedTemplateId.value || !selectedTemplate.value?.game) return
|
if (!selectedTemplateId.value || !selectedTemplate.value?.template) return
|
||||||
|
|
||||||
const ok = window.confirm(`"${selectedTemplate.value.game.name}" 템플릿을 삭제할까요? 관련 기본 아이템과 티어표도 함께 삭제됩니다.`)
|
const ok = window.confirm(`"${selectedTemplate.value.template.name}" 템플릿을 삭제할까요? 관련 기본 아이템과 티어표도 함께 삭제됩니다.`)
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1290,7 +1290,7 @@ async function removeTemplate() {
|
|||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('failed')
|
if (!res.ok) throw new Error('failed')
|
||||||
|
|
||||||
const deletedName = selectedTemplate.value.game.name
|
const deletedName = selectedTemplate.value.template.name
|
||||||
selectedTemplateId.value = ''
|
selectedTemplateId.value = ''
|
||||||
selectedTemplate.value = null
|
selectedTemplate.value = null
|
||||||
resetUploadState()
|
resetUploadState()
|
||||||
@@ -1312,7 +1312,7 @@ function setAdminTierListGameId(topicId) {
|
|||||||
refreshAdminTierLists()
|
refreshAdminTierLists()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTemplatePickerModal(mode = 'game-admin') {
|
function openTemplatePickerModal(mode = 'template-admin') {
|
||||||
templatePickerMode.value = mode
|
templatePickerMode.value = mode
|
||||||
templatePickerQuery.value = ''
|
templatePickerQuery.value = ''
|
||||||
templatePickerSort.value = 'recent'
|
templatePickerSort.value = 'recent'
|
||||||
@@ -1368,7 +1368,7 @@ function buildModalItemFromTierListItem(item, tierList) {
|
|||||||
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'template' : 'user'),
|
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'template' : 'user'),
|
||||||
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
|
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
|
||||||
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
|
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
|
||||||
linkedGames: Array.isArray(matchedItem?.linkedGames) ? matchedItem.linkedGames : [],
|
linkedTemplates: Array.isArray(matchedItem?.linkedTemplates) ? matchedItem.linkedTemplates : [],
|
||||||
usageCount: matchedItem?.usageCount || 0,
|
usageCount: matchedItem?.usageCount || 0,
|
||||||
canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false,
|
canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false,
|
||||||
isPromoting: false,
|
isPromoting: false,
|
||||||
@@ -1432,7 +1432,7 @@ async function saveAdminTierListMeta() {
|
|||||||
adminTierLists.value = adminTierLists.value.map((tierList) => (tierList.id === updated.id ? { ...tierList, ...updated } : tierList))
|
adminTierLists.value = adminTierLists.value.map((tierList) => (tierList.id === updated.id ? { ...tierList, ...updated } : tierList))
|
||||||
if (previewTierList.value?.id === updated.id) previewTierList.value = { ...previewTierList.value, ...updated }
|
if (previewTierList.value?.id === updated.id) previewTierList.value = { ...previewTierList.value, ...updated }
|
||||||
modalTargetAdminTierList.value = updated
|
modalTargetAdminTierList.value = updated
|
||||||
await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.game?.id || '')])
|
await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.template?.id || '')])
|
||||||
success.value = '티어표 정보를 수정했어요.'
|
success.value = '티어표 정보를 수정했어요.'
|
||||||
closeAdminTierListManageModal()
|
closeAdminTierListManageModal()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1454,7 +1454,7 @@ async function deleteAdminTierListEntry() {
|
|||||||
adminTierLists.value = adminTierLists.value.filter((tierList) => tierList.id !== modalTargetAdminTierList.value.id)
|
adminTierLists.value = adminTierLists.value.filter((tierList) => tierList.id !== modalTargetAdminTierList.value.id)
|
||||||
adminTierListTotal.value = Math.max(0, adminTierListTotal.value - 1)
|
adminTierListTotal.value = Math.max(0, adminTierListTotal.value - 1)
|
||||||
if (previewTierList.value?.id === modalTargetAdminTierList.value.id) previewTierList.value = null
|
if (previewTierList.value?.id === modalTargetAdminTierList.value.id) previewTierList.value = null
|
||||||
await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.game?.id || '')])
|
await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.template?.id || '')])
|
||||||
success.value = '티어표를 삭제했어요.'
|
success.value = '티어표를 삭제했어요.'
|
||||||
closeAdminTierListManageModal()
|
closeAdminTierListManageModal()
|
||||||
if (!adminTierLists.value.length && adminTierListPage.value > 1) {
|
if (!adminTierLists.value.length && adminTierListPage.value > 1) {
|
||||||
@@ -1641,7 +1641,7 @@ function templateRequestTargetLabel(request) {
|
|||||||
|
|
||||||
const displayThumbnailUrl = computed(() => {
|
const displayThumbnailUrl = computed(() => {
|
||||||
if (thumbPreviewUrl.value) return thumbPreviewUrl.value
|
if (thumbPreviewUrl.value) return thumbPreviewUrl.value
|
||||||
if (selectedTemplate.value?.game?.thumbnailSrc) return toApiUrl(selectedTemplate.value.game.thumbnailSrc)
|
if (selectedTemplate.value?.template?.thumbnailSrc) return toApiUrl(selectedTemplate.value.template.thumbnailSrc)
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1701,18 +1701,18 @@ function userAvatarFallback(user) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminGamesSection
|
<AdminGamesSection
|
||||||
v-else-if="activeTab === 'game-admin'"
|
v-else-if="activeTab === 'template-admin'"
|
||||||
:active-template-request="activeTemplateRequest"
|
:active-template-request="activeTemplateRequest"
|
||||||
:template-request-source-url="templateRequestSourceUrl"
|
:template-request-source-url="templateRequestSourceUrl"
|
||||||
:staged-request-draft-count="stagedRequestDraftCount"
|
:staged-request-draft-count="stagedRequestDraftCount"
|
||||||
:applied-request-item-count="appliedRequestItemCount"
|
:applied-request-item-count="appliedRequestItemCount"
|
||||||
:open-template-create-modal="openTemplateCreateModal"
|
:open-template-create-modal="openTemplateCreateModal"
|
||||||
:is-game-loading="isGameLoading"
|
:is-template-loading="isTemplateLoading"
|
||||||
:has-selected-template="hasSelectedTemplate"
|
:has-selected-template="hasSelectedTemplate"
|
||||||
:selected-template="selectedTemplate"
|
:selected-template="selectedTemplate"
|
||||||
:display-thumbnail-url="displayThumbnailUrl"
|
:display-thumbnail-url="displayThumbnailUrl"
|
||||||
:can-apply-thumbnail="canApplyThumbnail"
|
:can-apply-thumbnail="canApplyThumbnail"
|
||||||
:game-visibility-saving="gameVisibilitySaving"
|
:template-visibility-saving="templateVisibilitySaving"
|
||||||
:thumb-file-input-ref="setThumbFileInputRef"
|
:thumb-file-input-ref="setThumbFileInputRef"
|
||||||
:open-thumb-file-picker="openThumbFilePicker"
|
:open-thumb-file-picker="openThumbFilePicker"
|
||||||
:on-thumb="onThumb"
|
:on-thumb="onThumb"
|
||||||
@@ -1739,7 +1739,7 @@ function userAvatarFallback(user) {
|
|||||||
:remove-upload-draft="removeUploadDraft"
|
:remove-upload-draft="removeUploadDraft"
|
||||||
:has-template-item-order-changes="hasTemplateItemOrderChanges"
|
:has-template-item-order-changes="hasTemplateItemOrderChanges"
|
||||||
:save-template-item-order="saveTemplateItemOrder"
|
:save-template-item-order="saveTemplateItemOrder"
|
||||||
:game-item-list-ref="setGameItemListRef"
|
:template-item-list-ref="setGameItemListRef"
|
||||||
:save-template-item-label="saveTemplateItemLabel"
|
:save-template-item-label="saveTemplateItemLabel"
|
||||||
:remove-template-item="removeTemplateItem"
|
:remove-template-item="removeTemplateItem"
|
||||||
:selected-template-id="selectedTemplateId"
|
:selected-template-id="selectedTemplateId"
|
||||||
@@ -1823,7 +1823,7 @@ function userAvatarFallback(user) {
|
|||||||
v-model="newTemplateId"
|
v-model="newTemplateId"
|
||||||
class="field__input"
|
class="field__input"
|
||||||
maxlength="120"
|
maxlength="120"
|
||||||
placeholder="game id (영문/숫자)"
|
placeholder="topic id (영문/숫자)"
|
||||||
@keydown.enter.prevent="createTemplate"
|
@keydown.enter.prevent="createTemplate"
|
||||||
/>
|
/>
|
||||||
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newTemplateId.length }}/120자</span>
|
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newTemplateId.length }}/120자</span>
|
||||||
@@ -1981,7 +1981,7 @@ function userAvatarFallback(user) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="customItemModal__pickerActions">
|
<div class="customItemModal__pickerActions">
|
||||||
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
|
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
|
||||||
<button class="btn btn--ghost btn--small customItemModal__createGameButton" type="button" @click="openTemplateCreateModal">새 템플릿 만들기</button>
|
<button class="btn btn--ghost btn--small customItemModal__createTemplateButton" type="button" @click="openTemplateCreateModal">새 템플릿 만들기</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="customItemModal__body">
|
<div class="customItemModal__body">
|
||||||
@@ -2223,24 +2223,24 @@ function userAvatarFallback(user) {
|
|||||||
<div class="adminSidebar__label">Mode</div>
|
<div class="adminSidebar__label">Mode</div>
|
||||||
<div class="adminSidebar__tabs">
|
<div class="adminSidebar__tabs">
|
||||||
<button class="tab" :class="{ 'tab--active': activeTab === 'featured' }" @click="setTab('featured')">목록 관리</button>
|
<button class="tab" :class="{ 'tab--active': activeTab === 'featured' }" @click="setTab('featured')">목록 관리</button>
|
||||||
<button class="tab" :class="{ 'tab--active': activeTab === 'game-admin' }" @click="setTab('game-admin')">템플릿 관리</button>
|
<button class="tab" :class="{ 'tab--active': activeTab === 'template-admin' }" @click="setTab('template-admin')">템플릿 관리</button>
|
||||||
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
|
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
|
||||||
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
|
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
|
||||||
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
|
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="activeTab === 'game-admin'" class="adminSidebar__panel">
|
<section v-if="activeTab === 'template-admin'" class="adminSidebar__panel">
|
||||||
<div class="adminSidebar__label">Template</div>
|
<div class="adminSidebar__label">Template</div>
|
||||||
<div class="adminSidebar__group">
|
<div class="adminSidebar__group">
|
||||||
<button class="btn btn--primary" @click="openTemplateCreateModal">새 템플릿 생성</button>
|
<button class="btn btn--primary" @click="openTemplateCreateModal">새 템플릿 생성</button>
|
||||||
<button class="btn btn--ghost" @click="openTemplatePickerModal('game-admin')">템플릿 선택</button>
|
<button class="btn btn--ghost" @click="openTemplatePickerModal('template-admin')">템플릿 선택</button>
|
||||||
<div v-if="selectedTemplate?.game" class="adminSelectionCard">
|
<div v-if="selectedTemplate?.template" class="adminSelectionCard">
|
||||||
<div class="adminSelectionCard__label">선택한 템플릿</div>
|
<div class="adminSelectionCard__label">선택한 템플릿</div>
|
||||||
<div class="adminSelectionCard__title">{{ selectedTemplate.game.name }}</div>
|
<div class="adminSelectionCard__title">{{ selectedTemplate.template.name }}</div>
|
||||||
<div class="adminSelectionCard__meta">{{ selectedTemplate.game.id }}</div>
|
<div class="adminSelectionCard__meta">{{ selectedTemplate.template.id }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedTemplateId && !hasSelectedTemplate && !isGameLoading" class="hint hint--tight">선택된 템플릿 ID: {{ selectedTemplateId }}</div>
|
<div v-if="selectedTemplateId && !hasSelectedTemplate && !isTemplateLoading" class="hint hint--tight">선택된 템플릿 ID: {{ selectedTemplateId }}</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -3330,7 +3330,7 @@ function userAvatarFallback(user) {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
.adminUiScope .thumb--game {
|
.adminUiScope .thumb--template {
|
||||||
max-width: 150px;
|
max-width: 150px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -3493,7 +3493,7 @@ function userAvatarFallback(user) {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.adminUiScope .customItemModal__createGameButton {
|
.adminUiScope .customItemModal__createTemplateButton {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
.adminUiScope .customItemModal__body {
|
.adminUiScope .customItemModal__body {
|
||||||
|
|||||||
@@ -904,7 +904,7 @@ onMounted(() => {
|
|||||||
id: img.id,
|
id: img.id,
|
||||||
src: img.src,
|
src: img.src,
|
||||||
label: img.label,
|
label: img.label,
|
||||||
origin: 'game',
|
origin: 'template',
|
||||||
}))
|
}))
|
||||||
const map = {}
|
const map = {}
|
||||||
base.forEach((it) => (map[it.id] = it))
|
base.forEach((it) => (map[it.id] = it))
|
||||||
|
|||||||
Reference in New Issue
Block a user