Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 932b4e35a7 | |||
| 257d50f9c5 | |||
| 6a8d4ddabd | |||
| 75a3822502 | |||
| 337bee8900 | |||
| 28fa7bb37d | |||
| d089ba99e9 | |||
| 2923237813 | |||
| fd3e983cdc | |||
| 04ac5c6ede | |||
| 79a187d120 | |||
| 0a87e3b1ec | |||
| 136db137ec | |||
| 1fabf66f04 | |||
| 9b97a7c23b | |||
| 9b0a6d8f15 | |||
| 5af5202455 |
@@ -7,7 +7,7 @@ const FileStoreFactory = require('session-file-store')
|
||||
|
||||
const { ensureData } = require('./src/db')
|
||||
const authRoutes = require('./src/routes/auth')
|
||||
const gamesRoutes = require('./src/routes/games')
|
||||
const topicsRoutes = require('./src/routes/topics')
|
||||
const tierListsRoutes = require('./src/routes/tierlists')
|
||||
const adminRoutes = require('./src/routes/admin')
|
||||
|
||||
@@ -74,12 +74,14 @@ app.use(async (req, res, next) => {
|
||||
await ensureData()
|
||||
next()
|
||||
} catch (e) {
|
||||
console.error('[backend] db init failed', e)
|
||||
res.status(500).json({ error: 'db_init_failed' })
|
||||
}
|
||||
})
|
||||
|
||||
app.use('/api/auth', authRoutes)
|
||||
app.use('/api/games', gamesRoutes)
|
||||
app.use('/api/games', topicsRoutes)
|
||||
app.use('/api/topics', topicsRoutes)
|
||||
app.use('/api/tierlists', tierListsRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,23 +7,24 @@ const { z } = require('zod')
|
||||
const { nanoid } = require('nanoid')
|
||||
const {
|
||||
findUserById,
|
||||
findGameById,
|
||||
findGameItemById,
|
||||
listGameItems,
|
||||
findTopicById,
|
||||
findTopicItemById,
|
||||
listTopicItems,
|
||||
findImageAssetById,
|
||||
createGame,
|
||||
listGames,
|
||||
updateGameThumbnail,
|
||||
updateGameVisibility,
|
||||
createGameItem,
|
||||
updateGameItemLabel,
|
||||
updateGameItemDisplayOrder,
|
||||
createTopic,
|
||||
listTopics,
|
||||
updateTopicThumbnail,
|
||||
updateTopicVisibility,
|
||||
createTopicItem,
|
||||
updateTopicItemLabel,
|
||||
updateTopicItemDisplayOrder,
|
||||
countTierListsUsingTopicItem,
|
||||
updateCustomItemLabel,
|
||||
updateImageAssetLabel,
|
||||
deleteGameItem,
|
||||
deleteGame,
|
||||
deleteTopicItem,
|
||||
deleteTopic,
|
||||
deleteTierList,
|
||||
updateGameDisplayOrder,
|
||||
updateTopicDisplayOrder,
|
||||
listCustomItems,
|
||||
findCustomItemById,
|
||||
findUnusedCustomItems,
|
||||
@@ -38,7 +39,7 @@ const {
|
||||
listAdminTemplateRequests,
|
||||
findTemplateRequestById,
|
||||
updateTemplateRequestStatus,
|
||||
updateTemplateRequestTargetGame,
|
||||
updateTemplateRequestTargetTopic,
|
||||
adminUpdateUser,
|
||||
adminUpdateUserPassword,
|
||||
adminDeleteUser,
|
||||
@@ -54,6 +55,10 @@ const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function getTemplateIdParam(req) {
|
||||
return req.params.templateId || req.params.gameId || ''
|
||||
}
|
||||
|
||||
function buildUploadFilename(file) {
|
||||
const ext = path.extname(file.originalname || '').toLowerCase()
|
||||
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
|
||||
@@ -110,7 +115,7 @@ function canManageAdminRole(actingUser, primaryAdmin) {
|
||||
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
|
||||
}
|
||||
|
||||
router.post('/games', requireAdmin, async (req, res) => {
|
||||
router.post(['/games', '/templates'], requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(60),
|
||||
@@ -119,62 +124,66 @@ router.post('/games', requireAdmin, async (req, res) => {
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
const exists = await findGameById(parsed.data.id)
|
||||
const exists = await findTopicById(parsed.data.id)
|
||||
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
||||
const game = await createGame({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
|
||||
const template = await createTopic({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
|
||||
if (parsed.data.thumbnailSrc) {
|
||||
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
|
||||
await updateGameThumbnail(game.id, copiedThumb)
|
||||
await updateTopicThumbnail(template.id, copiedThumb)
|
||||
}
|
||||
res.json({ game: await findGameById(game.id) })
|
||||
const savedTemplate = await findTopicById(template.id)
|
||||
res.json({ template: savedTemplate })
|
||||
})
|
||||
|
||||
router.patch('/games/:gameId', requireAdmin, async (req, res) => {
|
||||
router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
isPublic: z.boolean(),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const template = await findTopicById(templateId)
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const updated = await updateGameVisibility(game.id, parsed.data.isPublic)
|
||||
res.json({ game: updated })
|
||||
const updated = await updateTopicVisibility(template.id, parsed.data.isPublic)
|
||||
res.json({ template: updated })
|
||||
})
|
||||
|
||||
router.patch('/games/display-order', requireAdmin, async (req, res) => {
|
||||
router.patch(['/games/display-order', '/templates/display-order'], requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameIds: z.array(z.string().min(1)).max(50),
|
||||
topicIds: z.array(z.string().min(1)).max(50),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const games = await listGames('', { includePrivate: true })
|
||||
const validGameIds = new Set(games.map((game) => game.id))
|
||||
const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId))
|
||||
const updatedGames = await updateGameDisplayOrder(filteredIds)
|
||||
res.json({ games: updatedGames })
|
||||
const templates = await listTopics('', { includePrivate: true })
|
||||
const validTopicIds = new Set(templates.map((template) => template.id))
|
||||
const filteredIds = parsed.data.topicIds.filter((topicId) => validTopicIds.has(topicId))
|
||||
const updatedTemplates = await updateTopicDisplayOrder(filteredIds)
|
||||
res.json({ templates: updatedTemplates })
|
||||
})
|
||||
|
||||
router.patch('/games/:gameId/items/display-order', requireAdmin, async (req, res) => {
|
||||
router.patch(['/games/:gameId/items/display-order', '/templates/:templateId/items/display-order'], requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
itemIds: z.array(z.string().min(1)).min(1),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const template = await findTopicById(templateId)
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const items = await updateGameItemDisplayOrder(game.id, parsed.data.itemIds)
|
||||
const items = await updateTopicItemDisplayOrder(template.id, parsed.data.itemIds)
|
||||
res.json({ items })
|
||||
})
|
||||
|
||||
router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
|
||||
router.post(['/games/:gameId/thumbnail', '/templates/:templateId/thumbnail'], requireAdmin, upload.single('thumbnail'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const template = await findTopicById(templateId)
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const optimized = await writeOptimizedImage({
|
||||
file: req.file,
|
||||
@@ -185,15 +194,16 @@ router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail')
|
||||
quality: 84,
|
||||
})
|
||||
|
||||
const updated = await updateGameThumbnail(req.params.gameId, optimized.src)
|
||||
res.json({ game: updated })
|
||||
const updated = await updateTopicThumbnail(templateId, optimized.src)
|
||||
res.json({ template: updated })
|
||||
})
|
||||
|
||||
router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), async (req, res) => {
|
||||
router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireAdmin, upload.array('images', 50), async (req, res) => {
|
||||
const files = Array.isArray(req.files) ? req.files : []
|
||||
if (!files.length) return res.status(400).json({ error: 'file_required' })
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const template = await findTopicById(templateId)
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const labelsRaw = req.body?.labels
|
||||
const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : []
|
||||
@@ -211,9 +221,9 @@ router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), a
|
||||
quality: 84,
|
||||
})
|
||||
|
||||
return createGameItem({
|
||||
return createTopicItem({
|
||||
id: nanoid(),
|
||||
gameId: game.id,
|
||||
topicId: template.id,
|
||||
src: optimized.src,
|
||||
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
|
||||
})
|
||||
@@ -223,30 +233,40 @@ router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), a
|
||||
res.json({ item: items[0], items })
|
||||
})
|
||||
|
||||
router.delete('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteGameItem(req.params.itemId)
|
||||
router.delete(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
|
||||
const template = await findTopicById(getTemplateIdParam(req))
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteTopicItem(req.params.itemId)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
router.patch('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
router.get(['/games/:gameId/items/:itemId/usage', '/templates/:templateId/items/:itemId/usage'], requireAdmin, async (req, res) => {
|
||||
const template = await findTopicById(getTemplateIdParam(req))
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
const item = await findTopicItemById(req.params.itemId)
|
||||
if (!item || item.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
|
||||
const usage = await countTierListsUsingTopicItem(req.params.itemId)
|
||||
res.json({ usage })
|
||||
})
|
||||
|
||||
router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
|
||||
const schema = z.object({ label: z.string().trim().min(1).max(60) })
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
const template = await findTopicById(getTemplateIdParam(req))
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const updated = await updateGameItemLabel(req.params.itemId, parsed.data.label)
|
||||
if (!updated || updated.gameId !== game.id) return res.status(404).json({ error: 'not_found' })
|
||||
const updated = await updateTopicItemLabel(req.params.itemId, parsed.data.label)
|
||||
if (!updated || updated.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
|
||||
res.json({ item: updated })
|
||||
})
|
||||
|
||||
router.delete('/games/:gameId', requireAdmin, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteGame(req.params.gameId)
|
||||
router.delete(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const template = await findTopicById(templateId)
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteTopic(templateId)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
@@ -266,7 +286,7 @@ router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
if (parsed.data.sourceType === 'template') {
|
||||
const updated = await updateGameItemLabel(itemId, parsed.data.label)
|
||||
const updated = await updateTopicItemLabel(itemId, parsed.data.label)
|
||||
if (!updated) return res.status(404).json({ error: 'not_found' })
|
||||
return res.json({ item: updated })
|
||||
}
|
||||
@@ -298,7 +318,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
gameId: z.string().trim().max(120).optional().default(''),
|
||||
topicId: z.string().trim().max(120).optional().default(''),
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||
})
|
||||
@@ -307,7 +327,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
|
||||
const result = await listAdminTierLists({
|
||||
queryText: parsed.data.q,
|
||||
gameId: parsed.data.gameId,
|
||||
topicId: parsed.data.topicId,
|
||||
page: parsed.data.page,
|
||||
limit: parsed.data.limit,
|
||||
currentUserId: req.session?.userId || '',
|
||||
@@ -318,14 +338,14 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
router.get('/tierlists/stats', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
gameId: z.string().trim().max(120).optional().default(''),
|
||||
topicId: z.string().trim().max(120).optional().default(''),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const result = await summarizeAdminTierLists({
|
||||
queryText: parsed.data.q,
|
||||
gameId: parsed.data.gameId,
|
||||
topicId: parsed.data.topicId,
|
||||
})
|
||||
res.json(result)
|
||||
})
|
||||
@@ -440,10 +460,10 @@ async function removeCustomItemFiles(items) {
|
||||
)
|
||||
}
|
||||
|
||||
async function promoteLibraryItemToGameItem({ item, gameId }) {
|
||||
return createGameItem({
|
||||
async function promoteLibraryItemToTemplateItem({ item, templateId }) {
|
||||
return createTopicItem({
|
||||
id: nanoid(),
|
||||
gameId,
|
||||
topicId: templateId,
|
||||
src: item.src || '',
|
||||
label: item.label,
|
||||
})
|
||||
@@ -480,7 +500,7 @@ function uniqueTierListPoolItems(tierList) {
|
||||
})
|
||||
}
|
||||
|
||||
async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
|
||||
async function promoteTierListItemsToTemplate({ tierList, templateId, itemIds = [] }) {
|
||||
const allowedIds = new Set((itemIds || []).filter(Boolean))
|
||||
const sourceItems = uniqueTierListPoolItems(tierList).filter((item) => item.origin === 'custom')
|
||||
const itemsToCopy = allowedIds.size ? sourceItems.filter((item) => allowedIds.has(item.id)) : sourceItems
|
||||
@@ -489,9 +509,9 @@ async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
|
||||
for (const item of itemsToCopy) {
|
||||
const copiedSrc = await copyUploadIntoGameAsset(item.src)
|
||||
createdItems.push(
|
||||
await createGameItem({
|
||||
await createTopicItem({
|
||||
id: nanoid(),
|
||||
gameId,
|
||||
topicId: templateId,
|
||||
src: copiedSrc,
|
||||
label: item.label,
|
||||
})
|
||||
@@ -501,8 +521,8 @@ async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
|
||||
return createdItems
|
||||
}
|
||||
|
||||
async function promoteSnapshotItemsToGame({ items, gameId }) {
|
||||
const existingItems = await listGameItems(gameId)
|
||||
async function promoteSnapshotItemsToTemplate({ items, templateId }) {
|
||||
const existingItems = await listTopicItems(templateId)
|
||||
const existingSrcs = new Set(
|
||||
existingItems
|
||||
.map((item) => (typeof item?.src === 'string' ? item.src.trim() : ''))
|
||||
@@ -514,9 +534,9 @@ async function promoteSnapshotItemsToGame({ items, gameId }) {
|
||||
const copiedSrc = await copyUploadIntoGameAsset(item.src)
|
||||
if (!copiedSrc || existingSrcs.has(copiedSrc)) continue
|
||||
createdItems.push(
|
||||
await createGameItem({
|
||||
await createTopicItem({
|
||||
id: nanoid(),
|
||||
gameId,
|
||||
topicId: templateId,
|
||||
src: copiedSrc,
|
||||
label: item.label,
|
||||
})
|
||||
@@ -553,43 +573,43 @@ function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}
|
||||
})
|
||||
}
|
||||
|
||||
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
|
||||
await createGame({ id: gameId, name: gameName, isPublic: false })
|
||||
async function createTemplateFromTierList({ tierList, templateId, templateName }) {
|
||||
await createTopic({ id: templateId, name: templateName, isPublic: false })
|
||||
if (tierList.thumbnailSrc) {
|
||||
const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc)
|
||||
await updateGameThumbnail(gameId, copiedThumb)
|
||||
await updateTopicThumbnail(templateId, copiedThumb)
|
||||
}
|
||||
|
||||
const createdItems = []
|
||||
for (const item of uniqueTierListPoolItems(tierList)) {
|
||||
const copiedSrc = await copyUploadIntoGameAsset(item.src)
|
||||
createdItems.push(
|
||||
await createGameItem({
|
||||
await createTopicItem({
|
||||
id: nanoid(),
|
||||
gameId,
|
||||
topicId: templateId,
|
||||
src: copiedSrc,
|
||||
label: item.label,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return { game: await findGameById(gameId), items: createdItems }
|
||||
return { game: await findTopicById(templateId), items: createdItems }
|
||||
}
|
||||
|
||||
async function createGameTemplateFromRequest({ templateRequest, gameId, gameName }) {
|
||||
await createGame({ id: gameId, name: gameName, isPublic: false })
|
||||
async function createTemplateFromRequest({ templateRequest, templateId, templateName }) {
|
||||
await createTopic({ id: templateId, name: templateName, isPublic: false })
|
||||
|
||||
if (templateRequest.thumbnailSrc) {
|
||||
const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc)
|
||||
await updateGameThumbnail(gameId, copiedThumb)
|
||||
await updateTopicThumbnail(templateId, copiedThumb)
|
||||
}
|
||||
|
||||
const items = await promoteSnapshotItemsToGame({
|
||||
const items = await promoteSnapshotItemsToTemplate({
|
||||
items: templateRequest.items || [],
|
||||
gameId,
|
||||
templateId,
|
||||
})
|
||||
|
||||
return { game: await findGameById(gameId), items }
|
||||
return { game: await findTopicById(templateId), items }
|
||||
}
|
||||
|
||||
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
@@ -606,7 +626,7 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
return res.json({ ok: true, sourceType: 'template-asset' })
|
||||
}
|
||||
|
||||
await deleteGameItem(target.id)
|
||||
await deleteTopicItem(target.id)
|
||||
return res.json({ ok: true, sourceType: 'template' })
|
||||
}
|
||||
|
||||
@@ -622,21 +642,21 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
|
||||
router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameId: z.string().min(1),
|
||||
topicId: z.string().min(1),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const game = await findGameById(parsed.data.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||
const template = await findTopicById(parsed.data.topicId)
|
||||
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
||||
|
||||
const customItem = await findCustomItemById(req.params.itemId)
|
||||
const gameItem = customItem ? null : await findGameItemById(req.params.itemId)
|
||||
const templateItem = customItem ? null : await findTopicItemById(req.params.itemId)
|
||||
const assetItemId = String(req.params.itemId || '')
|
||||
const imageAsset = !customItem && !gameItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null
|
||||
const imageAsset = !customItem && !templateItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null
|
||||
const sourceItem =
|
||||
customItem ||
|
||||
gameItem ||
|
||||
templateItem ||
|
||||
(imageAsset
|
||||
? {
|
||||
src: imageAsset.src || '',
|
||||
@@ -645,27 +665,27 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
|
||||
: null)
|
||||
if (!sourceItem) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const item = await promoteLibraryItemToGameItem({ item: sourceItem, gameId: game.id })
|
||||
const item = await promoteLibraryItemToTemplateItem({ item: sourceItem, templateId: template.id })
|
||||
res.json({ item })
|
||||
})
|
||||
|
||||
router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameId: z.string().min(1),
|
||||
topicId: z.string().min(1),
|
||||
itemIds: z.array(z.string().min(1)).optional().default([]),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const game = await findGameById(parsed.data.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||
const template = await findTopicById(parsed.data.topicId)
|
||||
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
||||
|
||||
const tierList = await findTierListById(req.params.tierListId)
|
||||
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const items = await promoteTierListItemsToGame({
|
||||
const items = await promoteTierListItemsToTemplate({
|
||||
tierList,
|
||||
gameId: game.id,
|
||||
templateId: template.id,
|
||||
itemIds: parsed.data.itemIds,
|
||||
})
|
||||
res.json({ items })
|
||||
@@ -673,26 +693,26 @@ router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, re
|
||||
|
||||
router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameId: z.string().trim().min(1).max(120),
|
||||
topicId: z.string().trim().min(1).max(120),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
itemIds: z.array(z.string().min(1)).optional().default([]),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const exists = await findGameById(parsed.data.gameId)
|
||||
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
||||
const exists = await findTopicById(parsed.data.topicId)
|
||||
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
|
||||
|
||||
const tierList = await findTierListById(req.params.tierListId)
|
||||
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const result = await createGameTemplateFromTierList({
|
||||
const result = await createTemplateFromTierList({
|
||||
tierList: {
|
||||
...tierList,
|
||||
pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool,
|
||||
},
|
||||
gameId: parsed.data.gameId,
|
||||
gameName: parsed.data.name,
|
||||
templateId: parsed.data.topicId,
|
||||
templateName: parsed.data.name,
|
||||
})
|
||||
res.json(result)
|
||||
})
|
||||
@@ -731,32 +751,32 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r
|
||||
if (templateRequest.status !== 'pending') return res.status(409).json({ error: 'request_already_handled' })
|
||||
|
||||
if (templateRequest.type === 'update') {
|
||||
const targetGameId = templateRequest.targetGameId || templateRequest.sourceGameId
|
||||
const game = await findGameById(targetGameId)
|
||||
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||
const targetTopicId = templateRequest.targetTopicId || templateRequest.sourceTopicId
|
||||
const template = await findTopicById(targetTopicId)
|
||||
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
||||
|
||||
const items = await promoteSnapshotItemsToGame({
|
||||
const items = await promoteSnapshotItemsToTemplate({
|
||||
items: templateRequest.items || [],
|
||||
gameId: game.id,
|
||||
templateId: template.id,
|
||||
})
|
||||
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
|
||||
return res.json({ request, items })
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
gameId: z.string().trim().min(1).max(120),
|
||||
topicId: z.string().trim().min(1).max(120),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const exists = await findGameById(parsed.data.gameId)
|
||||
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
||||
const exists = await findTopicById(parsed.data.topicId)
|
||||
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
|
||||
|
||||
const result = await createGameTemplateFromRequest({
|
||||
const result = await createTemplateFromRequest({
|
||||
templateRequest,
|
||||
gameId: parsed.data.gameId,
|
||||
gameName: parsed.data.name,
|
||||
templateId: parsed.data.topicId,
|
||||
templateName: parsed.data.name,
|
||||
})
|
||||
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
|
||||
res.json({ request, ...result })
|
||||
@@ -769,10 +789,10 @@ router.post('/template-requests/:requestId/review', requireAdmin, async (req, re
|
||||
return res.status(409).json({ error: 'request_already_handled' })
|
||||
}
|
||||
|
||||
if (templateRequest.type === 'create' && templateRequest.targetGameId && !templateRequest.targetGameName) {
|
||||
templateRequest = await updateTemplateRequestTargetGame({
|
||||
if (templateRequest.type === 'create' && templateRequest.targetTopicId && !templateRequest.targetTopicName) {
|
||||
templateRequest = await updateTemplateRequestTargetTopic({
|
||||
id: templateRequest.id,
|
||||
targetGameId: '',
|
||||
targetTopicId: '',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -786,7 +806,7 @@ router.post('/template-requests/:requestId/review', requireAdmin, async (req, re
|
||||
|
||||
router.post('/template-requests/:requestId/link-game', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameId: z.string().trim().min(1).max(120),
|
||||
topicId: z.string().trim().min(1).max(120),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -798,19 +818,19 @@ router.post('/template-requests/:requestId/link-game', requireAdmin, async (req,
|
||||
return res.status(409).json({ error: 'request_already_handled' })
|
||||
}
|
||||
|
||||
const game = await findGameById(parsed.data.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||
const template = await findTopicById(parsed.data.topicId)
|
||||
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
||||
|
||||
const request = await updateTemplateRequestTargetGame({
|
||||
const request = await updateTemplateRequestTargetTopic({
|
||||
id: templateRequest.id,
|
||||
targetGameId: game.id,
|
||||
targetTopicId: template.id,
|
||||
})
|
||||
res.json({ request })
|
||||
})
|
||||
|
||||
router.post('/template-requests/:requestId/promote-items', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameId: z.string().trim().min(1).max(120),
|
||||
topicId: z.string().trim().min(1).max(120),
|
||||
itemIds: z.array(z.string().min(1)).optional().default([]),
|
||||
itemSrcs: z.array(z.string().min(1)).optional().default([]),
|
||||
itemLabels: z.record(z.string(), z.string().min(1).max(60)).optional().default({}),
|
||||
@@ -824,8 +844,8 @@ router.post('/template-requests/:requestId/promote-items', requireAdmin, async (
|
||||
return res.status(409).json({ error: 'request_already_handled' })
|
||||
}
|
||||
|
||||
const game = await findGameById(parsed.data.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||
const template = await findTopicById(parsed.data.topicId)
|
||||
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
||||
|
||||
const promotableItems = pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels, parsed.data.itemSrcs)
|
||||
if (!promotableItems.length) {
|
||||
@@ -834,14 +854,14 @@ router.post('/template-requests/:requestId/promote-items', requireAdmin, async (
|
||||
|
||||
let items = []
|
||||
try {
|
||||
items = await promoteSnapshotItemsToGame({
|
||||
items = await promoteSnapshotItemsToTemplate({
|
||||
items: promotableItems,
|
||||
gameId: game.id,
|
||||
templateId: template.id,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[admin] template request promote-items failed', {
|
||||
requestId: templateRequest.id,
|
||||
gameId: game.id,
|
||||
topicId: template.id,
|
||||
itemCount: promotableItems.length,
|
||||
message: error?.message || 'unknown_error',
|
||||
code: error?.code || '',
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
const express = require('express')
|
||||
const { listGames, getGameDetail, findGameById, favoriteGame, unfavoriteGame } = require('../db')
|
||||
const { requireAuth } = require('../middleware/auth')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const games = await listGames(req.session?.userId || '', { includePrivate: !!req.session?.isAdmin })
|
||||
res.json({ games })
|
||||
})
|
||||
|
||||
router.post('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' })
|
||||
await favoriteGame({ userId: req.session.userId, gameId: game.id })
|
||||
const games = await listGames(req.session.userId)
|
||||
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: true }
|
||||
res.json({ game: updated })
|
||||
})
|
||||
|
||||
router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' })
|
||||
await unfavoriteGame({ userId: req.session.userId, gameId: game.id })
|
||||
const games = await listGames(req.session.userId)
|
||||
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: false }
|
||||
res.json({ game: updated })
|
||||
})
|
||||
|
||||
router.get('/:gameId', async (req, res) => {
|
||||
const detail = await getGameDetail(req.params.gameId)
|
||||
if (!detail) return res.status(404).json({ error: 'not_found' })
|
||||
if (!detail.game.isPublic && !req.session?.isAdmin) return res.status(404).json({ error: 'not_found' })
|
||||
res.json({ game: detail.game, items: detail.items })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -20,7 +20,7 @@ const { requireAuth } = require('../middleware/auth')
|
||||
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
||||
|
||||
const router = express.Router()
|
||||
const FREEFORM_GAME_ID = 'freeform'
|
||||
const FREEFORM_TOPIC_ID = 'freeform'
|
||||
const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기'
|
||||
|
||||
function normalizePoolItem(item) {
|
||||
@@ -61,7 +61,7 @@ const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 }
|
||||
const templateRequestSchema = z.object({
|
||||
type: z.enum(['create', 'update']),
|
||||
sourceTierListId: z.string().max(64).optional().default(''),
|
||||
gameId: z.string().min(1).max(120),
|
||||
topicId: z.string().min(1).max(120).optional(),
|
||||
requestTitle: z.string().trim().min(1).max(120),
|
||||
requestDescription: z.string().trim().min(1).max(1000),
|
||||
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||
@@ -72,7 +72,11 @@ const templateRequestSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(16),
|
||||
itemIds: z.array(z.string()).optional().default([]),
|
||||
}).passthrough()
|
||||
}).passthrough().superRefine((value, ctx) => {
|
||||
if (!value.topicId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['topicId'] })
|
||||
}
|
||||
})
|
||||
),
|
||||
boardItems: z.array(
|
||||
z.object({
|
||||
@@ -86,7 +90,7 @@ const templateRequestSchema = z.object({
|
||||
|
||||
const tierListUpsertSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
gameId: z.string().min(1),
|
||||
topicId: z.string().min(1).optional(),
|
||||
title: z.string().min(1).max(120),
|
||||
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||
description: z.string().max(1000).optional().default(''),
|
||||
@@ -111,12 +115,16 @@ const tierListUpsertSchema = z.object({
|
||||
origin: z.enum(['game', 'custom']).default('game'),
|
||||
})
|
||||
),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (!value.topicId) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['topicId'] })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/public', async (req, res) => {
|
||||
const gameId = req.query.gameId
|
||||
const topicId = typeof req.query.topicId === 'string' ? req.query.topicId : ''
|
||||
const queryText = typeof req.query.q === 'string' ? req.query.q : ''
|
||||
const lists = await listPublicTierLists(gameId, req.session?.userId || '', queryText)
|
||||
const lists = await listPublicTierLists(topicId, req.session?.userId || '', queryText)
|
||||
res.json({ tierLists: lists })
|
||||
})
|
||||
|
||||
@@ -226,14 +234,15 @@ router.post('/template-request', requireAuth, async (req, res) => {
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const payload = parsed.data
|
||||
const topicId = payload.topicId
|
||||
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
|
||||
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
|
||||
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
|
||||
|
||||
if (payload.type === 'create') {
|
||||
if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||
} else if (payload.gameId === FREEFORM_GAME_ID) {
|
||||
return res.status(400).json({ error: 'game_template_required' })
|
||||
if (topicId !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||
} else if (topicId === FREEFORM_TOPIC_ID) {
|
||||
return res.status(400).json({ error: 'topic_template_required' })
|
||||
}
|
||||
|
||||
let sourceTierList = null
|
||||
@@ -251,8 +260,8 @@ router.post('/template-request', requireAuth, async (req, res) => {
|
||||
type: payload.type,
|
||||
requesterId: req.session.userId,
|
||||
sourceTierListId: sourceTierList?.id || '',
|
||||
sourceGameId: payload.gameId,
|
||||
targetGameId: payload.type === 'update' ? payload.gameId : '',
|
||||
sourceTopicId: topicId,
|
||||
targetTopicId: payload.type === 'update' ? topicId : '',
|
||||
title: payload.requestTitle,
|
||||
description: payload.requestDescription,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
@@ -274,6 +283,7 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
const parsed = tierListUpsertSchema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
const payload = parsed.data
|
||||
const topicId = payload.topicId
|
||||
const normalizedPool = payload.pool.map(normalizePoolItem)
|
||||
|
||||
let existing = null
|
||||
@@ -284,7 +294,7 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
const updated = await saveTierList({
|
||||
id: existing.id,
|
||||
authorId: existing.authorId,
|
||||
gameId: existing.gameId,
|
||||
topicId: existing.topicId,
|
||||
title: payload.title,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
description: payload.description || '',
|
||||
@@ -303,7 +313,7 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
const created = await saveTierList({
|
||||
id: nanoid(),
|
||||
authorId: req.session.userId,
|
||||
gameId: payload.gameId,
|
||||
topicId,
|
||||
title: payload.title,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
description: payload.description || '',
|
||||
|
||||
37
backend/src/routes/topics.js
Normal file
37
backend/src/routes/topics.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const express = require('express')
|
||||
const { listTopics, getTopicDetail, findTopicById, favoriteTopic, unfavoriteTopic } = require('../db')
|
||||
const { requireAuth } = require('../middleware/auth')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const topics = await listTopics(req.session?.userId || '', { includePrivate: !!req.session?.isAdmin })
|
||||
res.json({ topics })
|
||||
})
|
||||
|
||||
router.post('/:topicId/favorite', requireAuth, async (req, res) => {
|
||||
const topic = await findTopicById(req.params.topicId)
|
||||
if (!topic || topic.id === 'freeform') return res.status(404).json({ error: 'not_found' })
|
||||
await favoriteTopic({ userId: req.session.userId, topicId: topic.id })
|
||||
const topics = await listTopics(req.session.userId)
|
||||
const updated = topics.find((entry) => entry.id === topic.id) || { ...topic, isFavorited: true }
|
||||
res.json({ topic: updated })
|
||||
})
|
||||
|
||||
router.delete('/:topicId/favorite', requireAuth, async (req, res) => {
|
||||
const topic = await findTopicById(req.params.topicId)
|
||||
if (!topic || topic.id === 'freeform') return res.status(404).json({ error: 'not_found' })
|
||||
await unfavoriteTopic({ userId: req.session.userId, topicId: topic.id })
|
||||
const topics = await listTopics(req.session.userId)
|
||||
const updated = topics.find((entry) => entry.id === topic.id) || { ...topic, isFavorited: false }
|
||||
res.json({ topic: updated })
|
||||
})
|
||||
|
||||
router.get('/:topicId', async (req, res) => {
|
||||
const detail = await getTopicDetail(req.params.topicId)
|
||||
if (!detail) return res.status(404).json({ error: 'not_found' })
|
||||
if (!detail.topic.isPublic && !req.session?.isAdmin) return res.status(404).json({ error: 'not_found' })
|
||||
res.json({ topic: detail.topic, items: detail.items })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -1,5 +1,67 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-02 v1.4.25
|
||||
- 이제 프런트와 백엔드 소비층이 `topic/template`를 기본으로 읽을 준비가 되었으므로, 응답과 payload에 `gameId / gameName` 호환 키를 오래 남기는 것보다 실제 표면을 먼저 정리하는 편이 더 낫다고 판단했다.
|
||||
- 다만 오래된 외부 링크까지 한 번에 끊는 건 위험하므로, 이번 단계에서는 데이터/응답/프런트 소비는 `topic`으로 마감하되 `/games/:gameId`와 관리자 route alias 같은 레거시 주소만 마지막 호환 레이어로 남기는 점진 종료가 가장 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.24
|
||||
- `topic/template` 소비층이 이미 정리된 상태라면, 공개 주제 API와 관리자 템플릿 API 응답도 이제는 `game` 키를 기본으로 유지할 이유가 크지 않으므로 새 의미 키만 기본으로 내보내는 편이 맞다고 판단했다.
|
||||
- 다만 관리자 화면 내부 상태 구조를 한 번에 뒤집는 건 위험하므로, 응답은 줄이되 `selectedTemplate.game`처럼 화면 구조에 깊게 퍼진 부분은 프런트에서 한 번 정규화해 받는 점진 방식이 가장 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.23
|
||||
- 프런트가 이미 `topic/template` 메서드만 실제로 쓰고 있다면, `api.js` 안에 남은 레거시 `game` 별칭까지 계속 유지하는 건 오히려 정리 상태를 흐리므로 이 단계에서 정리하는 편이 맞다고 판단했다.
|
||||
- 티어표 저장과 템플릿 요청처럼 핵심 생성 흐름은 백엔드 내부 payload도 먼저 `topicId` 기준으로 맞춰 두는 편이, 이후 응답 호환 키를 걷어낼 때 충격을 더 줄인다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.22
|
||||
- 내부 함수명과 export를 정리한 뒤에도 라우트 파일명이 계속 `games.js`로 남아 있으면 마지막까지 개념 충돌을 남기게 되므로, 공개 주제 라우트 파일명도 실제 의미에 맞게 `topics.js`로 옮기는 편이 맞다고 판단했다.
|
||||
- `/api/games` 호환 경로는 유지하더라도, 서버 내부 구현만큼은 `topic` 기준 param 이름과 파일 이름으로 정리해 두는 편이 이후 레거시 제거를 훨씬 더 쉽게 만든다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.21
|
||||
- 백엔드에서 `topic/template` 응답을 내보내더라도 프런트가 계속 `game` 키만 읽으면 호환 레이어가 끝나지 않으므로, 이번 단계부터는 실제 사용자 화면과 관리자 저장 흐름도 새 키를 우선 읽게 맞추는 편이 맞다고 판단했다.
|
||||
- 이 구간은 외부 API를 끊는 작업이 아니라 “프런트가 새 의미를 먼저 받아들이는 단계”이므로, 기존 `game` 키는 fallback으로만 남겨 두고 단계적으로 걷어내는 편이 가장 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.20
|
||||
- 스키마만 `topic`으로 옮기고 함수명/라우트 내부가 계속 `game`으로 남아 있으면 이후 유지보수에서 계속 의미 충돌이 생기므로, 이번 단계부터는 백엔드 export와 주요 라우트 내부 이름도 `topic/template`를 기본으로 읽히게 정리하는 편이 맞다고 판단했다.
|
||||
- 다만 외부 API와 프런트 호환을 한 번에 끊는 건 위험하므로, 실제 구현은 새 `topic` 이름을 기본으로 쓰되 기존 `game` 이름은 alias와 호환 응답으로 잠시 유지하는 점진 전환이 가장 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.19
|
||||
- 템플릿 기본 아이템 삭제가 과거에 저장된 티어표까지 바꿔 버리면 운영자 편집 의도보다 사용자 결과물 보존이 더 크게 흔들리므로, 이 삭제는 “앞으로의 템플릿 구성만 바꾸고 기존 저장본은 보존”하는 편이 맞다고 판단했다.
|
||||
- 다만 이미 여러 티어표에서 쓰인 아이템인지 모른 채 지우게 두는 것도 위험하므로, 삭제 자체를 막기보다는 저장된 티어표 사용 개수와 공개/비공개 범위를 먼저 보여주고 운영자가 맥락을 알고 결정하게 하는 쪽이 더 현실적이라고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.18
|
||||
- 새 창 열기처럼 브라우저 기본 앵커가 충분한 동작은 템플릿 안에서 `window.open`을 직접 부르기보다, 기본 링크 동작에 맡기는 편이 더 단순하고 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.17
|
||||
- `editor` 주소는 이전과 현재가 같은 URL 형태를 공유하므로, 여기까지 redirect를 두면 호환성이 아니라 자기 자신으로의 재해석만 반복하게 된다. 이 구간은 별도 레거시 레코드를 두지 않고 현재 라우트 하나로 수용하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.16
|
||||
- 백엔드/DB 장애 상황을 단순 연결 실패처럼 보여주면 사용자가 원인을 잘못 이해하게 되므로, 네트워크 단절과 서버 점검/초기화 실패를 전역 UI에서 분리해서 안내하는 편이 맞다고 판단했다.
|
||||
- 이런 장애 안내는 각 화면별 에러 문구를 따로 손보는 것보다 `api` 공통 계층에서 상태를 감지하고 `App` 셸이 한 번에 전환하는 구조가 재사용성과 유지보수 측면에서 더 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.15
|
||||
- 실제 운영 DB에서 마지막 500 원인을 먼저 재현해본 결과, 스키마 설계보다 MariaDB의 `SHOW ... LIKE ?` 플레이스홀더 비호환과 부분 마이그레이션 상태 재진입 이슈가 핵심이었으므로, 이 단계에선 구조 변경보다 기동 안정성을 먼저 회복하는 편이 맞다고 판단했다.
|
||||
- 마이그레이션 로직은 “처음 실행”뿐 아니라 “반쯤 적용된 상태에서 다시 실행”도 견뎌야 하므로, 컬럼 존재 확인과 조건 분기를 모두 공용 `information_schema` 검사로 모으는 편이 더 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.14
|
||||
- 기존 `/games` 주소 호환은 alias보다 redirect가 더 맞다고 판단했다. 이번 단계에선 주소는 유지하되 라우트 파라미터 의미는 항상 `topicId`로 정규화해 Vue Router 경고와 내부 분기를 함께 줄였다.
|
||||
- 운영 DB에 직접 `RENAME TABLE`과 컬럼 `CHANGE`를 거는 방식은 실제 환경에서 실패 여지가 커서, 마지막 스키마 전환도 새 topic 스키마를 먼저 만들고 기존 game 데이터를 복사하는 비파괴 마이그레이션이 더 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.13
|
||||
- 사용자 표면과 API 이름층까지 `topic/template`로 옮긴 뒤에는, DB 스키마도 실제로 따라오게 해야 이후 유지보수 비용이 덜 쌓이므로 `games` 계열 실명을 `topics` 계열로 마이그레이션하는 편이 맞다고 판단했다.
|
||||
- 다만 한 번에 응답 키까지 완전히 끊으면 프런트와 관리자 흐름이 너무 크게 흔들릴 수 있으므로, 이번 단계에서는 실제 저장 스키마는 `topic`으로 옮기고 응답의 `gameId / gameName`은 호환 키로 잠시 함께 유지하는 점진 마감이 가장 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.12
|
||||
- 프런트 이름만 바꾸는 단계가 끝난 뒤에는, 백엔드도 새 `/api/topics`, `/api/admin/templates` 경로를 열고 기존 `/games`는 호환용으로 남기는 점진 전환이 가장 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.11
|
||||
- 백엔드 `/api/games` 경로를 바로 바꾸기보다, 프런트 API 객체에서 먼저 `topic/template` 의미 이름을 제공하고 호출부를 옮기는 편이 위험이 훨씬 낮다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.10
|
||||
- 사용자 주소는 이미 `/topics`로 옮기기 시작했으므로, 라우트 이름과 기본 파라미터도 `topicHub / topicId` 기준으로 맞추고 기존 `gameId`는 호환 fallback으로만 남기는 편이 더 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.9
|
||||
- 경로 전환은 화면마다 문자열을 직접 고치는 방식보다, 공용 경로 헬퍼를 먼저 세워 주제·에디터·로그인 리다이렉트 흐름을 한 기준으로 묶는 편이 이후 리네이밍 비용을 훨씬 줄인다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.8
|
||||
- 주제 상세 화면 제목은 내부 ID를 잠깐 보여주는 것보다, 로딩 문구를 거쳐 실제 이름으로 자연스럽게 바뀌는 편이 사용자 체감상 더 안정적이라고 판단했다.
|
||||
- 주요 목록 화면은 `pageHead` 문법을 계속 통일해 두는 편이, 이후 검색/필터 툴바를 더 붙이더라도 구조를 예측하기 쉽다고 판단했다.
|
||||
|
||||
32
docs/todo.md
32
docs/todo.md
@@ -1,6 +1,38 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.4.25`에서 티어표/요청 응답의 `gameId / gameName` 호환 키를 실제로 제거했으므로, 브라우저에서 홈 목록, 주제 상세, 저장된 티어표 열기, 즐겨찾기, 검색 결과, 관리자 템플릿 요청/전체 티어표 관리가 모두 정상 동작하는지 한 번 더 QA한다.
|
||||
- `v1.4.25`에서 관리자 route query와 편집기 저장/request payload를 `topicId` 기준으로 옮겼으므로, `/admin/games?topicId=...`, `/admin/tierlists?mode=all&topicId=...`, 티어표 저장, 템플릿 요청, 추가 아이템 가져오기 흐름이 모두 정상인지 확인한다.
|
||||
- 남은 `gameId`는 의도적으로 유지한 레거시 주소 alias(`/games/:gameId`)와 관리자 alias route path뿐이므로, 오래된 외부 링크 진입 후 주소가 새 `topic` 체계로 자연스럽게 정규화되는지만 마지막으로 본다.
|
||||
- `v1.4.24`에서 공개 주제 API와 관리자 템플릿 API의 기본 응답 키를 더 줄였으므로, 실제 브라우저에서 홈 목록, 즐겨찾기 토글, 주제 상세, 티어표 편집기, 관리자 템플릿 공개 전환/생성이 모두 그대로 정상인지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 `mapTierListRow`, `mapTemplateRequestRow`, 관리자 route query, 저장 payload 입력 호환에 남아 있는 `gameId/gameName/sourceGameId/targetGameId`를 끝까지 걷어낼지 최종 결정한다.
|
||||
- `v1.4.23`에서 프런트 `api.js`의 레거시 `game` 별칭 메서드와 티어표 저장/요청 내부 payload를 더 걷어냈으므로, 실제 브라우저에서 저장/복사/템플릿 요청/관리자 요청 카드 표시가 그대로 정상인지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 응답의 `game`, `gameId`, `gameName`, `sourceGameId`, `targetGameId` 호환 키를 실제로 제거할지, 아니면 `v1.4` 마감 후 안정화 기간을 두고 걷어낼지 최종 결정한다.
|
||||
- `v1.4.22`에서 공개 주제 라우트 파일을 `topics.js`로 옮겼으므로, 실제 서버 재기동 후 `/api/topics`와 `/api/games` 호환 경로가 모두 정상 응답하는지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 응답의 `game`, `gameId`, `gameName` 호환 키를 실제로 어느 범위까지 제거할지, 그리고 관리자/티어표 저장 payload에서 남은 `gameId` 입력 호환을 어디까지 유지할지 최종 결정한다.
|
||||
- `v1.4.21`에서 홈/주제 상세/에디터/나의 티어표/즐겨찾기/검색 결과/관리자 템플릿 생성이 `topic/template` 응답 키를 우선 읽도록 바뀌었으므로, 실제 브라우저에서 즐겨찾기 토글과 에디터 이동, 관리자 신규 템플릿 생성이 모두 정상인지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 실제 응답의 `game`, `gameId`, `gameName` 호환 키를 어디까지 남길지, 그리고 `/api/games` 호환 경로와 `games.js` 파일명을 언제 걷어낼지 최종 범위를 정한다.
|
||||
- `v1.4.20`에서 백엔드 `db` export와 공개/관리자 라우트 내부 이름을 `topic/template` 기준으로 정리했으므로, 실제 브라우저와 관리자 화면에서 주제 목록/즐겨찾기/템플릿 생성/요청 반영 흐름이 모두 정상인지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 남아 있는 호환 응답 키 `game`, `gameId`, `gameName`과 레거시 route 파일명 `games.js`를 어디까지 실제 `topic` 이름으로 마감할지 범위를 결정한다.
|
||||
- `v1.4.19`에서 템플릿 기본 아이템 삭제는 기존 저장 티어표를 보존하도록 정책이 바뀌었으므로, 실제 운영 데이터에서 삭제 후 예전 티어표의 배치/대기풀이 그대로 유지되는지와 새 티어표 생성 시에만 아이템이 빠지는지 한 번 더 QA한다.
|
||||
- `v1.4.19`에서 삭제 전 영향 개수 경고를 붙였으므로, 공개/비공개 티어표가 섞인 템플릿에서 숫자가 기대대로 보이는지와 삭제 취소/확정 후 스크롤 위치가 안정적으로 유지되는지 한 번 더 QA한다.
|
||||
- `v1.4.19`에서 템플릿 썸네일 등록 아이콘은 썸네일이 있을 때 숨기도록 정리했으므로, 썸네일 있음/없음 상태 전환과 드래그 오버 활성 상태에서 안내 문구가 겹치지 않는지 한 번 더 QA한다.
|
||||
- `v1.4.18`에서 관리자 템플릿 요청 카드 썸네일 클릭을 브라우저 기본 새 창 열기로 정리했으므로, 요청 썸네일 클릭 시 오류 없이 새 탭이 열리고 `전체 티어표 관리` 썸네일 모달 동작과도 섞이지 않는지 한 번 더 QA한다.
|
||||
- `v1.4.17`에서 주제 컬렉션 카드 클릭 시 에디터 진입 무한 루프를 끊었으므로, 새 티어표 만들기/기존 티어표 열기/공유 링크 열기 세 흐름이 모두 정상 진입하는지 한 번 더 QA한다.
|
||||
- `v1.4.16`에서 장애 전용 안내 화면을 붙였으므로, 실제로 `db_init_failed`와 네트워크 차단 상황에서 각각 `서비스 점검 중`, `서버 연결 확인 중` 화면이 기대대로 분기되는지 한 번 더 QA한다.
|
||||
- `v1.4.15`에서 `ensureData()`가 실제 운영 DB 설정으로 `ok`까지 통과한 것은 확인했으므로, 이제는 브라우저에서 `/api/auth/me`, `/api/auth/meta`, `/api/topics` 500이 실제로 사라졌는지와 기존 세션 로그인 흐름이 복구됐는지 한 번 더 QA한다.
|
||||
- `v1.4.14`부터는 DB 마이그레이션이 rename 대신 복사 기반으로 바뀌었으므로, 실제 운영 DB에서 서버 재시작 후 `topics` 계열 테이블과 `tierlists.topic_id`, `template_requests.source_topic_id/target_topic_id`가 기대대로 채워지는지 먼저 확인한다.
|
||||
- 레거시 `/games/...`와 `/editor/:gameId/...`는 redirect로 남겼으므로, 오래된 북마크 진입 후 주소가 `/topics/...`, `/editor/:topicId/...`로 자연스럽게 정규화되는지 한 번 더 QA한다.
|
||||
- `v1.4.13`부터 DB 실명도 `topics / topic_items / favorite_topics / topic_id` 기준으로 옮겼으므로, 기존 운영 DB에서 서버 재시작 후 자동 마이그레이션이 한 번만 자연스럽게 수행되는지 먼저 확인한다.
|
||||
- 백엔드 응답은 현재 `topicId / topicName`과 `gameId / gameName`을 함께 내려주고 있으므로, 다음 단계에서는 실제 프런트/관리자에서 더 이상 `gameId` fallback이 필요 없는 지점을 확인해 호환 키 제거 순서를 정한다.
|
||||
- 티어표 공개 목록, 관리자 전체 티어표 관리, 저장/요청 API는 `topicId`를 우선 받도록 바꿨으므로, 실제 브라우저에서 검색/저장/공유/관리자 필터가 모두 같은 파라미터 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||
- `/api/topics`, `/api/admin/templates` alias를 연 뒤 프런트 호출도 새 경로로 옮겼으므로, 실제 브라우저에서 주제 목록/즐겨찾기/주제 상세/관리자 템플릿 관리가 모두 같은 세션으로 자연스럽게 동작하는지 한 번 더 QA한다.
|
||||
- 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다.
|
||||
- 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 API 이름층으로 바뀐 뒤에도 저장, 즐겨찾기, 템플릿 생성, 아이템 정렬 흐름이 자연스러운지 한 번 더 QA한다.
|
||||
- `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 `api.getGame`, `listGames`, `favoriteGame`처럼 남아 있는 프런트 API 이름을 어느 수준까지 `topic/template` 의미로 감쌀지 정리한다.
|
||||
- 경로 헬퍼를 사용자 주요 화면에 연결했으므로, 로그인 리다이렉트·공유 프리뷰·복사본 이동·주제 복귀 흐름이 실제 브라우저에서 모두 같은 주소 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||
- 다음 단계에서는 `router/index.js`의 `gameHub`, `route.params.gameId`, `api.getGame`처럼 남아 있는 프런트 내부 이름도 어디까지 `topic/template` 의미로 감쌀지 범위를 더 좁힌다.
|
||||
- 주제 상세 화면 제목은 ID fallback 대신 로딩 문구를 쓰도록 바꿨으므로, 직접 진입·뒤로가기·다른 주제로 연속 이동할 때 헤더가 깜빡이거나 이전 제목을 잠깐 유지하지 않는지 한 번 더 QA한다.
|
||||
- 검색 결과 화면도 `pageHead` 구조로 맞췄으므로, 주요 목록 화면들 간 상단 여백과 타이포 리듬이 자연스러운지 한 번 더 비교 QA한다.
|
||||
- 주제 상세 컬렉션 화면은 `pageHead` 공통 레이아웃과 `/topics` 기본 경로로 옮겼으므로, 직접 진입·뒤로가기·검색 후 재진입 시 주소와 헤더 흐름이 자연스러운지 한 번 더 QA한다.
|
||||
|
||||
@@ -1,5 +1,78 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-02 v1.4.25
|
||||
- 티어표와 템플릿 요청 응답에서 `gameId / gameName / sourceGameId / targetGameId` 호환 키를 실제로 제거하고, 프런트 화면도 `topicId / topicName / sourceTopicId / targetTopicId`만 읽도록 정리했다.
|
||||
- 관리자 전체 티어표 관리와 템플릿 요청 관리, 나의 티어표/즐겨찾기/검색 결과 이동, 티어표 편집기 저장·요청 payload도 `topicId` 기준으로 맞춰, 화면과 요청 바디에서 보이는 `game` 흔적을 더 줄였다.
|
||||
- 관리자 템플릿 정렬 저장과 템플릿 아이템/요청 반영 API body도 `topicIds / topicId` 기준으로 옮겼고, 남은 `gameId`는 이제 레거시 주소 호환용 `/games/:gameId`와 관리자 alias route path 쪽에만 남도록 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.24
|
||||
- 공개 주제 API는 이제 `topics` 목록과 `topic` 상세만 기본 응답으로 내려주고, 즐겨찾기 토글도 `topic`만 반환하도록 정리했다. 관리자 템플릿 생성/공개 상태 저장도 `template`만 기본 응답으로 맞췄다.
|
||||
- 홈, 주제 상세, 티어표 편집기, 관리자 템플릿 관리 화면도 이 변경에 맞춰 `data.topics`, `data.topic`, `data.template`를 직접 읽도록 바꿨다.
|
||||
- 관리자 내부 상태는 `api.getTopic()` 응답을 받아도 `selectedTemplate.game`에 한 번 정규화하도록 보강해, UI 구조를 크게 흔들지 않으면서 응답 호환 키는 더 줄일 수 있게 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.23
|
||||
- 프런트 `api.js`에서 더 이상 쓰지 않는 `listGames / getGame / favoriteGame / updateAdminGame* / listPublicTierLists` 같은 레거시 별칭 메서드를 정리해, 공개/관리자 호출부가 실제로 쓰는 `topic/template` API만 남기도록 정리했다.
|
||||
- 관리자 템플릿 요청 상태와 전체 티어표 관리 카드도 `sourceTopicId / targetTopicId / topicName`을 우선 읽도록 더 당겨, 화면에서 `game` 키를 보는 범위를 줄였다.
|
||||
- 티어표 저장/템플릿 요청 백엔드는 이제 내부적으로 `sourceTopicId / targetTopicId / topicId`만 넘기도록 정리하고, 기존 `sourceGameId / gameId`는 저장 경로에서 한 단계 더 덜어냈다.
|
||||
|
||||
## 2026-04-02 v1.4.22
|
||||
- 백엔드 공개 주제 라우트 파일을 [topics.js](/Users/bicute/Desktop/zenn.dev/tier-cursor/backend/src/routes/topics.js)로 옮기고, 진입점도 이 이름으로 읽히게 정리했다. 이제 서버 코드에서 `games.js` 파일명이 남아 있던 마지막 큰 표면도 실제 의미에 더 가깝게 맞춰졌다.
|
||||
- 공개 주제 라우트의 path 파라미터도 `:topicId` 기준으로 읽히게 바꿔, 내부 구현에서 더 이상 `req.params.gameId`를 기본 전제로 보지 않도록 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.21
|
||||
- 프런트의 실제 소비 지점도 `topic/template` 응답 키를 우선 읽도록 옮겼다. 홈의 즐겨찾기 토글, 주제 상세 헤더, 티어표 편집기 템플릿 로딩, 나의 티어표/즐겨찾기/검색 결과의 에디터 이동이 이제 `topic`, `topicId`, `template`를 먼저 사용한다.
|
||||
- 관리자 템플릿 공개 상태 저장과 신규 템플릿 생성 흐름도 `data.template`를 우선 읽고, 기존 `data.game`은 fallback으로만 남겨 프런트와 백엔드의 의미 이름이 한 단계 더 가까워지게 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.20
|
||||
- 백엔드 `db`와 라우트 내부 이름층을 한 단계 더 `topic` 기준으로 옮겼다. `listTopics / findTopicById / getTopicDetail / createTopic / updateTopicThumbnail / updateTopicVisibility`, `createTopicItem / updateTopicItemLabel / updateTopicItemDisplayOrder / deleteTopicItem / deleteTopic` 같은 이름을 실제 export로 추가하고, 기존 `game` 이름은 호환 alias로만 남겼다.
|
||||
- 공개 주제 라우트는 이제 `listTopics`, `getTopicDetail`, `favoriteTopic` 기준으로 동작하고, 백엔드 진입점도 `gamesRoutes` 대신 `topicsRoutes`라는 이름으로 읽히도록 정리했다.
|
||||
- 관리자 라우트 역시 핵심 템플릿 흐름에서 `findTopicById`, `createTopic`, `createTopicItem`, `promoteSnapshotItemsToTemplate`, `createTemplateFromTierList` 같은 의미 이름을 직접 사용하도록 바꿔, 실제 저장 스키마와 코드 언어가 더 가까워지게 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.19
|
||||
- 관리자 템플릿 기본 아이템 삭제는 이제 기존에 저장된 티어표의 그룹/대기풀 데이터를 건드리지 않고, 템플릿의 현재 기본 아이템 목록에서만 제거되도록 바꿨다. 그래서 이미 만들어진 티어표는 그대로 유지되고, 이후 새로 만드는 티어표에서만 해당 아이템이 빠진다.
|
||||
- 삭제 전에는 이 아이템이 이미 저장된 티어표 몇 개에서 사용 중인지(공개/비공개 포함) 확인 문구를 먼저 보여주도록 바꿔, 운영자가 영향 범위를 알고 삭제할 수 있게 했다.
|
||||
- 템플릿 썸네일이 이미 등록된 상태에서는 등록 아이콘이 겹쳐 보이지 않도록 정리했고, 기본 아이템 삭제 후 템플릿을 다시 불러와도 페이지가 맨 위로 튀지 않게 스크롤 위치를 복원하도록 보강했다.
|
||||
|
||||
## 2026-04-02 v1.4.18
|
||||
- 관리자 템플릿 요청 카드 썸네일 클릭은 `window.open(...)`을 템플릿 이벤트 안에서 직접 호출하던 구조 때문에 브라우저 새 창 열기 시 `Cannot read properties of undefined (reading 'open')`가 날 수 있었고, 이를 제거해 앵커의 기본 새 창 동작만 사용하도록 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.17
|
||||
- 주제 컬렉션에서 티어표 카드를 클릭할 때 `Maximum call stack size exceeded`가 나던 원인은 `editor` 레거시 redirect가 새 라우트와 동일한 URL 패턴을 다시 자기 자신에게 redirect하던 구조였고, 불필요한 `editor` redirect 레코드를 제거해 무한 라우팅 루프를 끊었다.
|
||||
|
||||
## 2026-04-02 v1.4.16
|
||||
- 백엔드나 DB 장애가 났을 때 일반 화면에서 계속 `연결할 수 없어요` 식으로 보이던 흐름을 정리하고, `api` 공통 요청 계층에서 `db_init_failed` 같은 500과 네트워크 실패를 감지해 앱 전체를 점검/연결 확인 화면으로 전환하도록 바꿨다.
|
||||
- 이제 데이터베이스 초기화 실패나 서버 내부 500은 `서비스 점검 중`, 네트워크 단절은 `서버 연결 확인 중`으로 구분되어 보이며, 사용자는 일반 페이지 대신 전용 안내 화면과 다시 시도 버튼을 보게 된다.
|
||||
|
||||
## 2026-04-02 v1.4.15
|
||||
- `db_init_failed`의 직접 원인은 MariaDB에서 `SHOW TABLES LIKE ?`, `SHOW COLUMNS ... LIKE ?` 플레이스홀더를 허용하지 않던 부분이었고, 이를 `information_schema` 조회 기반으로 바꿔 실제 운영 DB에서도 `ensureData()`가 정상 통과되게 고쳤다.
|
||||
- 중간 마이그레이션 상태에서 `template_requests.target_topic_id`가 이미 생긴 DB는 중복 컬럼 추가로 다시 실패할 수 있었으므로, 해당 확인도 `columnExists()` 기준으로 바꿔 부분 적용된 DB까지 안전하게 다시 기동되게 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.14
|
||||
- `/games/:gameId`, `/editor/:gameId/...` 레거시 주소는 Vue Router alias 대신 redirect로 정리해, `topicId` 기준 라우트와 섞일 때 뜨던 param mismatch 경고를 제거했다.
|
||||
- 운영 DB에서 바로 `RENAME/CHANGE`를 치던 초기 마이그레이션은 위험도가 높아, `topics / topic_items / favorite_topics / topic_id` 스키마를 안전하게 만들고 기존 `games` 계열 데이터를 복사해 오는 방식으로 바꿨다.
|
||||
- DB 초기화 실패 시 원인을 바로 확인할 수 있도록 백엔드에서 `db_init_failed` 응답 전에 실제 에러를 서버 로그에 남기도록 보강했다.
|
||||
|
||||
## 2026-04-02 v1.4.13
|
||||
- DB 실명 변경 마지막 단계로 `games / game_items / favorite_games`를 `topics / topic_items / favorite_topics` 기준으로 자동 마이그레이션하도록 정리하고, `tierlists.game_id`, `template_requests.source_game_id/target_game_id`도 각각 `topic_id`, `source_topic_id/target_topic_id`로 옮기게 했다.
|
||||
- 백엔드 저장/조회 쿼리는 이제 새 topic 스키마를 기준으로 동작하고, 응답에는 `topicId / topicName`을 기본으로 내려주되 기존 프런트가 바로 깨지지 않도록 `gameId / gameName`도 잠시 함께 유지했다.
|
||||
- 티어표 공개 목록, 관리자 전체 티어표 관리, 티어표 저장/요청 API는 `topicId`를 우선 받도록 정리하고 기존 `gameId`는 호환 입력으로만 남겨, 외부 표면과 실제 저장 스키마가 한 단계 더 가까워지게 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.12
|
||||
- 백엔드에 `/api/topics`와 `/api/admin/templates` alias 경로를 추가하고, 주제/템플릿 응답도 `topic/topics`, `template/templates` 키를 함께 내려주도록 정리했다.
|
||||
- 프런트의 새 의미 이름은 이제 실제로도 `/api/topics`, `/api/admin/templates`를 타도록 연결해, 경로 이름과 호출 이름이 다시 어긋나지 않게 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.11
|
||||
- 프런트 API 이름층을 한 단계 더 정리해 `listTopics / getTopic / favoriteTopic`, `updateAdminTemplate*`, `searchPublicTierListsByTopic` 같은 의미 기반 이름을 추가하고 실제 호출부도 이 기준으로 옮겼다.
|
||||
- 백엔드 경로와 응답 구조는 그대로 유지한 채 프런트에서 읽는 이름만 먼저 바꿔, 다음 단계의 API/모델 리네이밍 부담을 더 줄였다.
|
||||
|
||||
## 2026-04-02 v1.4.10
|
||||
- 주제 상세 라우트 이름을 `topicHub`로, 기본 경로 파라미터를 `topicId`로 바꾸고 기존 `gameId` 주소는 alias로 유지했다.
|
||||
- 앱 셸, 주제 상세, 티어표 편집기는 이제 내부에서 `topicId`를 우선 읽고, 레거시 주소로 들어온 경우에만 `gameId` fallback을 쓰도록 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.9
|
||||
- `frontend/src/lib/paths.js`를 추가해 주제 진입, 에디터 이동, 로그인 리다이렉트, 공유 프리뷰 주소 같은 사용자 표면 경로를 공용 함수로 모았다.
|
||||
- 홈, 주제 상세, 나의 티어표, 즐겨찾기, 검색 결과, 로그인, 설정, 관리자 미리보기, 티어표 편집기까지 이 경로 헬퍼를 쓰도록 바꿔 이후 `topics` 전환을 더 안전하게 이어갈 수 있는 기반을 만들었다.
|
||||
|
||||
## 2026-04-02 v1.4.8
|
||||
- 주제 상세 컬렉션 화면은 제목을 `topicId` fallback으로 먼저 노출하지 않도록 바꾸고, 주제 전환 시에는 로딩 문구를 거쳐 실제 이름으로 자연스럽게 바뀌게 정리했다.
|
||||
- 검색 결과 화면도 공통 `pageHead` 문법으로 맞춰 주요 목록 화면들의 상단 리듬을 한 번 더 통일했다.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { editorNewPath, favoritesPath, homePath, loginPath, mePath } from './lib/paths'
|
||||
import { toApiUrl } from './lib/runtime'
|
||||
import { useToast } from './composables/useToast'
|
||||
import iconDockToLeft from './assets/icons/dock_to_left.svg'
|
||||
@@ -22,6 +23,7 @@ const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const { toasts, dismissToast } = useToast()
|
||||
const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
|
||||
const currentTopicId = computed(() => route.params.topicId || '')
|
||||
|
||||
const leftRailCollapsed = ref(false)
|
||||
const rightRailOpen = ref(true)
|
||||
@@ -32,6 +34,8 @@ const isGuideModalOpen = ref(false)
|
||||
const themeMode = ref('dark')
|
||||
const guideStepIndex = ref(0)
|
||||
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
|
||||
const backendState = ref('online')
|
||||
const backendMessage = ref('')
|
||||
provide('rightRailOpen', rightRailOpen)
|
||||
provide('localRightRailTarget', '#local-right-rail-root')
|
||||
|
||||
@@ -134,16 +138,17 @@ const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.le
|
||||
const isLightTheme = computed(() => themeMode.value === 'light')
|
||||
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
|
||||
const showSettingsThemePanel = computed(() => false && route.name === 'profile')
|
||||
const showTopicViewToggle = computed(() => route.name === 'gameHub')
|
||||
const showTopicViewToggle = computed(() => route.name === 'topicHub')
|
||||
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
|
||||
const leftBottomPrimaryAction = computed(() => {
|
||||
if (!authReady.value) return null
|
||||
if (route.name === 'home' && auth.user) {
|
||||
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new', iconSrc: iconDashboardCustomize }
|
||||
return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
|
||||
}
|
||||
if (route.name === 'gameHub') {
|
||||
const target = `/editor/${route.params.gameId}/new`
|
||||
return { label: '새 티어표 만들기', to: auth.user ? target : `/login?redirect=${target}`, iconSrc: iconAddNotes }
|
||||
if (route.name === 'topicHub') {
|
||||
const target = editorNewPath(currentTopicId.value)
|
||||
return { label: '새 티어표 만들기', to: auth.user ? target : loginPath(target), iconSrc: iconAddNotes }
|
||||
}
|
||||
return null
|
||||
})
|
||||
@@ -157,11 +162,11 @@ const routeMeta = computed(() => {
|
||||
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 주제를 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
||||
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
|
||||
action: () => {
|
||||
router.push(auth.user ? '/editor/freeform/new' : '/login')
|
||||
router.push(auth.user ? editorNewPath('freeform') : loginPath())
|
||||
},
|
||||
}
|
||||
}
|
||||
if (route.name === 'gameHub') {
|
||||
if (route.name === 'topicHub') {
|
||||
return {
|
||||
title: '주제 티어표',
|
||||
subtitle: '주제별 공개 티어표 탐색',
|
||||
@@ -169,8 +174,8 @@ const routeMeta = computed(() => {
|
||||
contextText: auth.user ? '이 주제의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.',
|
||||
actionLabel: auth.user ? '새 티어표 만들기' : '로그인하러 가기',
|
||||
action: () => {
|
||||
const target = `/editor/${route.params.gameId}/new`
|
||||
router.push(auth.user ? target : `/login?redirect=${target}`)
|
||||
const target = editorNewPath(currentTopicId.value)
|
||||
router.push(auth.user ? target : loginPath(target))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -181,7 +186,7 @@ const routeMeta = computed(() => {
|
||||
contextTitle: '편집 패널',
|
||||
contextText: '현재 편집 옵션은 중앙 화면 안에 유지되어 있습니다. 다음 단계에서 우측 패널로 정리해갈게요.',
|
||||
actionLabel: '주제 목록으로',
|
||||
action: () => router.push('/'),
|
||||
action: () => router.push(homePath()),
|
||||
}
|
||||
}
|
||||
if (isAdminRoute.value) {
|
||||
@@ -191,7 +196,7 @@ const routeMeta = computed(() => {
|
||||
contextTitle: '운영 노트',
|
||||
contextText: '관리자 화면은 기능이 많아 우선 공통 셸 톤을 맞췄고, 세부 패널은 다음 단계에서 시안 방식으로 더 세밀하게 나눌 예정입니다.',
|
||||
actionLabel: '주제 목록으로',
|
||||
action: () => router.push('/'),
|
||||
action: () => router.push(homePath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'me') {
|
||||
@@ -201,7 +206,7 @@ const routeMeta = computed(() => {
|
||||
contextTitle: '작성 이력',
|
||||
contextText: '최근 저장한 티어표를 열람하고 정리할 수 있어요.',
|
||||
actionLabel: '즐겨찾기 보기',
|
||||
action: () => router.push('/favorites'),
|
||||
action: () => router.push(favoritesPath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'favorites') {
|
||||
@@ -211,7 +216,7 @@ const routeMeta = computed(() => {
|
||||
contextTitle: '정리 도구',
|
||||
contextText: '정렬과 검색으로 원하는 티어표를 빠르게 다시 찾을 수 있어요.',
|
||||
actionLabel: '나의 티어표 보기',
|
||||
action: () => router.push('/me'),
|
||||
action: () => router.push(mePath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'profile') {
|
||||
@@ -221,7 +226,7 @@ const routeMeta = computed(() => {
|
||||
contextTitle: '계정 관리',
|
||||
contextText: '아바타와 닉네임을 관리하고 표시 정보를 확인할 수 있어요.',
|
||||
actionLabel: '나의 티어표 보기',
|
||||
action: () => router.push('/me'),
|
||||
action: () => router.push(mePath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'search') {
|
||||
@@ -231,7 +236,7 @@ const routeMeta = computed(() => {
|
||||
contextTitle: '검색',
|
||||
contextText: '제목, 작성자 기준으로 공개 티어표를 전체 검색할 수 있어요.',
|
||||
actionLabel: '홈으로',
|
||||
action: () => router.push('/'),
|
||||
action: () => router.push(homePath()),
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -240,7 +245,7 @@ const routeMeta = computed(() => {
|
||||
contextTitle: '작업 공간',
|
||||
contextText: '현재 화면에 맞는 도구와 안내를 여기에 배치할 수 있습니다.',
|
||||
actionLabel: '홈으로',
|
||||
action: () => router.push('/'),
|
||||
action: () => router.push(homePath()),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -249,6 +254,13 @@ function syncViewportWidth() {
|
||||
viewportWidth.value = window.innerWidth
|
||||
}
|
||||
|
||||
function handleBackendStatus(event) {
|
||||
const state = event?.detail?.state
|
||||
if (!state) return
|
||||
backendState.value = state
|
||||
backendMessage.value = typeof event?.detail?.message === 'string' ? event.detail.message : ''
|
||||
}
|
||||
|
||||
function applyTheme(mode) {
|
||||
themeMode.value = mode === 'light' ? 'light' : 'dark'
|
||||
if (typeof document !== 'undefined') document.documentElement.dataset.theme = themeMode.value
|
||||
@@ -268,6 +280,7 @@ onMounted(async () => {
|
||||
await auth.refresh()
|
||||
if (typeof window !== 'undefined') {
|
||||
syncViewportWidth()
|
||||
window.addEventListener('tier-maker:backend-status', handleBackendStatus)
|
||||
window.addEventListener('resize', syncViewportWidth)
|
||||
window.addEventListener('keydown', handleGlobalKeydown)
|
||||
const leftSaved = window.localStorage.getItem('tier-maker:left-rail-collapsed')
|
||||
@@ -290,6 +303,7 @@ function handleGlobalKeydown(event) {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('tier-maker:backend-status', handleBackendStatus)
|
||||
window.removeEventListener('resize', syncViewportWidth)
|
||||
window.removeEventListener('keydown', handleGlobalKeydown)
|
||||
}
|
||||
@@ -345,7 +359,7 @@ function toggleRightRail() {
|
||||
}
|
||||
|
||||
function setTopicViewMode(mode) {
|
||||
if (route.name !== 'gameHub') return
|
||||
if (route.name !== 'topicHub') return
|
||||
const nextQuery = { ...route.query }
|
||||
if (mode === 'list') nextQuery.view = 'list'
|
||||
else delete nextQuery.view
|
||||
@@ -395,7 +409,12 @@ function handleLeftRailSearch() {
|
||||
function submitGlobalSearch() {
|
||||
const query = (searchQuery.value || '').trim()
|
||||
isCollapsedSearchOpen.value = false
|
||||
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
|
||||
router.push(homePath(query))
|
||||
}
|
||||
|
||||
function reloadApp() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
|
||||
@@ -412,7 +431,26 @@ function submitGlobalSearch() {
|
||||
}"
|
||||
:style="shellStyle"
|
||||
>
|
||||
<template v-if="isPreviewMode">
|
||||
<template v-if="showBackendFallback">
|
||||
<main class="backendFallback">
|
||||
<section class="backendFallback__card">
|
||||
<div class="backendFallback__eyebrow">{{ backendState === 'maintenance' ? 'Maintenance' : 'Connection' }}</div>
|
||||
<h1 class="backendFallback__title">{{ backendState === 'maintenance' ? '서비스 점검 중' : '서버 연결 확인 중' }}</h1>
|
||||
<p class="backendFallback__desc">
|
||||
{{
|
||||
backendMessage ||
|
||||
(backendState === 'maintenance'
|
||||
? '백엔드 또는 데이터베이스 작업으로 인해 잠시 이용이 어렵습니다. 잠시 후 다시 시도해주세요.'
|
||||
: '네트워크 또는 서버 연결 상태를 확인한 뒤 다시 시도해주세요.')
|
||||
}}
|
||||
</p>
|
||||
<div class="backendFallback__actions">
|
||||
<button class="backendFallback__button" type="button" @click="reloadApp">다시 시도</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
<template v-else-if="isPreviewMode">
|
||||
<main class="appMain appMain--preview">
|
||||
<RouterView />
|
||||
</main>
|
||||
@@ -658,6 +696,65 @@ function submitGlobalSearch() {
|
||||
transition: grid-template-columns 220ms ease;
|
||||
}
|
||||
|
||||
.backendFallback {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(86, 153, 255, 0.14), transparent 38%),
|
||||
var(--theme-shell-bg);
|
||||
}
|
||||
|
||||
.backendFallback__card {
|
||||
width: min(100%, 560px);
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 28px;
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
}
|
||||
|
||||
.backendFallback__eyebrow {
|
||||
color: var(--theme-accent-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.backendFallback__title {
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.backendFallback__desc {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.backendFallback__actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.backendFallback__button {
|
||||
min-width: 128px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(98, 170, 255, 0.32);
|
||||
background: rgba(98, 170, 255, 0.18);
|
||||
color: var(--theme-text-strong);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.appShell--preview {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ function setThumbFileElement(el) {
|
||||
<div class="hint hint--tight">
|
||||
{{
|
||||
props.activeTemplateRequest.type === 'create'
|
||||
? (props.activeTemplateRequest.targetGameId
|
||||
? (props.activeTemplateRequest.targetTopicId
|
||||
? '이미 연결된 신규 템플릿이 있어요. 필요한 아이템만 골라 저장하세요.'
|
||||
: '새 템플릿을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.')
|
||||
: '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.'
|
||||
@@ -76,8 +76,8 @@ function setThumbFileElement(el) {
|
||||
<span class="pill pill--accent">{{ props.activeTemplateRequest.type === 'create' ? '신규 템플릿 요청' : '기존 템플릿 업데이트' }}</span>
|
||||
<span class="pill">요청 아이템 {{ props.stagedRequestDraftCount }}개</span>
|
||||
<span v-if="props.appliedRequestItemCount" class="pill pill--soft">이미 반영 {{ props.appliedRequestItemCount }}개</span>
|
||||
<span v-if="props.activeTemplateRequest.type === 'create' && (props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId)" class="pill pill--soft">
|
||||
연결된 템플릿 · {{ props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId }}
|
||||
<span v-if="props.activeTemplateRequest.type === 'create' && (props.activeTemplateRequest.targetTopicName || props.activeTemplateRequest.targetTopicId)" class="pill pill--soft">
|
||||
연결된 템플릿 · {{ props.activeTemplateRequest.targetTopicName || props.activeTemplateRequest.targetTopicId }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,7 +92,7 @@ function setThumbFileElement(el) {
|
||||
요청 티어표 보기
|
||||
</a>
|
||||
<button
|
||||
v-if="props.activeTemplateRequest.type === 'create' && !props.activeTemplateRequest.targetGameId"
|
||||
v-if="props.activeTemplateRequest.type === 'create' && !props.activeTemplateRequest.targetTopicId"
|
||||
class="btn btn--ghost btn--small"
|
||||
type="button"
|
||||
@click="props.openTemplateCreateModal"
|
||||
@@ -125,7 +125,7 @@ function setThumbFileElement(el) {
|
||||
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.game.name" />
|
||||
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
|
||||
<div class="thumbDropZone__copy">
|
||||
<div class="thumbDropZone__iconWrap">
|
||||
<div v-if="!props.displayThumbnailUrl" class="thumbDropZone__iconWrap">
|
||||
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" />
|
||||
</div>
|
||||
<div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
|
||||
|
||||
@@ -45,7 +45,6 @@ const props = defineProps({
|
||||
:target="props.templateRequestSourceUrl(request) ? '_blank' : undefined"
|
||||
:rel="props.templateRequestSourceUrl(request) ? 'noreferrer' : undefined"
|
||||
:aria-disabled="!props.templateRequestSourceUrl(request)"
|
||||
@click.prevent="props.templateRequestSourceUrl(request) && window.open(props.templateRequestSourceUrl(request), '_blank', 'noopener,noreferrer')"
|
||||
>
|
||||
<img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" draggable="false" />
|
||||
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
|
||||
@@ -54,18 +53,18 @@ const props = defineProps({
|
||||
<template v-if="request.type === 'create'">
|
||||
<label class="templateRequestField">
|
||||
<span class="templateRequestField__label">템플릿 이름</span>
|
||||
<input v-model="request.draftGameName" class="input" placeholder="새 템플릿 이름" />
|
||||
<input v-model="request.draftTopicName" class="input" placeholder="새 템플릿 이름" />
|
||||
</label>
|
||||
<label class="templateRequestField">
|
||||
<span class="templateRequestField__label">템플릿 ID</span>
|
||||
<input v-model="request.draftGameId" class="input" placeholder="임시 템플릿 ID" />
|
||||
<input v-model="request.draftTopicId" class="input" placeholder="임시 템플릿 ID" />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="templateRequestCard__thumbLabel">템플릿 이름</div>
|
||||
<div class="templateRequestCard__thumbValue">{{ request.draftGameName || request.sourceGameName || '-' }}</div>
|
||||
<div class="templateRequestCard__thumbValue">{{ request.draftTopicName || request.sourceTopicName || '-' }}</div>
|
||||
<div class="templateRequestCard__thumbLabel">템플릿 ID</div>
|
||||
<div class="templateRequestCard__thumbValue">{{ request.draftGameId || request.sourceGameId || '-' }}</div>
|
||||
<div class="templateRequestCard__thumbValue">{{ request.draftTopicId || request.sourceTopicId || '-' }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,8 +89,8 @@ const props = defineProps({
|
||||
|
||||
<div class="tierAdminCard__stats">
|
||||
<span class="pill">추가 아이템 {{ request.items?.length || 0 }}개</span>
|
||||
<span v-if="request.type === 'create' && (request.targetGameName || request.targetGameId)" class="pill pill--soft">
|
||||
연결됨 · {{ request.targetGameName || request.targetGameId }}
|
||||
<span v-if="request.type === 'create' && (request.targetTopicName || request.targetTopicId)" class="pill pill--soft">
|
||||
연결됨 · {{ request.targetTopicName || request.targetTopicId }}
|
||||
</span>
|
||||
<span class="pill" :class="{ 'pill--accent': request.status === 'reviewing' }">{{ props.templateRequestStatusLabel(request) }}</span>
|
||||
</div>
|
||||
@@ -110,7 +109,7 @@ const props = defineProps({
|
||||
{{
|
||||
request.isHandling
|
||||
? '이동중...'
|
||||
: request.type === 'create' && (request.targetGameName || request.targetGameId)
|
||||
: request.type === 'create' && (request.targetTopicName || request.targetTopicId)
|
||||
? '연결된 템플릿 열기'
|
||||
: '확인하기'
|
||||
}}
|
||||
@@ -149,7 +148,7 @@ const props = defineProps({
|
||||
<div class="tierAdminCard__title">{{ tierList.title }}</div>
|
||||
<div v-if="tierList.description" class="tierAdminCard__desc">{{ tierList.description }}</div>
|
||||
<div class="tierAdminCard__meta">
|
||||
{{ tierList.gameName || tierList.gameId }} · {{ props.tierListAuthorDisplayName(tierList) }}
|
||||
{{ tierList.topicName || tierList.topicId }} · {{ props.tierListAuthorDisplayName(tierList) }}
|
||||
</div>
|
||||
<div class="tierAdminCard__meta">{{ props.fmt(tierList.updatedAt) }}</div>
|
||||
</div>
|
||||
@@ -171,7 +170,7 @@ const props = defineProps({
|
||||
</div>
|
||||
<div class="tierAdminSection__actions">
|
||||
<button class="btn btn--ghost btn--small" @click="props.openTierListImportModal(tierList, tierList.extraItems)">추가 아이템 전체 가져오기</button>
|
||||
<button v-if="tierList.gameId === 'freeform'" class="btn btn--primary btn--small" @click="props.openTierListImportModal(tierList, tierList.extraItems)">
|
||||
<button v-if="tierList.topicId === 'freeform'" class="btn btn--primary btn--small" @click="props.openTierListImportModal(tierList, tierList.extraItems)">
|
||||
새 템플릿으로 가져오기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -167,7 +167,7 @@ export function useAdminCustomItems({
|
||||
|
||||
try {
|
||||
item.isPromoting = true
|
||||
await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetTemplateId.value })
|
||||
await api.promoteAdminTemplateItem(item.id, { topicId: customItemModalTargetTemplateId.value })
|
||||
const targetTemplateName =
|
||||
templates.value.find((template) => template.id === customItemModalTargetTemplateId.value)?.name || customItemModalTargetTemplateId.value
|
||||
if (selectedTemplateId.value === customItemModalTargetTemplateId.value) await loadTemplate()
|
||||
|
||||
@@ -70,8 +70,8 @@ export function useAdminFeaturedGames({
|
||||
async function saveFeaturedOrder() {
|
||||
resetMessages()
|
||||
try {
|
||||
const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredTemplateIds.value })
|
||||
templates.value = data.games || []
|
||||
const data = await api.updateAdminTemplateDisplayOrder({ topicIds: featuredTemplateIds.value })
|
||||
templates.value = data.templates || []
|
||||
featuredTemplateIds.value = templates.value
|
||||
.filter((template) => template.displayRank != null)
|
||||
.sort((a, b) => a.displayRank - b.displayRank)
|
||||
|
||||
@@ -131,9 +131,12 @@ export function useAdminGameManager({
|
||||
|
||||
try {
|
||||
isGameLoading.value = true
|
||||
const data = await api.getGame(selectedTemplateId.value)
|
||||
const data = await api.getTopic(selectedTemplateId.value)
|
||||
const loadedTemplate = data.template || data.topic || null
|
||||
selectedTemplate.value = {
|
||||
...data,
|
||||
game: loadedTemplate,
|
||||
template: loadedTemplate,
|
||||
items: (data.items || []).map((item) => ({
|
||||
...item,
|
||||
draftLabel: item.label,
|
||||
@@ -150,18 +153,18 @@ export function useAdminGameManager({
|
||||
}
|
||||
|
||||
async function createTemplate(options = {}) {
|
||||
const nextGameId = typeof options.gameId === 'string' ? options.gameId.trim() : newTemplateId.value.trim()
|
||||
const nextGameName = typeof options.gameName === 'string' ? options.gameName.trim() : newTemplateName.value.trim()
|
||||
const nextTopicId = typeof options.topicId === 'string' ? options.topicId.trim() : newTemplateId.value.trim()
|
||||
const nextTopicName = typeof options.topicName === 'string' ? options.topicName.trim() : newTemplateName.value.trim()
|
||||
const preserveUploadState = !!options.preserveUploadState
|
||||
resetMessages()
|
||||
try {
|
||||
const res = await fetch(toApiUrl('/api/admin/games'), {
|
||||
const res = await fetch(toApiUrl('/api/admin/templates'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: nextGameId,
|
||||
name: nextGameName,
|
||||
id: nextTopicId,
|
||||
name: nextTopicName,
|
||||
isPublic: !!newTemplateIsPublic.value,
|
||||
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
|
||||
}),
|
||||
@@ -169,27 +172,28 @@ export function useAdminGameManager({
|
||||
if (!res.ok) throw new Error('failed')
|
||||
|
||||
const data = await res.json()
|
||||
const createdTemplate = data.template || {}
|
||||
if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) {
|
||||
const linkData = await api.linkAdminTemplateRequestGame(activeTemplateRequest.value.id, {
|
||||
gameId: data.game.id,
|
||||
topicId: createdTemplate.id,
|
||||
})
|
||||
activeTemplateRequest.value = {
|
||||
...activeTemplateRequest.value,
|
||||
targetGameId: linkData.request?.targetGameId || data.game.id,
|
||||
targetGameName: linkData.request?.targetGameName || data.game.name || nextGameName,
|
||||
targetTopicId: linkData.request?.targetTopicId || createdTemplate.id,
|
||||
targetTopicName: linkData.request?.targetTopicName || createdTemplate.name || nextTopicName,
|
||||
}
|
||||
const requestIndex = templateRequests.value.findIndex((entry) => entry.id === activeTemplateRequest.value.id)
|
||||
if (requestIndex >= 0) {
|
||||
templateRequests.value.splice(requestIndex, 1, {
|
||||
...templateRequests.value[requestIndex],
|
||||
targetGameId: linkData.request?.targetGameId || data.game.id,
|
||||
targetGameName: linkData.request?.targetGameName || data.game.name || nextGameName,
|
||||
targetTopicId: linkData.request?.targetTopicId || createdTemplate.id,
|
||||
targetTopicName: linkData.request?.targetTopicName || createdTemplate.name || nextTopicName,
|
||||
})
|
||||
}
|
||||
}
|
||||
await refreshTemplates()
|
||||
selectedTemplateId.value = data.game.id
|
||||
if (customItemModalOpen.value) customItemModalTargetTemplateId.value = data.game.id
|
||||
selectedTemplateId.value = createdTemplate.id
|
||||
if (customItemModalOpen.value) customItemModalTargetTemplateId.value = createdTemplate.id
|
||||
closeTemplateCreateModal()
|
||||
await loadTemplate({ preserveUploadState })
|
||||
if (!preserveUploadState && activeTemplateRequest.value?.id) {
|
||||
@@ -254,16 +258,16 @@ export function useAdminGameManager({
|
||||
}
|
||||
|
||||
try {
|
||||
if (!selectedTemplateId.value && activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetGameId) {
|
||||
const draftGameId = (activeTemplateRequest.value?.draftGameId || '').trim()
|
||||
const draftGameName = (activeTemplateRequest.value?.draftGameName || '').trim()
|
||||
if (!draftGameId || !draftGameName) {
|
||||
if (!selectedTemplateId.value && activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetTopicId) {
|
||||
const draftTopicId = (activeTemplateRequest.value?.draftTopicId || '').trim()
|
||||
const draftTopicName = (activeTemplateRequest.value?.draftTopicName || '').trim()
|
||||
if (!draftTopicId || !draftTopicName) {
|
||||
error.value = '먼저 신규 템플릿의 이름과 템플릿 ID를 저장해주세요.'
|
||||
return
|
||||
}
|
||||
await createTemplate({
|
||||
gameId: draftGameId,
|
||||
gameName: draftGameName,
|
||||
topicId: draftTopicId,
|
||||
topicName: draftTopicName,
|
||||
preserveUploadState: true,
|
||||
})
|
||||
}
|
||||
@@ -283,7 +287,7 @@ export function useAdminGameManager({
|
||||
fd.append('images', entry.file)
|
||||
fd.append('labels', entry.label.trim())
|
||||
})
|
||||
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}/images`), {
|
||||
const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/images`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
@@ -297,7 +301,7 @@ export function useAdminGameManager({
|
||||
for (const requestId of requestIds) {
|
||||
const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId)
|
||||
const result = await api.promoteAdminTemplateRequestItems(requestId, {
|
||||
gameId: selectedTemplateId.value,
|
||||
topicId: selectedTemplateId.value,
|
||||
itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean),
|
||||
itemSrcs: draftsForRequest.map((entry) => entry.src).filter(Boolean),
|
||||
itemLabels: draftsForRequest.reduce((acc, entry) => {
|
||||
@@ -323,7 +327,7 @@ export function useAdminGameManager({
|
||||
error.value = `요청 아이템을 템플릿 기본 아이템으로 옮기지 못했어요.${detail}`
|
||||
return
|
||||
}
|
||||
if (apiError === 'game_not_found') {
|
||||
if (apiError === 'topic_not_found') {
|
||||
error.value = '선택한 템플릿을 찾지 못했어요.'
|
||||
return
|
||||
}
|
||||
@@ -336,7 +340,7 @@ export function useAdminGameManager({
|
||||
if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) return
|
||||
|
||||
try {
|
||||
const data = await api.updateAdminGameItemDisplayOrder(selectedTemplateId.value, {
|
||||
const data = await api.updateAdminTemplateItemDisplayOrder(selectedTemplateId.value, {
|
||||
itemIds: selectedTemplate.value.items.map((item) => item.id),
|
||||
})
|
||||
selectedTemplate.value = {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { editorPath } from '../lib/paths'
|
||||
|
||||
export function useAdminTemplateRequests({
|
||||
api,
|
||||
activeTemplateRequest,
|
||||
@@ -19,14 +21,14 @@ export function useAdminTemplateRequests({
|
||||
type: request.type,
|
||||
status: request.status,
|
||||
thumbnailSrc: request.thumbnailSrc || '',
|
||||
draftGameId: request.draftGameId || '',
|
||||
draftGameName: request.draftGameName || '',
|
||||
draftGameIsPublic: !!request.draftGameIsPublic,
|
||||
draftTopicId: request.draftTopicId || '',
|
||||
draftTopicName: request.draftTopicName || '',
|
||||
draftTopicIsPublic: !!request.draftTopicIsPublic,
|
||||
sourceTierListId: request.sourceTierListId || '',
|
||||
sourceGameId: request.sourceGameId || '',
|
||||
sourceTopicId: request.sourceTopicId || '',
|
||||
sourceTierListTitle: request.sourceTierListTitle || '',
|
||||
targetGameId: request.targetGameId || '',
|
||||
targetGameName: request.targetGameName || '',
|
||||
targetTopicId: request.targetTopicId || '',
|
||||
targetTopicName: request.targetTopicName || '',
|
||||
requesterName: request.requesterName || '',
|
||||
}
|
||||
}
|
||||
@@ -36,8 +38,8 @@ export function useAdminTemplateRequests({
|
||||
}
|
||||
|
||||
function templateRequestSourceUrl(request) {
|
||||
if (!request?.sourceGameId || !request?.sourceTierListId) return ''
|
||||
return `/editor/${request.sourceGameId}/${request.sourceTierListId}?preview=1`
|
||||
if (!request?.sourceTopicId || !request?.sourceTierListId) return ''
|
||||
return editorPath(request.sourceTopicId, request.sourceTierListId, { preview: true })
|
||||
}
|
||||
|
||||
function templateRequestReviewHint(request) {
|
||||
@@ -53,9 +55,9 @@ export function useAdminTemplateRequests({
|
||||
const syncedRequest = {
|
||||
...request,
|
||||
...(data.request || {}),
|
||||
draftGameId: request.draftGameId || '',
|
||||
draftGameName: request.draftGameName || '',
|
||||
draftGameIsPublic: !!request.draftGameIsPublic,
|
||||
draftTopicId: request.draftTopicId || '',
|
||||
draftTopicName: request.draftTopicName || '',
|
||||
draftTopicIsPublic: !!request.draftTopicIsPublic,
|
||||
}
|
||||
Object.assign(request, syncedRequest)
|
||||
request.status = syncedRequest.status || 'reviewing'
|
||||
@@ -63,18 +65,18 @@ export function useAdminTemplateRequests({
|
||||
setTab('game-admin')
|
||||
|
||||
if (request.type === 'create') {
|
||||
const linkedGameId = syncedRequest.targetGameId || ''
|
||||
if (linkedGameId) {
|
||||
await selectAdminTemplate(linkedGameId)
|
||||
const linkedTopicId = syncedRequest.targetTopicId || ''
|
||||
if (linkedTopicId) {
|
||||
await selectAdminTemplate(linkedTopicId)
|
||||
} else {
|
||||
openTemplateCreateModal()
|
||||
newTemplateId.value = (syncedRequest.draftGameId || '').trim()
|
||||
newTemplateName.value = (syncedRequest.draftGameName || '').trim()
|
||||
newTemplateId.value = (syncedRequest.draftTopicId || '').trim()
|
||||
newTemplateName.value = (syncedRequest.draftTopicName || '').trim()
|
||||
}
|
||||
mergeRequestItemsIntoDrafts(syncedRequest)
|
||||
} else {
|
||||
const nextGameId = syncedRequest.targetGameId || syncedRequest.sourceGameId || ''
|
||||
if (nextGameId) await selectAdminTemplate(nextGameId)
|
||||
const nextTopicId = syncedRequest.targetTopicId || syncedRequest.sourceTopicId || ''
|
||||
if (nextTopicId) await selectAdminTemplate(nextTopicId)
|
||||
mergeRequestItemsIntoDrafts(syncedRequest)
|
||||
}
|
||||
success.value = '요청 아이템을 템플릿 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
|
||||
|
||||
@@ -1,25 +1,54 @@
|
||||
import { toApiUrl } from './runtime'
|
||||
|
||||
function emitBackendStatus(detail) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(new CustomEvent('tier-maker:backend-status', { detail }))
|
||||
}
|
||||
|
||||
async function request(path, { method = 'GET', body, headers } = {}) {
|
||||
const res = await fetch(toApiUrl(path), {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(headers || {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
let res
|
||||
try {
|
||||
res = await fetch(toApiUrl(path), {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(headers || {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
} catch (error) {
|
||||
emitBackendStatus({
|
||||
state: 'offline',
|
||||
message: '서버 연결을 확인할 수 없어 잠시 후 다시 시도해주세요.',
|
||||
path,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
const data = contentType.includes('application/json') ? await res.json() : await res.text()
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status >= 500 && data?.error === 'db_init_failed') {
|
||||
emitBackendStatus({
|
||||
state: 'maintenance',
|
||||
message: '서비스 점검 중이거나 데이터베이스 초기화 중입니다. 잠시 후 다시 이용해주세요.',
|
||||
path,
|
||||
})
|
||||
} else if (res.status >= 500) {
|
||||
emitBackendStatus({
|
||||
state: 'maintenance',
|
||||
message: '서비스 내부 점검이 필요합니다. 잠시 후 다시 이용해주세요.',
|
||||
path,
|
||||
})
|
||||
}
|
||||
const err = new Error('request_failed')
|
||||
err.status = res.status
|
||||
err.data = data
|
||||
throw err
|
||||
}
|
||||
emitBackendStatus({ state: 'online', path })
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -30,27 +59,25 @@ export const api = {
|
||||
login: ({ email, password }) => request('/api/auth/login', { method: 'POST', body: { email, password } }),
|
||||
logout: () => request('/api/auth/logout', { method: 'POST' }),
|
||||
|
||||
listGames: () => request('/api/games'),
|
||||
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
|
||||
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }),
|
||||
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
||||
updateAdminGameItemDisplayOrder: (gameId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { method: 'PATCH', body: payload }),
|
||||
updateAdminGame: (gameId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminGameItem: (gameId, itemId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||
listTopics: () => request('/api/topics'),
|
||||
getTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}`),
|
||||
favoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteTopic: (topicId) => request(`/api/topics/${encodeURIComponent(topicId)}/favorite`, { method: 'DELETE' }),
|
||||
updateAdminTemplateDisplayOrder: (payload) => request('/api/admin/templates/display-order', { method: 'PATCH', body: payload }),
|
||||
updateAdminTemplateItemDisplayOrder: (templateId, payload) =>
|
||||
request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/display-order`, { method: 'PATCH', body: payload }),
|
||||
updateAdminTemplate: (templateId, payload) =>
|
||||
request(`/api/admin/templates/${encodeURIComponent(templateId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminTemplateItem: (templateId, itemId, payload) =>
|
||||
request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) =>
|
||||
request(
|
||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
|
||||
),
|
||||
listAdminTierLists: ({ q = '', gameId = '', page = 1, limit = 50 } = {}) =>
|
||||
request(
|
||||
`/api/admin/tierlists?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`
|
||||
),
|
||||
getAdminTierListStats: ({ q = '', gameId = '' } = {}) =>
|
||||
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}`),
|
||||
listAdminTierLists: ({ q = '', topicId = '', page = 1, limit = 50 } = {}) =>
|
||||
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
||||
getAdminTierListStats: ({ q = '', topicId = '' } = {}) =>
|
||||
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}`),
|
||||
updateAdminTierList: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
|
||||
deleteAdminTierList: (tierListId) =>
|
||||
@@ -66,13 +93,13 @@ export const api = {
|
||||
cleanupAdminMissingImageReferences: () => request('/api/admin/image-assets/missing/cleanup', { method: 'POST', body: {} }),
|
||||
listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`),
|
||||
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
||||
promoteAdminCustomItem: (itemId, payload) =>
|
||||
promoteAdminTemplateItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||
updateAdminCustomItemLabel: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
|
||||
promoteAdminTierListItems: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
|
||||
createAdminGameTemplateFromTierList: (tierListId, payload) =>
|
||||
createAdminTemplateFromTierList: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
|
||||
startAdminTemplateRequestReview: (requestId) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/review`, { method: 'POST', body: {} }),
|
||||
@@ -111,10 +138,10 @@ export const api = {
|
||||
},
|
||||
deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
|
||||
|
||||
listPublicTierLists: (gameId) =>
|
||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
|
||||
searchPublicTierLists: (gameId, q = '') =>
|
||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
|
||||
listPublicTierListsByTopic: (topicId) =>
|
||||
request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}`),
|
||||
searchPublicTierListsByTopic: (topicId, q = '') =>
|
||||
request(`/api/tierlists/public?topicId=${encodeURIComponent(topicId || '')}&q=${encodeURIComponent(q || '')}`),
|
||||
searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`),
|
||||
listMyTierLists: () => request('/api/tierlists/me'),
|
||||
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>
|
||||
|
||||
38
frontend/src/lib/paths.js
Normal file
38
frontend/src/lib/paths.js
Normal file
@@ -0,0 +1,38 @@
|
||||
function encodeSegment(value) {
|
||||
return encodeURIComponent(String(value || '').trim())
|
||||
}
|
||||
|
||||
export function homePath(query = '') {
|
||||
const normalized = String(query || '').trim()
|
||||
return normalized ? `/?q=${encodeURIComponent(normalized)}` : '/'
|
||||
}
|
||||
|
||||
export function loginPath(redirect = '') {
|
||||
const normalized = String(redirect || '').trim()
|
||||
return normalized ? `/login?redirect=${encodeURIComponent(normalized)}` : '/login'
|
||||
}
|
||||
|
||||
export function topicPath(topicId) {
|
||||
return `/topics/${encodeSegment(topicId)}`
|
||||
}
|
||||
|
||||
export function editorNewPath(topicId) {
|
||||
return `/editor/${encodeSegment(topicId)}/new`
|
||||
}
|
||||
|
||||
export function editorPath(topicId, tierListId, { preview = false } = {}) {
|
||||
const base = `/editor/${encodeSegment(topicId)}/${encodeSegment(tierListId)}`
|
||||
return preview ? `${base}?preview=1` : base
|
||||
}
|
||||
|
||||
export function mePath() {
|
||||
return '/me'
|
||||
}
|
||||
|
||||
export function favoritesPath() {
|
||||
return '/favorites'
|
||||
}
|
||||
|
||||
export function profilePath() {
|
||||
return '/profile'
|
||||
}
|
||||
@@ -16,9 +16,10 @@ export function createRouter() {
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: HomeView },
|
||||
{ path: '/topics/:gameId', alias: ['/games/:gameId'], name: 'gameHub', component: GameHubView },
|
||||
{ path: '/editor/:gameId/new', name: 'newEditor', component: TierEditorView },
|
||||
{ path: '/editor/:gameId/:tierListId', name: 'editEditor', component: TierEditorView },
|
||||
{ path: '/games/:gameId', redirect: (to) => `/topics/${encodeURIComponent(String(to.params.gameId || ''))}` },
|
||||
{ path: '/topics/:topicId', name: 'topicHub', component: GameHubView },
|
||||
{ path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView },
|
||||
{ path: '/editor/:topicId/:tierListId', name: 'editEditor', component: TierEditorView },
|
||||
{ path: '/login', name: 'login', component: LoginView },
|
||||
{ path: '/me', name: 'me', component: MyTierListsView },
|
||||
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { Teleport, computed, inject, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { editorPath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import lockResetIcon from '../assets/icons/lock_reset.svg'
|
||||
import deleteIcon from '../assets/icons/delete.svg'
|
||||
@@ -423,11 +424,11 @@ watch(
|
||||
(name) => {
|
||||
activeTab.value = tabFromAdminRoute(name)
|
||||
if (name === 'adminGames') {
|
||||
const nextGameId = typeof route.query.gameId === 'string' ? route.query.gameId : ''
|
||||
if (nextGameId && nextGameId !== selectedTemplateId.value) {
|
||||
selectedTemplateId.value = nextGameId
|
||||
const nextTopicId = typeof route.query.topicId === 'string' ? route.query.topicId : ''
|
||||
if (nextTopicId && nextTopicId !== selectedTemplateId.value) {
|
||||
selectedTemplateId.value = nextTopicId
|
||||
queueMicrotask(() => {
|
||||
if (selectedTemplateId.value === nextGameId) void loadTemplate()
|
||||
if (selectedTemplateId.value === nextTopicId) void loadTemplate()
|
||||
})
|
||||
}
|
||||
return
|
||||
@@ -435,8 +436,8 @@ watch(
|
||||
if (name === 'adminTierlists') {
|
||||
const nextMode = route.query.mode === 'all' ? 'all' : 'requests'
|
||||
if (tierlistsMode.value !== nextMode) tierlistsMode.value = nextMode
|
||||
const nextTierListGameId = typeof route.query.gameId === 'string' ? route.query.gameId : ''
|
||||
if (adminTierListGameId.value !== nextTierListGameId) adminTierListGameId.value = nextTierListGameId
|
||||
const nextTierListTopicId = typeof route.query.topicId === 'string' ? route.query.topicId : ''
|
||||
if (adminTierListGameId.value !== nextTierListTopicId) adminTierListGameId.value = nextTierListTopicId
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -446,7 +447,7 @@ watch(
|
||||
() => selectedTemplateId.value,
|
||||
(templateId) => {
|
||||
if (route.name !== 'adminGames') return
|
||||
syncAdminRouteQuery({ gameId: templateId || undefined })
|
||||
syncAdminRouteQuery({ topicId: templateId || undefined })
|
||||
}
|
||||
)
|
||||
|
||||
@@ -464,16 +465,16 @@ watch(
|
||||
if (route.name !== 'adminTierlists') return
|
||||
syncAdminRouteQuery({
|
||||
mode: mode === 'all' ? 'all' : undefined,
|
||||
gameId: mode === 'all' && adminTierListGameId.value ? adminTierListGameId.value : undefined,
|
||||
topicId: mode === 'all' && adminTierListGameId.value ? adminTierListGameId.value : undefined,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => adminTierListGameId.value,
|
||||
(gameId) => {
|
||||
(topicId) => {
|
||||
if (route.name !== 'adminTierlists' || tierlistsMode.value !== 'all') return
|
||||
syncAdminRouteQuery({ gameId: gameId || undefined })
|
||||
syncAdminRouteQuery({ topicId: topicId || undefined })
|
||||
}
|
||||
)
|
||||
|
||||
@@ -732,7 +733,7 @@ function setTab(tab) {
|
||||
if (nextRouteName && route.name !== nextRouteName) {
|
||||
const nextQuery =
|
||||
tab === 'game-admin'
|
||||
? { gameId: selectedTemplateId.value || undefined }
|
||||
? { topicId: selectedTemplateId.value || undefined }
|
||||
: tab === 'tierlists' && tierlistsMode.value === 'all'
|
||||
? { mode: 'all' }
|
||||
: {}
|
||||
@@ -757,10 +758,10 @@ function setTierlistsMode(mode) {
|
||||
|
||||
function openTemplateCreateModal() {
|
||||
resetMessages()
|
||||
if (activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetGameId) {
|
||||
newTemplateId.value = activeTemplateRequest.value?.draftGameId || ''
|
||||
newTemplateName.value = activeTemplateRequest.value?.draftGameName || ''
|
||||
newTemplateIsPublic.value = !!activeTemplateRequest.value?.draftGameIsPublic
|
||||
if (activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetTopicId) {
|
||||
newTemplateId.value = activeTemplateRequest.value?.draftTopicId || ''
|
||||
newTemplateName.value = activeTemplateRequest.value?.draftTopicName || ''
|
||||
newTemplateIsPublic.value = !!activeTemplateRequest.value?.draftTopicIsPublic
|
||||
} else {
|
||||
newTemplateId.value = ''
|
||||
newTemplateName.value = ''
|
||||
@@ -786,8 +787,8 @@ async function selectAdminTemplate(templateId) {
|
||||
|
||||
async function refreshTemplates() {
|
||||
try {
|
||||
const data = await api.listGames()
|
||||
templates.value = data.games || []
|
||||
const data = await api.listTopics()
|
||||
templates.value = data.topics || []
|
||||
featuredTemplateIds.value = templates.value
|
||||
.filter((template) => template.displayRank != null)
|
||||
.sort((a, b) => a.displayRank - b.displayRank)
|
||||
@@ -821,7 +822,7 @@ async function refreshAdminTierLists() {
|
||||
try {
|
||||
const data = await api.listAdminTierLists({
|
||||
q: adminTierListQuery.value,
|
||||
gameId: adminTierListGameId.value,
|
||||
topicId: adminTierListGameId.value,
|
||||
page: adminTierListPage.value,
|
||||
limit: adminTierListLimit.value,
|
||||
})
|
||||
@@ -838,7 +839,7 @@ async function refreshAdminTierLists() {
|
||||
async function refreshAdminTierListStats() {
|
||||
if (!auth.user?.isAdmin) return
|
||||
try {
|
||||
const data = await api.getAdminTierListStats({ q: adminTierListQuery.value, gameId: adminTierListGameId.value })
|
||||
const data = await api.getAdminTierListStats({ q: adminTierListQuery.value, topicId: adminTierListGameId.value })
|
||||
adminTierListStats.value = {
|
||||
total: data.total || 0,
|
||||
publicCount: data.publicCount || 0,
|
||||
@@ -856,7 +857,7 @@ async function refreshSelectedTemplateTierListStats(templateId = '') {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.getAdminTierListStats({ gameId: templateId })
|
||||
const data = await api.getAdminTierListStats({ topicId: templateId })
|
||||
selectedTemplateTierListStats.value = {
|
||||
total: data.total || 0,
|
||||
publicCount: data.publicCount || 0,
|
||||
@@ -873,15 +874,15 @@ async function refreshTemplateRequests() {
|
||||
const data = await api.listAdminTemplateRequests()
|
||||
templateRequests.value = (data.requests || []).map((request) => ({
|
||||
...request,
|
||||
draftGameId:
|
||||
draftTopicId:
|
||||
request.type === 'create'
|
||||
? ('tmpl-' + String(request.id || 'request').replace(/[^a-zA-Z0-9]+/g, '').slice(0, 12).toLowerCase())
|
||||
: request.targetGameId || request.sourceGameId || '',
|
||||
draftGameName:
|
||||
: request.targetTopicId || request.sourceTopicId || '',
|
||||
draftTopicName:
|
||||
request.type === 'create'
|
||||
? `${request.sourceTierListTitle || request.sourceGameName || '새 템플릿'}`
|
||||
: request.targetGameName || request.sourceGameName || '',
|
||||
draftGameIsPublic: false,
|
||||
? `${request.sourceTierListTitle || request.sourceTopicName || '새 템플릿'}`
|
||||
: request.targetTopicName || request.sourceTopicName || '',
|
||||
draftTopicIsPublic: false,
|
||||
}))
|
||||
} catch (e) {
|
||||
error.value = '템플릿 요청 목록을 불러오지 못했어요.'
|
||||
@@ -1147,7 +1148,7 @@ async function uploadThumbnail() {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('thumbnail', thumbFile.value)
|
||||
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}/thumbnail`), {
|
||||
const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/thumbnail`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
@@ -1169,18 +1170,19 @@ async function saveTemplateVisibility() {
|
||||
if (!selectedTemplate.value?.game?.id) return
|
||||
try {
|
||||
gameVisibilitySaving.value = true
|
||||
const data = await api.updateAdminGame(selectedTemplate.value.game.id, {
|
||||
const data = await api.updateAdminTemplate(selectedTemplate.value.game.id, {
|
||||
isPublic: !!selectedTemplate.value.game.isPublic,
|
||||
})
|
||||
const nextTemplate = data.template || {}
|
||||
selectedTemplate.value = {
|
||||
...selectedTemplate.value,
|
||||
game: {
|
||||
...selectedTemplate.value.game,
|
||||
...data.game,
|
||||
...nextTemplate,
|
||||
},
|
||||
}
|
||||
await refreshTemplates()
|
||||
success.value = data.game?.isPublic ? '템플릿을 공개 상태로 전환했어요.' : '템플릿을 비공개 상태로 전환했어요.'
|
||||
success.value = nextTemplate?.isPublic ? '템플릿을 공개 상태로 전환했어요.' : '템플릿을 비공개 상태로 전환했어요.'
|
||||
return true
|
||||
} catch (e) {
|
||||
error.value = '템플릿 공개 상태를 저장하지 못했어요.'
|
||||
@@ -1214,9 +1216,27 @@ async function toggleSelectedTemplateVisibility(nextValue) {
|
||||
|
||||
async function removeTemplateItem(itemId) {
|
||||
resetMessages()
|
||||
if (!selectedTemplateId.value) return
|
||||
try {
|
||||
const usageRes = await fetch(
|
||||
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}/usage`),
|
||||
{
|
||||
credentials: 'include',
|
||||
}
|
||||
)
|
||||
if (!usageRes.ok) throw new Error('usage_failed')
|
||||
|
||||
const usageData = await usageRes.json()
|
||||
const usage = usageData?.usage || { totalCount: 0, publicCount: 0, privateCount: 0 }
|
||||
const impactMessage = usage.totalCount
|
||||
? `이 아이템은 이미 저장된 티어표 ${usage.totalCount}개(공개 ${usage.publicCount}개, 비공개 ${usage.privateCount}개)에서 사용 중이에요.\n기존 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외됩니다.\n정말 삭제할까요?`
|
||||
: '이 기본 아이템을 삭제할까요?\n기존 저장 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외됩니다.'
|
||||
const ok = window.confirm(impactMessage)
|
||||
if (!ok) return
|
||||
|
||||
const previousScrollY = window.scrollY
|
||||
const res = await fetch(
|
||||
toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}`),
|
||||
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}`),
|
||||
{
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
@@ -1225,6 +1245,8 @@ async function removeTemplateItem(itemId) {
|
||||
if (!res.ok) throw new Error('failed')
|
||||
|
||||
await loadTemplate()
|
||||
await nextTick()
|
||||
window.scrollTo({ top: previousScrollY, behavior: 'auto' })
|
||||
success.value = '템플릿 기본 아이템을 삭제했어요.'
|
||||
} catch (e) {
|
||||
error.value = '템플릿 기본 아이템 삭제에 실패했어요.'
|
||||
@@ -1243,7 +1265,7 @@ async function saveTemplateItemLabel(item) {
|
||||
|
||||
try {
|
||||
item.isSavingLabel = true
|
||||
const data = await api.updateAdminGameItem(selectedTemplateId.value, item.id, { label: nextLabel })
|
||||
const data = await api.updateAdminTemplateItem(selectedTemplateId.value, item.id, { label: nextLabel })
|
||||
item.label = data.item.label
|
||||
item.draftLabel = data.item.label
|
||||
success.value = '기본 아이템 이름을 수정했어요.'
|
||||
@@ -1262,7 +1284,7 @@ async function removeTemplate() {
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}`), {
|
||||
const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}`), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
@@ -1284,8 +1306,8 @@ function submitAdminTierListSearch() {
|
||||
refreshAdminTierLists()
|
||||
}
|
||||
|
||||
function setAdminTierListGameId(gameId) {
|
||||
adminTierListGameId.value = gameId || ''
|
||||
function setAdminTierListGameId(topicId) {
|
||||
adminTierListGameId.value = topicId || ''
|
||||
adminTierListPage.value = 1
|
||||
refreshAdminTierLists()
|
||||
}
|
||||
@@ -1529,8 +1551,8 @@ function closePreviewModal() {
|
||||
}
|
||||
|
||||
function previewTierListUrl(tierList) {
|
||||
if (!tierList?.gameId || !tierList?.id) return ''
|
||||
return `/editor/${tierList.gameId}/${tierList.id}?preview=1`
|
||||
if (!tierList?.topicId || !tierList?.id) return ''
|
||||
return editorPath(tierList.topicId, tierList.id, { preview: true })
|
||||
}
|
||||
|
||||
function openTierListImportModal(tierList, items) {
|
||||
@@ -1545,9 +1567,9 @@ function openTierListImportModal(tierList, items) {
|
||||
importModalItems.value = nextItems
|
||||
importModalMode.value = 'existing'
|
||||
importModalTargetTemplateId.value = ''
|
||||
importModalNewTemplateId.value = tierList.gameId === 'freeform' ? '' : `${tierList.gameId}-copy`
|
||||
importModalNewTemplateId.value = tierList.topicId === 'freeform' ? '' : `${tierList.topicId}-copy`
|
||||
importModalNewTemplateName.value =
|
||||
tierList.gameId === 'freeform' ? `${tierList.title} 템플릿` : `${tierList.gameName || tierList.gameId} 파생 템플릿`
|
||||
tierList.topicId === 'freeform' ? `${tierList.title} 템플릿` : `${tierList.topicName || tierList.topicId} 파생 템플릿`
|
||||
importModalOpen.value = true
|
||||
}
|
||||
|
||||
@@ -1575,26 +1597,26 @@ async function confirmTierListImport() {
|
||||
}
|
||||
|
||||
const data = await api.promoteAdminTierListItems(tierList.id, {
|
||||
gameId: importModalTargetTemplateId.value,
|
||||
topicId: importModalTargetTemplateId.value,
|
||||
itemIds,
|
||||
})
|
||||
if (selectedTemplateId.value === importModalTargetTemplateId.value) await loadTemplate()
|
||||
success.value = `${data.items?.length || 0}개의 아이템을 기존 템플릿에 추가했어요.`
|
||||
} else {
|
||||
const nextGameId = (importModalNewTemplateId.value || '').trim()
|
||||
const nextGameName = (importModalNewTemplateName.value || '').trim()
|
||||
if (!nextGameId || !nextGameName) {
|
||||
const nextTopicId = (importModalNewTemplateId.value || '').trim()
|
||||
const nextTopicName = (importModalNewTemplateName.value || '').trim()
|
||||
if (!nextTopicId || !nextTopicName) {
|
||||
error.value = '새 템플릿 ID와 이름을 모두 입력해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
const data = await api.createAdminGameTemplateFromTierList(tierList.id, {
|
||||
gameId: nextGameId,
|
||||
name: nextGameName,
|
||||
const data = await api.createAdminTemplateFromTierList(tierList.id, {
|
||||
topicId: nextTopicId,
|
||||
name: nextTopicName,
|
||||
itemIds,
|
||||
})
|
||||
await refreshTemplates()
|
||||
success.value = `"${data.game?.name || nextGameName}" 템플릿을 생성했어요.`
|
||||
success.value = `"${data.template?.name || nextTopicName}" 템플릿을 생성했어요.`
|
||||
}
|
||||
|
||||
closeTierListImportModal()
|
||||
@@ -1609,12 +1631,12 @@ function templateRequestTypeLabel(request) {
|
||||
|
||||
function templateRequestTargetLabel(request) {
|
||||
if (request.type === 'create') {
|
||||
if (request.targetGameName || request.targetGameId) {
|
||||
return `연결된 템플릿 · ${request.targetGameName || request.targetGameId}`
|
||||
if (request.targetTopicName || request.targetTopicId) {
|
||||
return `연결된 템플릿 · ${request.targetTopicName || request.targetTopicId}`
|
||||
}
|
||||
return '연결된 템플릿 없음'
|
||||
}
|
||||
return request.targetGameName || request.targetGameId || request.sourceGameName
|
||||
return request.targetTopicName || request.targetTopicId || request.sourceTopicName
|
||||
}
|
||||
|
||||
const displayThumbnailUrl = computed(() => {
|
||||
@@ -2073,7 +2095,7 @@ function userAvatarFallback(user) {
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">티어표 관리</div>
|
||||
<div class="modalCard__desc">
|
||||
{{ modalTargetAdminTierList ? `${modalTargetAdminTierList.gameName || modalTargetAdminTierList.gameId} · ${tierListAuthorDisplayName(modalTargetAdminTierList)}` : '' }}
|
||||
{{ modalTargetAdminTierList ? `${modalTargetAdminTierList.topicName || modalTargetAdminTierList.topicId} · ${tierListAuthorDisplayName(modalTargetAdminTierList)}` : '' }}
|
||||
</div>
|
||||
<div class="modalCard__form">
|
||||
<label class="field">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { editorPath, loginPath } from '../lib/paths'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -42,12 +43,12 @@ async function loadFavorites() {
|
||||
favorites.value = data.tierLists || []
|
||||
} catch (e) {
|
||||
toast.error('로그인이 필요해요.')
|
||||
router.push('/login?redirect=/favorites')
|
||||
router.push(loginPath('/favorites'))
|
||||
}
|
||||
}
|
||||
|
||||
function openTierList(tierList) {
|
||||
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
||||
router.push(editorPath(tierList.topicId, tierList.id))
|
||||
}
|
||||
|
||||
onMounted(loadFavorites)
|
||||
|
||||
@@ -3,13 +3,14 @@ import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { editorNewPath, editorPath, loginPath } from '../lib/paths'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const topicId = computed(() => route.params.gameId)
|
||||
const topicId = computed(() => route.params.topicId)
|
||||
const topicName = ref('')
|
||||
const tierLists = ref([])
|
||||
const error = ref('')
|
||||
@@ -52,11 +53,11 @@ function handleThumbnailError(tierListId) {
|
||||
async function loadTierLists() {
|
||||
isTopicLoading.value = true
|
||||
try {
|
||||
const [gameRes, listRes] = await Promise.all([
|
||||
api.getGame(topicId.value),
|
||||
api.searchPublicTierLists(topicId.value, query.value),
|
||||
const [topicRes, listRes] = await Promise.all([
|
||||
api.getTopic(topicId.value),
|
||||
api.searchPublicTierListsByTopic(topicId.value, query.value),
|
||||
])
|
||||
topicName.value = gameRes.game?.name || ''
|
||||
topicName.value = topicRes.topic?.name || ''
|
||||
brokenThumbnailIds.value = {}
|
||||
tierLists.value = listRes.tierLists || []
|
||||
} catch (e) {
|
||||
@@ -67,15 +68,16 @@ async function loadTierLists() {
|
||||
}
|
||||
|
||||
function createNew() {
|
||||
const target = editorNewPath(topicId.value)
|
||||
if (!auth.user) {
|
||||
router.push(`/login?redirect=/editor/${topicId.value}/new`)
|
||||
router.push(loginPath(target))
|
||||
return
|
||||
}
|
||||
router.push(`/editor/${topicId.value}/new`)
|
||||
router.push(target)
|
||||
}
|
||||
|
||||
function openTierList(id) {
|
||||
router.push(`/editor/${topicId.value}/${id}`)
|
||||
router.push(editorPath(topicId.value, id))
|
||||
}
|
||||
|
||||
function submitSearch() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api } from '../lib/api'
|
||||
import SvgIcon from '../components/SvgIcon.vue'
|
||||
import kidStarIcon from '../assets/icons/kid_star.svg'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { loginPath, topicPath } from '../lib/paths'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -35,8 +36,8 @@ const templates = computed(() => {
|
||||
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const data = await api.listGames()
|
||||
templateRecords.value = data.games || []
|
||||
const data = await api.listTopics()
|
||||
templateRecords.value = data.topics || []
|
||||
} catch (e) {
|
||||
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
||||
}
|
||||
@@ -46,21 +47,21 @@ onMounted(loadTemplates)
|
||||
watch(() => auth.user?.id, loadTemplates)
|
||||
|
||||
function openTopic(templateId) {
|
||||
router.push(`/topics/${templateId}`)
|
||||
router.push(topicPath(templateId))
|
||||
}
|
||||
|
||||
async function toggleFavorite(template, event) {
|
||||
event?.stopPropagation()
|
||||
if (!auth.user) {
|
||||
router.push(`/login?redirect=${encodeURIComponent(route.fullPath || '/')}`)
|
||||
router.push(loginPath(route.fullPath || '/'))
|
||||
return
|
||||
}
|
||||
if (!template?.id || loadingFavoriteId.value === template.id) return
|
||||
|
||||
try {
|
||||
loadingFavoriteId.value = template.id
|
||||
const res = template.isFavorited ? await api.unfavoriteGame(template.id) : await api.favoriteGame(template.id)
|
||||
templateRecords.value = templateRecords.value.map((entry) => (entry.id === template.id ? { ...entry, ...res.game } : entry))
|
||||
const res = template.isFavorited ? await api.unfavoriteTopic(template.id) : await api.favoriteTopic(template.id)
|
||||
templateRecords.value = templateRecords.value.map((entry) => (entry.id === template.id ? { ...entry, ...(res.topic || {}) } : entry))
|
||||
} catch (e) {
|
||||
error.value = '즐겨찾기 변경에 실패했어요.'
|
||||
} finally {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { api } from '../lib/api'
|
||||
import { homePath, mePath } from '../lib/paths'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -36,7 +37,7 @@ const checkingSession = computed(() => !authReady.value || auth.status === 'load
|
||||
onMounted(async () => {
|
||||
if (!auth.hydrated) await auth.refresh()
|
||||
if (auth.user) {
|
||||
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
|
||||
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -51,7 +52,7 @@ watch(
|
||||
() => [auth.hydrated, auth.user],
|
||||
([hydrated, user]) => {
|
||||
if (!hydrated || !user) return
|
||||
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
|
||||
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -65,7 +66,7 @@ async function submit() {
|
||||
try {
|
||||
if (mode.value === 'signup') await auth.signup(email.value, password.value)
|
||||
else await auth.login(email.value, password.value)
|
||||
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
|
||||
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
|
||||
} catch (e) {
|
||||
error.value = '로그인/회원가입에 실패했어요.'
|
||||
}
|
||||
@@ -133,7 +134,7 @@ async function submit() {
|
||||
<div v-if="!hasUsers" class="roleBadge">첫 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
|
||||
|
||||
<div class="authActions">
|
||||
<button class="secondaryAction" type="button" @click="router.push('/')">취소</button>
|
||||
<button class="secondaryAction" type="button" @click="router.push(homePath())">취소</button>
|
||||
<button class="primaryAction" type="submit">{{ submitLabel }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { editorPath, loginPath } from '../lib/paths'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -54,14 +55,12 @@ onMounted(async () => {
|
||||
myLists.value = data.tierLists || []
|
||||
} catch (e) {
|
||||
toast.error('로그인이 필요해요.')
|
||||
router.push('/login?redirect=/me')
|
||||
router.push(loginPath('/me'))
|
||||
}
|
||||
})
|
||||
|
||||
function openList(t) {
|
||||
router.push(
|
||||
"/editor/" + t.gameId + "/" + t.id,
|
||||
)
|
||||
router.push(editorPath(t.topicId, t.id))
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { homePath, loginPath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
@@ -40,7 +41,7 @@ const displayInitial = computed(() => {
|
||||
onMounted(async () => {
|
||||
if (!auth.hydrated) await auth.refresh()
|
||||
if (!auth.user) {
|
||||
router.replace('/login')
|
||||
router.replace(loginPath())
|
||||
return
|
||||
}
|
||||
nickname.value = auth.user?.nickname || ''
|
||||
@@ -112,7 +113,7 @@ async function saveProfile() {
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
toast.success('로그아웃했어요.')
|
||||
router.push('/')
|
||||
router.push(homePath())
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { editorPath } from '../lib/paths'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -37,7 +38,7 @@ function tierListThumbnailUrl(tierList) {
|
||||
}
|
||||
|
||||
function openTierList(tierList) {
|
||||
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
||||
router.push(editorPath(tierList.topicId, tierList.id))
|
||||
}
|
||||
|
||||
async function loadResults() {
|
||||
|
||||
@@ -9,6 +9,7 @@ import addRowBelowIcon from '../assets/icons/add_row_below.svg'
|
||||
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
|
||||
import shareIcon from '../assets/icons/share.svg'
|
||||
import { api } from '../lib/api'
|
||||
import { editorNewPath, editorPath, loginPath, mePath, topicPath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/useToast'
|
||||
@@ -19,7 +20,7 @@ const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const globalRightRailOpen = inject('rightRailOpen', ref(true))
|
||||
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
|
||||
const templateId = computed(() => route.params.gameId)
|
||||
const templateId = computed(() => route.params.topicId)
|
||||
const tierListId = computed(() => route.params.tierListId)
|
||||
const previewMode = computed(() => route.query.preview === '1')
|
||||
const templateName = ref('')
|
||||
@@ -134,8 +135,8 @@ const templateRequestTargetLabel = computed(() => (templateId.value === 'freefor
|
||||
const shareTierListUrl = computed(() => {
|
||||
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
||||
if (!savedTierListId) return ''
|
||||
if (typeof window === 'undefined') return `/editor/${templateId.value}/${savedTierListId}?preview=1`
|
||||
return new URL(`/editor/${templateId.value}/${savedTierListId}?preview=1`, window.location.origin).toString()
|
||||
if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true })
|
||||
return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString()
|
||||
})
|
||||
|
||||
watch(error, (message) => {
|
||||
@@ -672,7 +673,7 @@ function buildPayload(existingId) {
|
||||
const finalTitle = effectiveTitle.value
|
||||
return {
|
||||
id: existingId || undefined,
|
||||
gameId: templateId.value,
|
||||
topicId: templateId.value,
|
||||
title: finalTitle,
|
||||
thumbnailSrc: thumbnailSrc.value || '',
|
||||
description: (description.value || '').trim(),
|
||||
@@ -697,7 +698,7 @@ async function persistTierList({ showModal = false } = {}) {
|
||||
persistedTierListId.value = savedTierListId || ''
|
||||
title.value = res.tierList?.title || payload.title
|
||||
if (tierListId.value === 'new' && res.tierList?.id) {
|
||||
await router.replace(`/editor/${templateId.value}/${res.tierList.id}`)
|
||||
await router.replace(editorPath(templateId.value, res.tierList.id))
|
||||
}
|
||||
updatedAt.value = Number(res.tierList?.updatedAt || Date.now())
|
||||
authorName.value = res.tierList?.authorName || effectiveAuthorName.value
|
||||
@@ -793,7 +794,7 @@ async function confirmDeleteTierList() {
|
||||
await api.deleteTierList(currentTierListId)
|
||||
closeDeleteModal()
|
||||
toast.success('티어표를 삭제했어요.')
|
||||
router.push(templateId.value === 'freeform' ? '/me' : `/topics/${templateId.value}`)
|
||||
router.push(templateId.value === 'freeform' ? mePath() : topicPath(templateId.value))
|
||||
} catch (e) {
|
||||
error.value = '티어표 삭제에 실패했어요.'
|
||||
} finally {
|
||||
@@ -808,7 +809,7 @@ async function duplicateCurrentTierList() {
|
||||
const duplicatedId = data.tierList?.id
|
||||
if (!duplicatedId) throw new Error('duplicate_failed')
|
||||
toast.success('티어표를 복사해 내 작업으로 가져왔어요.')
|
||||
router.push(`/editor/${templateId.value}/${duplicatedId}`)
|
||||
router.push(editorPath(templateId.value, duplicatedId))
|
||||
} catch (e) {
|
||||
error.value = '티어표 복사에 실패했어요.'
|
||||
}
|
||||
@@ -841,7 +842,7 @@ async function requestTemplate(type) {
|
||||
await api.requestTierListTemplate({
|
||||
type,
|
||||
sourceTierListId: sourceId,
|
||||
gameId: templateId.value,
|
||||
topicId: templateId.value,
|
||||
requestTitle: templateRequestDraftTitle.value.trim(),
|
||||
requestDescription: templateRequestDraftDescription.value.trim(),
|
||||
thumbnailSrc: saved.tierList?.thumbnailSrc || thumbnailSrc.value || '',
|
||||
@@ -892,14 +893,14 @@ onMounted(() => {
|
||||
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
|
||||
|
||||
if (isNewTierList.value && !auth.user) {
|
||||
router.replace(`/login?redirect=/editor/${templateId.value}/new`)
|
||||
router.replace(loginPath(editorNewPath(templateId.value)))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const gameRes = await api.getGame(templateId.value)
|
||||
templateName.value = gameRes.game?.name || templateId.value
|
||||
const base = (gameRes.items || []).map((img) => ({
|
||||
const topicRes = await api.getTopic(templateId.value)
|
||||
templateName.value = topicRes.topic?.name || templateId.value
|
||||
const base = (topicRes.items || []).map((img) => ({
|
||||
id: img.id,
|
||||
src: img.src,
|
||||
label: img.label,
|
||||
@@ -1131,7 +1132,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div v-if="sourceTierListId" class="editorMain__sourceNote">
|
||||
<span>복사본</span>
|
||||
<button class="editorMain__sourceLink" type="button" @click="router.push(`/editor/${templateId}/${sourceTierListId}`)">{{ copiedFromLabel }}</button>
|
||||
<button class="editorMain__sourceLink" type="button" @click="router.push(editorPath(templateId, sourceTierListId))">{{ copiedFromLabel }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user