Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79a187d120 | |||
| 0a87e3b1ec | |||
| 136db137ec | |||
| 1fabf66f04 | |||
| 9b97a7c23b | |||
| 9b0a6d8f15 | |||
| 5af5202455 | |||
| 6b6676ceec | |||
| de640de4a1 | |||
| 20955e277c | |||
| 1ed08d1e34 | |||
| a733c97991 | |||
| 31613e4613 | |||
| d5621362f1 | |||
| caaddb8448 | |||
| 20186f7fe2 | |||
| 77605791fb | |||
| 9bb64b52f3 | |||
| c4d896ce36 | |||
| 1957f30341 | |||
| 8d257e21ff | |||
| 19fdf85dcc | |||
| 2626fe2335 | |||
| 074d028f04 | |||
| 208e9709f8 | |||
| 4ed7f275ba | |||
| 88ce413c31 | |||
| 7f7475fb20 | |||
| 8a44b51cce | |||
| 9d63ed2e76 | |||
| 99eb79f2c3 | |||
| 6b8abea203 | |||
| d692798358 | |||
| 49d4946735 | |||
| bd53cf96dc | |||
| 66c3b1e7b7 | |||
| 494f04d9a7 | |||
| 5aae278fd3 | |||
| 14dfe0ad75 | |||
| a7cfb97131 | |||
| badf250967 | |||
| a16b1e1025 | |||
| c1dfea41a5 | |||
| 188576f8ac | |||
| 5db1e57f13 | |||
| 2918a0423c | |||
| b542b963d2 |
@@ -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/topics', gamesRoutes)
|
||||
app.use('/api/tierlists', tierListsRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
updateImageAssetLabel,
|
||||
deleteGameItem,
|
||||
deleteGame,
|
||||
deleteTierList,
|
||||
updateGameDisplayOrder,
|
||||
listCustomItems,
|
||||
findCustomItemById,
|
||||
@@ -31,7 +32,9 @@ const {
|
||||
listUsers,
|
||||
findPrimaryAdminUser,
|
||||
listAdminTierLists,
|
||||
summarizeAdminTierLists,
|
||||
findTierListById,
|
||||
updateAdminTierListMeta,
|
||||
listAdminTemplateRequests,
|
||||
findTemplateRequestById,
|
||||
updateTemplateRequestStatus,
|
||||
@@ -44,12 +47,17 @@ const {
|
||||
getImageAssetStats,
|
||||
listRecentImageOptimizationJobs,
|
||||
clearImageOptimizationJobs,
|
||||
cleanupMissingUploadReferences,
|
||||
} = require('../db')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
||||
|
||||
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 : ''
|
||||
@@ -106,7 +114,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),
|
||||
@@ -122,24 +130,26 @@ router.post('/games', requireAdmin, async (req, res) => {
|
||||
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
|
||||
await updateGameThumbnail(game.id, copiedThumb)
|
||||
}
|
||||
res.json({ game: await findGameById(game.id) })
|
||||
const template = await findGameById(game.id)
|
||||
res.json({ game: template, template })
|
||||
})
|
||||
|
||||
router.patch('/games/:gameId', requireAdmin, async (req, res) => {
|
||||
router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
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)
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const game = await findGameById(templateId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const updated = await updateGameVisibility(game.id, parsed.data.isPublic)
|
||||
res.json({ game: updated })
|
||||
res.json({ game: updated, template: updated })
|
||||
})
|
||||
|
||||
router.patch('/games/display-order', requireAdmin, async (req, res) => {
|
||||
router.patch(['/games/display-order', '/templates/display-order'], requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameIds: z.array(z.string().min(1)).max(50),
|
||||
})
|
||||
@@ -150,26 +160,28 @@ router.patch('/games/display-order', requireAdmin, async (req, res) => {
|
||||
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 })
|
||||
res.json({ games: updatedGames, templates: updatedGames })
|
||||
})
|
||||
|
||||
router.patch('/games/:gameId/items/display-order', requireAdmin, async (req, res) => {
|
||||
router.patch(['/games/:gameId/items/display-order', '/templates/:templateId/items/display-order'], requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
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)
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const game = await findGameById(templateId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const items = await updateGameItemDisplayOrder(game.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)
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const game = await findGameById(templateId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const optimized = await writeOptimizedImage({
|
||||
@@ -181,14 +193,15 @@ 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 updateGameThumbnail(templateId, optimized.src)
|
||||
res.json({ game: updated, template: updated })
|
||||
})
|
||||
|
||||
router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), async (req, res) => {
|
||||
router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireAdmin, upload.array('images', 50), async (req, res) => {
|
||||
const files = Array.isArray(req.files) ? req.files : []
|
||||
if (!files.length) return res.status(400).json({ error: 'file_required' })
|
||||
const game = await findGameById(req.params.gameId)
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const game = await findGameById(templateId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const labelsRaw = req.body?.labels
|
||||
@@ -219,19 +232,19 @@ 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)
|
||||
router.delete(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
|
||||
const game = await findGameById(getTemplateIdParam(req))
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteGameItem(req.params.itemId)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
router.patch('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
|
||||
const schema = z.object({ label: z.string().trim().min(1).max(60) })
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const game = await findGameById(req.params.gameId)
|
||||
const game = await findGameById(getTemplateIdParam(req))
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const updated = await updateGameItemLabel(req.params.itemId, parsed.data.label)
|
||||
@@ -239,10 +252,11 @@ router.patch('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
res.json({ item: updated })
|
||||
})
|
||||
|
||||
router.delete('/games/:gameId', requireAdmin, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
router.delete(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
|
||||
const templateId = getTemplateIdParam(req)
|
||||
const game = await findGameById(templateId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteGame(req.params.gameId)
|
||||
await deleteGame(templateId)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
@@ -277,11 +291,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
q: 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),
|
||||
orphanOnly: z
|
||||
.union([z.literal('true'), z.literal('false'), z.boolean()])
|
||||
.optional()
|
||||
.default('false')
|
||||
.transform((value) => value === true || value === 'true'),
|
||||
filter: z.enum(['all', 'user', 'template', 'asset', 'unused-user', 'unused-admin']).optional().default('all'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -290,7 +300,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
queryText: parsed.data.q,
|
||||
page: parsed.data.page,
|
||||
limit: parsed.data.limit,
|
||||
orphanOnly: parsed.data.orphanOnly,
|
||||
filterMode: parsed.data.filter,
|
||||
})
|
||||
res.json(result)
|
||||
})
|
||||
@@ -298,6 +308,8 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
topicId: z.string().trim().max(120).optional().default(''),
|
||||
gameId: z.string().trim().max(120).optional().default(''),
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||
})
|
||||
@@ -306,6 +318,8 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
|
||||
const result = await listAdminTierLists({
|
||||
queryText: parsed.data.q,
|
||||
topicId: parsed.data.topicId || parsed.data.gameId,
|
||||
gameId: parsed.data.gameId,
|
||||
page: parsed.data.page,
|
||||
limit: parsed.data.limit,
|
||||
currentUserId: req.session?.userId || '',
|
||||
@@ -313,6 +327,23 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.get('/tierlists/stats', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
topicId: z.string().trim().max(120).optional().default(''),
|
||||
gameId: z.string().trim().max(120).optional().default(''),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const result = await summarizeAdminTierLists({
|
||||
queryText: parsed.data.q,
|
||||
topicId: parsed.data.topicId || parsed.data.gameId,
|
||||
gameId: parsed.data.gameId,
|
||||
})
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.get('/template-requests', requireAdmin, async (req, res) => {
|
||||
const requests = await listAdminTemplateRequests({ statuses: ['pending', 'reviewing'] })
|
||||
res.json({ requests })
|
||||
@@ -390,6 +421,11 @@ router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => {
|
||||
res.json({ deletedCount })
|
||||
})
|
||||
|
||||
router.post('/image-assets/missing/cleanup', requireAdmin, async (req, res) => {
|
||||
const result = await cleanupMissingUploadReferences()
|
||||
res.json({ result })
|
||||
})
|
||||
|
||||
async function removeUploadFiles(srcs) {
|
||||
await Promise.all(
|
||||
(srcs || []).map(async (src) => {
|
||||
@@ -571,7 +607,7 @@ async function createGameTemplateFromRequest({ templateRequest, gameId, gameName
|
||||
}
|
||||
|
||||
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
const result = await listCustomItems({ page: 1, limit: 10000, orphanOnly: false })
|
||||
const result = await listCustomItems({ page: 1, limit: 10000, filterMode: 'all' })
|
||||
const target = result.items.find((item) => item.id === req.params.itemId)
|
||||
if (!target) return res.status(404).json({ error: 'not_found' })
|
||||
if (target.sourceType === 'template') {
|
||||
@@ -675,6 +711,34 @@ router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.patch('/tierlists/:tierListId', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
title: z.string().trim().min(1).max(120),
|
||||
description: z.string().max(500).optional().default(''),
|
||||
isPublic: z.boolean(),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const tierList = await findTierListById(req.params.tierListId)
|
||||
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const updated = await updateAdminTierListMeta({
|
||||
id: tierList.id,
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description || '',
|
||||
isPublic: parsed.data.isPublic,
|
||||
})
|
||||
res.json({ tierList: updated })
|
||||
})
|
||||
|
||||
router.delete('/tierlists/:tierListId', requireAdmin, async (req, res) => {
|
||||
const tierList = await findTierListById(req.params.tierListId)
|
||||
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteTierList(tierList.id)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
router.post('/template-requests/:requestId/approve', requireAdmin, async (req, res) => {
|
||||
const templateRequest = await findTemplateRequestById(req.params.requestId)
|
||||
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
@@ -6,7 +6,7 @@ const router = express.Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const games = await listGames(req.session?.userId || '', { includePrivate: !!req.session?.isAdmin })
|
||||
res.json({ games })
|
||||
res.json({ games, topics: games })
|
||||
})
|
||||
|
||||
router.post('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
@@ -15,7 +15,7 @@ router.post('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
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 })
|
||||
res.json({ game: updated, topic: updated })
|
||||
})
|
||||
|
||||
router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
@@ -24,14 +24,14 @@ router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
await unfavoriteGame({ userId: req.session.userId, gameId: game.id })
|
||||
const games = await listGames(req.session.userId)
|
||||
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: false }
|
||||
res.json({ game: updated })
|
||||
res.json({ game: updated, topic: updated })
|
||||
})
|
||||
|
||||
router.get('/:gameId', async (req, res) => {
|
||||
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 })
|
||||
res.json({ game: detail.game, topic: 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,8 @@ const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 }
|
||||
const templateRequestSchema = z.object({
|
||||
type: z.enum(['create', 'update']),
|
||||
sourceTierListId: z.string().max(64).optional().default(''),
|
||||
gameId: z.string().min(1).max(120),
|
||||
gameId: z.string().min(1).max(120).optional(),
|
||||
topicId: z.string().min(1).max(120).optional(),
|
||||
requestTitle: z.string().trim().min(1).max(120),
|
||||
requestDescription: z.string().trim().min(1).max(1000),
|
||||
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||
@@ -72,7 +73,11 @@ const templateRequestSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(16),
|
||||
itemIds: z.array(z.string()).optional().default([]),
|
||||
}).passthrough()
|
||||
}).passthrough().superRefine((value, ctx) => {
|
||||
if (!(value.topicId || value.gameId)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['topicId'] })
|
||||
}
|
||||
})
|
||||
),
|
||||
boardItems: z.array(
|
||||
z.object({
|
||||
@@ -86,12 +91,14 @@ const templateRequestSchema = z.object({
|
||||
|
||||
const tierListUpsertSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
gameId: z.string().min(1),
|
||||
gameId: z.string().min(1).optional(),
|
||||
topicId: z.string().min(1).optional(),
|
||||
title: z.string().min(1).max(120),
|
||||
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||
description: z.string().max(1000).optional().default(''),
|
||||
isPublic: z.boolean().default(false),
|
||||
showCharacterNames: z.boolean().optional().default(false),
|
||||
iconSize: z.number().int().min(48).max(112).optional().default(80),
|
||||
sourceTierListId: z.string().max(64).optional().default(''),
|
||||
sourceSnapshotTitle: z.string().max(120).optional().default(''),
|
||||
sourceSnapshotAuthor: z.string().max(120).optional().default(''),
|
||||
@@ -110,12 +117,16 @@ const tierListUpsertSchema = z.object({
|
||||
origin: z.enum(['game', 'custom']).default('game'),
|
||||
})
|
||||
),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (!(value.topicId || value.gameId)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'topicId_required', path: ['topicId'] })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/public', async (req, res) => {
|
||||
const gameId = req.query.gameId
|
||||
const topicId = typeof req.query.topicId === 'string' ? req.query.topicId : req.query.gameId
|
||||
const queryText = typeof req.query.q === 'string' ? req.query.q : ''
|
||||
const lists = await listPublicTierLists(gameId, req.session?.userId || '', queryText)
|
||||
const lists = await listPublicTierLists(topicId, req.session?.userId || '', queryText)
|
||||
res.json({ tierLists: lists })
|
||||
})
|
||||
|
||||
@@ -225,14 +236,15 @@ router.post('/template-request', requireAuth, async (req, res) => {
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const payload = parsed.data
|
||||
const topicId = payload.topicId || payload.gameId
|
||||
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
|
||||
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
|
||||
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
|
||||
|
||||
if (payload.type === 'create') {
|
||||
if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||
} else if (payload.gameId === FREEFORM_GAME_ID) {
|
||||
return res.status(400).json({ error: 'game_template_required' })
|
||||
if (topicId !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||
} else if (topicId === FREEFORM_TOPIC_ID) {
|
||||
return res.status(400).json({ error: 'topic_template_required' })
|
||||
}
|
||||
|
||||
let sourceTierList = null
|
||||
@@ -250,8 +262,10 @@ router.post('/template-request', requireAuth, async (req, res) => {
|
||||
type: payload.type,
|
||||
requesterId: req.session.userId,
|
||||
sourceTierListId: sourceTierList?.id || '',
|
||||
sourceGameId: payload.gameId,
|
||||
targetGameId: payload.type === 'update' ? payload.gameId : '',
|
||||
sourceGameId: topicId,
|
||||
sourceTopicId: topicId,
|
||||
targetGameId: payload.type === 'update' ? topicId : '',
|
||||
targetTopicId: payload.type === 'update' ? topicId : '',
|
||||
title: payload.requestTitle,
|
||||
description: payload.requestDescription,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
@@ -273,6 +287,7 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
const parsed = tierListUpsertSchema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
const payload = parsed.data
|
||||
const topicId = payload.topicId || payload.gameId
|
||||
const normalizedPool = payload.pool.map(normalizePoolItem)
|
||||
|
||||
let existing = null
|
||||
@@ -283,12 +298,14 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
const updated = await saveTierList({
|
||||
id: existing.id,
|
||||
authorId: existing.authorId,
|
||||
gameId: existing.gameId,
|
||||
gameId: existing.topicId || existing.gameId,
|
||||
topicId: existing.topicId || existing.gameId,
|
||||
title: payload.title,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
description: payload.description || '',
|
||||
isPublic: !!payload.isPublic,
|
||||
showCharacterNames: !!payload.showCharacterNames,
|
||||
iconSize: Number(payload.iconSize || 80),
|
||||
sourceTierListId: payload.sourceTierListId || existing.sourceTierListId || '',
|
||||
sourceSnapshotTitle: payload.sourceSnapshotTitle || existing.sourceSnapshotTitle || '',
|
||||
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || existing.sourceSnapshotAuthor || '',
|
||||
@@ -301,12 +318,14 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
const created = await saveTierList({
|
||||
id: nanoid(),
|
||||
authorId: req.session.userId,
|
||||
gameId: payload.gameId,
|
||||
gameId: topicId,
|
||||
topicId,
|
||||
title: payload.title,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
description: payload.description || '',
|
||||
isPublic: !!payload.isPublic,
|
||||
showCharacterNames: !!payload.showCharacterNames,
|
||||
iconSize: Number(payload.iconSize || 80),
|
||||
sourceTierListId: payload.sourceTierListId || '',
|
||||
sourceSnapshotTitle: payload.sourceSnapshotTitle || '',
|
||||
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || '',
|
||||
|
||||
157
docs/history.md
@@ -1,5 +1,162 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 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` 문법을 계속 통일해 두는 편이, 이후 검색/필터 툴바를 더 붙이더라도 구조를 예측하기 쉽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.7
|
||||
- 주제 상세 컬렉션 화면도 즐겨찾기·나의 티어표와 같은 `pageHead` 문법으로 맞춰야, 네비게이션으로 이동하는 주요 화면들의 리듬이 더 자연스럽다고 판단했다.
|
||||
- 라우트 전환은 한 번에 `/games`를 없애기보다, 먼저 `/topics`를 기본 진입 경로로 세우고 기존 `/games`는 alias로 유지하는 점진 전환이 더 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.6
|
||||
- 내부 리네이밍 2단계는 관리자 화면처럼 상태와 액션이 많은 영역부터 정리해 두는 편이, 이후 `/games` 라우트와 API 계층을 손볼 때 위험을 줄이는 데 더 유리하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.5
|
||||
- 내부 리네이밍은 한 번에 API와 DB까지 건드리기보다, 홈·주제 화면·에디터처럼 영향 범위가 비교적 명확한 프런트 핵심 흐름부터 `game` 의존 이름을 줄여 나가는 편이 더 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.4
|
||||
- 용어 정리 마무리 단계에서는 눈에 잘 띄는 영어 헤더를 그대로 두기보다, 홈과 관리자처럼 진입 빈도가 높은 화면의 상단 라벨까지 한국어로 맞춰야 전체 제품 인상이 더 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.3
|
||||
- 용어 전환은 메뉴 타이틀만 바꾸는 것으로 끝나지 않고, 관리자 작업 중 반복해서 보게 되는 토스트와 확인창까지 맞춰야 실제 체감 일관성이 살아난다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.2
|
||||
- 용어 정리를 시작한 뒤에는 일부 화면만 바꾸는 것보다, 관리자 모달과 확인 메시지처럼 실제 운영 중 많이 보는 문구도 함께 맞춰 주는 편이 체감 일관성이 더 높다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.1
|
||||
- 좌측 메뉴와 화면 타이틀의 명칭이 서로 다르면 사용자가 현재 위치를 직관적으로 매칭하기 어렵기 때문에, 메뉴 이름과 진입 타이틀을 같은 문구로 맞추는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.0
|
||||
- 서비스가 게임 외 주제 전반을 다룰 수 있는 단계에 온 만큼, 내부 모델명은 유지하더라도 사용자에게 보이는 주요 용어는 `주제 / 템플릿` 기준으로 먼저 정리하는 편이 맞다고 판단했다.
|
||||
- 대규모 내부 리네이밍은 API와 DB까지 손대야 하므로, 이번 단계에서는 사용자 화면 문구만 우선 바꾸고 내부 `game` 모델은 그대로 두는 점진적 전환이 더 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.93
|
||||
- 목록 카드 썸네일은 드래그 대상이 아니라 클릭 대상에 가깝기 때문에, 브라우저 기본 이미지 드래그 프리뷰는 전부 막아 두는 편이 UX 측면에서 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.92
|
||||
- 왼쪽 레일 활성 메뉴도 로그인 토글과 같은 이동형 배경 문법을 쓰는 편이 앱 전체 인터랙션 언어를 더 일관되게 만든다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.91
|
||||
- 로그인/회원가입 탭은 즉시 배경 교체보다, 선택 배경이 실제로 이동하는 토글 문법이 더 직관적이고 상태 전환이 잘 읽힌다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.90
|
||||
- 경고 수준의 CSS 진단이라도 실제 의미 없는 속성이나 벤더 전용 속성 누락이라면 바로 정리해 두는 편이 이후 유지보수 피로를 줄인다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.89
|
||||
- 더 이상 참조되지 않는 Vite 기본 자산과 레거시 public 아이콘 묶음은 남겨둘수록 혼동만 커지므로, 실제 사용 파일만 남기고 정리하는 편이 맞다고 판단했다.
|
||||
- 공유용 썸네일은 코드 수정과 별개로 시각 자산 손질이 자주 일어날 수 있으므로, 이번처럼 워크트리에 이미 반영된 최신 이미지 수정본은 함께 릴리스에 포함하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.88
|
||||
- 헤더의 `by zenn`은 이미 공통 카피라이트 링크가 생긴 뒤 역할이 겹치므로, 브랜드 영역은 서비스명 중심으로 정리하는 편이 맞다고 판단했다.
|
||||
- 외부 공유 미리보기는 메타 태그만 넣는 것보다 실제 전용 썸네일 자산을 함께 두는 편이 메신저/소셜/모바일 홈 화면까지 더 안정적으로 동작한다고 정리했다.
|
||||
- 파비콘은 인라인 data URL 하나에 의존하기보다 `svg + png + apple-touch-icon` 조합으로 두는 편이 브라우저와 기기 호환성 측면에서 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.86
|
||||
- 아이콘 크기는 이미지 다운로드 결과에만 반영되고 저장본에는 남지 않으면 사용자가 체감상 “저장되지 않는 설정”으로 느끼게 되므로, 티어표 본문 설정으로 저장하는 편이 맞다고 정리했다.
|
||||
- 저장 경로를 고친 뒤에도 프리뷰 화면이 기본값으로 보인다면, 데이터보다 프런트 렌더링 루트에 동일 CSS 변수가 전달되는지 먼저 확인하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.83
|
||||
- 모바일에서 열 헤더가 칸과 시각적으로 분리되는 문제는 전체 레이아웃을 다시 갈아엎기보다, 각 칸 안에 열 이름 배지를 같이 보여주는 편이 가장 적은 변경으로 효과를 낸다고 정리했다.
|
||||
- 배지를 쓰는 반응형 구간에서는 기존 상단 열 헤더까지 남겨두면 중복 정보가 되므로, 같은 브레이크포인트에서 헤더는 숨기고 칸 배지 하나만 남기는 편이 맞다고 정리했다.
|
||||
- 반응형 보정은 한 미디어 구간 안에서 서로 다른 규칙이 다시 덮어쓰지 않게 정리해야 하므로, 모바일용 `1fr` 레이아웃을 선언한 뒤 예전 `140px/150px` 규칙은 제거하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.82
|
||||
- 프리뷰 완성본도 결국 공유/열람용 결과물이므로, 이미지 다운로드 결과와 같은 작성자/저장 시각 메타를 같이 보여주는 편이 자연스럽다고 정리했다.
|
||||
- 관리자 템플릿 요청 카드는 “요청 티어표 보기”가 실제로 새창 이동용이라면 하단 버튼과 썸네일 클릭을 둘 다 유지하기보다, 썸네일 클릭 하나로 통합하는 편이 더 단순하고 직관적이라고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.81
|
||||
- 저장된 티어표 공유는 별도 새 페이지를 만들기보다, 이미 완성본 열람에 쓰고 있는 `preview=1` 주소를 그대로 공유 링크로 재사용하는 편이 가장 단순하고 일관적이라고 정리했다.
|
||||
- 공유 액션은 저장/삭제처럼 저장본 전제의 보조 기능이므로, 메인 저장 버튼 영역보다 하단 유틸리티 링크 영역에 두는 편이 더 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.79
|
||||
- 카피라이트처럼 앱 전체 브랜딩 성격의 footer는 관리자 텔레포트 안에 두기보다, `App.vue`의 공통 오른쪽 레일 footer로 두는 편이 위치도 안정적이고 화면 간 일관성도 높다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.78
|
||||
- 축소 상태에서는 텍스트가 사라지므로 같은 `티어표 만들기` 계열 액션이라도 커스텀 제작과 템플릿 기반 제작을 아이콘으로 구분해 주는 편이 맞다고 정리했다.
|
||||
- 관리자 우측 카피라이트처럼 “사이드바 하단”에 붙어야 하는 정보는 텔레포트 루트의 형제 노드로 두기보다, 실제 사이드바 컨테이너 내부의 마지막 행으로 두는 편이 레이아웃상 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.77
|
||||
- 왼쪽 레일을 접었을 때 하단 액션을 완전히 숨기면 `새 티어표 만들기` 진입점이 사라지므로, 펼친 상태의 하단 위치는 유지하되 축소 상태에서는 같은 위치의 아이콘 전용 버튼으로 바꿔 남겨두는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.76
|
||||
- 왼쪽 사이드 레일을 접었을 때는 텍스트가 사라진 뒤에도 행 높이가 제각각이면 아이콘 전용 탐색기로 읽히지 않으므로, 아바타/검색/내비 항목의 높이를 같은 규격으로 통일하는 편이 맞다고 정리했다.
|
||||
- 왼쪽 레일 검색은 화면에 따라 티어표 검색으로 바뀌면 사용자가 사이드 검색과 메인 검색 역할을 구분하기 어려우므로, 사이드는 게임 검색으로 고정하고 티어표 검색은 메인 화면 문맥에 맡기는 편이 더 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.75
|
||||
- 관리자 공용 모달은 기본 카드 여백을 계속 쓰되, 내부에 자체 셸을 가진 대형 상세 모달까지 같은 패딩을 강제로 받으면 오히려 레이아웃이 무너지므로 예외 클래스로 분리하는 편이 맞다고 정리했다.
|
||||
- 관리자 표기 링크는 텍스트만 두기보다, 추후 주소 변경이 쉬운 한 곳짜리 상수와 새 창 링크로 관리하는 편이 운영 측면에서 더 낫다고 판단했다.
|
||||
- 왼쪽 사이드 레일 접힘 상태는 요소를 좁히는 것만으로는 높이와 정렬 문제가 계속 남으므로, 메타 텍스트는 실제로 숨기고 아이콘 중심 문법으로 따로 정리하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.74
|
||||
- 관리자 공용 게임 선택 모달은 단순 검색만 제공하기보다, 현재 문맥에서 이미 선택 불가능한 대상을 `이미 추가됨`으로 명시하고 막아 주는 편이 운영 실수를 줄이는 데 더 효과적이라고 정리했다.
|
||||
- 프로젝트 표기는 관리자 헤더 상단보다 사이드바 최하단의 작은 카피라이트 문구로 빼는 편이 정보 밀도를 덜 방해한다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.73
|
||||
- 게임 선택이 여러 관리자 화면에 퍼지기 시작한 시점에서는 일부 화면만 셀렉트나 내부 리스트를 유지하기보다, 공용 검색 모달 하나로 통일하는 편이 장기적으로 더 일관되고 확장에 강하다고 정리했다.
|
||||
- 검색 입력과 실행 버튼은 세로로 같은 문법으로 쌓기보다, 입력은 입력끼리 실행은 액션으로 읽히게 한 줄 배치로 적당히 구분해주는 편이 운영 화면에서 덜 답답하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.72
|
||||
- 라우트 복원용 watcher가 composable 반환값 초기화보다 먼저 돌 수 있는 구간에서는 직접 함수를 즉시 호출하기보다, 초기화 완료 뒤 실행되도록 한 템포 미루는 편이 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.71
|
||||
- 관리자에서 게임 선택 지점이 늘어나는 구조라면 각 화면마다 셀렉트/긴 리스트를 따로 두기보다, 공용 검색 모달 하나로 통일하는 편이 이후 100개 이상 게임이 쌓여도 더 안정적이라고 정리했다.
|
||||
- 아이템 모달은 참조 정보 정리 뒤에도 왼쪽 선택 요약 카드가 여전히 과하다고 판단해, 예전처럼 게임 선택 자체에 더 집중한 구조로 한 단계 더 되돌리는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.70
|
||||
- 관리자 티어표 목록은 페이지 단위 열람만으로는 운영 개입이 부족하므로, 게임별 필터와 카드 단위 관리 액션을 함께 붙여 실제 검수 도구로 쓰는 편이 맞다고 정리했다.
|
||||
- 공개 여부는 문장 속 메타보다 배지로 따로 떼어 보여주는 편이 다른 관리자 카드들과 문법이 맞고, 공개/비공개 전환도 같은 관리 모달 안에서 바로 처리하는 쪽이 운영 흐름상 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.69
|
||||
- 관리자 아이템 라이브러리의 참조 수는 저장 구조 설명에는 도움이 되지만 실제 운영에서는 대부분 의미가 약하므로, 카드와 모달에서 계속 전면에 두기보다 다시 숨기고 필요한 경우 내부 데이터로만 남기는 편이 맞다고 정리했다.
|
||||
- 관리자 상단 요약 수치는 `활성/대기` 같은 상태 문구보다 실제 운영 판단에 바로 쓰이는 `공개/비공개 개수`가 더 중요하므로, 게임 관리와 티어표 관리 모두 공개 상태 집계를 먼저 보여주는 편이 낫다고 판단했다.
|
||||
- 공개/비공개 수치는 현재 페이지 일부를 세면 오해가 생기기 쉬우므로, 검색/게임 기준 전체 결과를 집계하는 별도 관리자 API로 계산하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.68
|
||||
- 관리자 아이템 상세는 새 모달을 겹쳐 올리는 방식보다 기존 모달 안에서 `왼쪽 선택 대상 / 오른쪽 작업과 참조 정보` 역할만 분명히 나누는 편이 더 안정적이라고 정리했다.
|
||||
- 같은 이미지를 두 위치에서 반복 노출하면 “모달이 두 개 겹친 것처럼” 느껴질 수 있으므로, 선택 썸네일은 한 곳에만 두고 양쪽 패널은 각자 스크롤되는 구조로 정리하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.67
|
||||
- 같은 이미지 공유 구조는 저장 효율에는 유리하지만 운영자가 관계를 읽기 어렵기 때문에, 카드 단계에서는 참조 수를 바로 보여주고 상세 모달에서는 같은 `src`를 가리키는 기록들을 함께 펼쳐 보여주는 편이 맞다고 정리했다.
|
||||
- 삭제 제한을 과하게 두기보다, 삭제 전 영향 범위를 문구와 개수로 먼저 보여주는 쪽이 운영 측면에서 더 현실적이라고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.66
|
||||
- 누락 참조 정리 도구는 커스텀 아이템 누락이 없어도 티어표/요청 썸네일 누락을 항상 따로 정리해야 하므로, 썸네일 정리를 커스텀 아이템 분기에 묶어두면 안 된다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.65
|
||||
- 누락 파일 수치가 계속 쌓이는 상태에서는 원인 분석만으로는 운영 부담이 줄지 않으므로, 실제 파일이 없는 참조만 골라 썸네일/아바타/게임 아이템/커스텀 아이템 참조를 정리하는 관리자 액션을 제공하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.64
|
||||
- 관리자 이미지 최적화 기록은 “재사용” 자체보다 이번 업로드와 재사용 자산의 관계가 더 중요하므로, 용량 숫자와 설명을 함께 보여주는 편이 운영자가 오해하지 않기 쉽다고 정리했다.
|
||||
- 관리자 아이템 라이브러리는 `사용자 업로드 미사용만` 같은 단일 체크박스보다 출처와 사용 상태를 함께 고를 수 있는 필터 모드가 더 실용적이므로, 사용자·템플릿·보관 자산·미사용 상태를 분리해 보는 구조가 맞다고 판단했다.
|
||||
- 게임 목록이 커질수록 선택 게임 설정을 사이드바 하단에 두는 구조는 스크롤 부담이 커지므로, 공개 상태와 썸네일 관리 액션은 선택된 게임 본문 상단 카드로 올리는 편이 더 안정적이라고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.63
|
||||
- 이미지 최적화 기록은 내부 라우트 카테고리를 그대로 보여주면 운영자가 실제 의미를 해석해야 하므로, 관리자 화면에는 기능 기준의 한국어 라벨과 재사용 여부를 함께 보여주는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.62
|
||||
- 커스텀 이미지가 많은 상태에서 저장할 때 사용자 체감 순서가 흔들리는 것은 업로드 성공보다 더 직접적인 UX 문제이므로, 내부 객체 키 순서가 아니라 현재 화면 배치 순서를 저장 기준으로 삼는 편이 맞다고 정리했다.
|
||||
- 템플릿 요청이 저장본에서만 가능하다면 삭제도 같은 기준을 따르는 편이 흐름상 자연스러우므로, 저장되지 않은 초안에는 삭제 액션을 노출하지 않는 쪽으로 판단했다.
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.vue`
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
|
||||
## 백엔드 진입점
|
||||
- 서버 엔트리: `backend/index.js`
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
|
||||
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
||||
- 티어표 편집 화면의 우측 패널은 공통 `rightRail`의 `localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
|
||||
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 고정 사이트 타이틀 `Tier Maker by zenn`을 표시한다.
|
||||
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 사이트 타이틀 `Tier Maker`와 현재 서비스 설명을 표시한다.
|
||||
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
||||
|
||||
64
docs/todo.md
@@ -1,10 +1,68 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `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한다.
|
||||
- `/topics/:gameId`를 기본 경로로 세우고 `/games/:gameId`는 alias로 남겼으므로, 다음 단계에서는 에디터/검색/공유 흐름에서 어떤 링크를 새 경로로 더 전환할지 범위를 정한다.
|
||||
- 내부 리네이밍 2단계로 관리자 `selectedTemplate / templates / loadTemplate / refreshTemplates` 묶음까지 정리했으므로, 다음 단계에서는 `/games/:gameId` 라우트와 프런트 API 호출부를 어디까지 `topic/template` 의미로 감쌀지 범위를 먼저 정리한다.
|
||||
- 화면/문서 층의 용어 정리는 거의 마무리됐으므로, 다음 단계에서는 내부 `gameId / games` 모델을 실제로 옮길지, 아니면 라우트 alias/리다이렉트부터 둘지 점진 전환 순서를 정한다.
|
||||
- 관리자 화면까지 포함한 사용자 노출 `게임` 문구를 3차까지 정리했으므로, 실제 운영 흐름에서 뜨는 확인창/토스트/선택 모달까지 표현이 자연스러운지 한 번 더 QA한다.
|
||||
- 왼쪽 사이드 메뉴와 각 화면 타이틀을 한글 기준으로 맞췄으므로, 홈/나의 티어표/즐겨찾기/설정 진입 시 실제 체감이 자연스럽고 중복 표현이 어색하지 않은지 한 번 더 QA한다.
|
||||
- 사용자 노출 용어는 `주제 / 템플릿` 기준으로 계속 걷어내고 있으므로, 홈/주제 화면/관리자 템플릿 관리에서 어색하게 남은 `게임` 문구가 없는지 한 번 더 QA한다.
|
||||
- 내부 모델명은 아직 `game`을 유지하므로, 다음 단계에서는 문서와 보조 화면 문구를 더 정리할지, 아니면 내부 리네이밍 계획을 따로 잡을지 결정한다.
|
||||
- 주제 목록과 티어표 카드 썸네일은 기본 이미지 드래그를 막았으므로, 데스크톱 브라우저에서 클릭/드래그 시 원본 이미지 프리뷰가 더 이상 뜨지 않는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 활성 배경은 공용 인디케이터가 이동하는 방식으로 바뀌었으므로, 홈/내 티어표/즐겨찾기/설정 전환과 레일 접힘 상태 양쪽에서 위치 보정이 자연스러운지 한 번 더 QA한다.
|
||||
- 로그인 화면 상단 토글은 이동형 인디케이터로 바뀌었으므로, 데스크톱과 모바일에서 `로그인 / 회원가입` 전환 애니메이션이 어색하지 않고 포커스/클릭 상태도 자연스러운지 한 번 더 QA한다.
|
||||
- 관리자 카드 설명 줄임은 `line-clamp` 표준 속성까지 함께 선언했으므로, 실제 브라우저별 표시 차이가 없는지 한 번 더 QA한다.
|
||||
- 사용하지 않는 기본 자산을 정리했으므로, 배포본에서 누락 참조 없이 파비콘/공유 썸네일/좌측 레일 아이콘이 정상 노출되는지 한 번 더 QA한다.
|
||||
- 공유 썸네일 `og-card`는 이번에 이미지 수정본까지 함께 반영했으므로, 실제 메신저 미리보기에서 최신 그림이 캐시 갱신 후 정상 노출되는지 한 번 더 QA한다.
|
||||
- 홈페이지 공유 메타와 새 `og-card.png`는 이번에 처음 붙였으므로, 카카오톡/디스코드/슬랙/모바일 브라우저에서 제목·설명·썸네일이 기대대로 보이는지 한 번 더 QA한다.
|
||||
- 파비콘은 `svg + 32px png + apple-touch-icon` 조합으로 정리했으므로, 데스크톱 브라우저 탭과 iOS 홈 화면 추가에서 모두 정상 노출되는지 한 번 더 QA한다.
|
||||
- 티어표 `아이콘 크기`는 이제 저장 데이터로 승격됐으므로, 저장 후 재진입/프리뷰/복사본 생성에서 같은 크기가 유지되는지 한 번 더 QA한다.
|
||||
- 티어표 편집/프리뷰 모바일 열 배지는 새로 붙였으므로, 실제 좁은 화면에서 칸 상단 배지와 아이템 썸네일이 겹치지 않고 열 구분이 자연스러운지 한 번 더 QA한다.
|
||||
- 모바일 열 배지는 같은 구간에서 상단 열 제목을 숨기도록 다시 맞췄으므로, 720px 안팎뿐 아니라 980px 이하 전 구간에서 중복 표기 없이 자연스러운지 한 번 더 QA한다.
|
||||
- 모바일 티어표 편집 레이아웃은 행 라벨 폭을 다시 덮어쓰던 규칙을 걷어냈으므로, 실제 980px 이하 구간에서 행 라벨이 과하게 넓지 않고 칸 폭을 충분히 남기는지 한 번 더 QA한다.
|
||||
- 프리뷰 완성본 하단 메타는 새로 붙였으므로, 작성자/저장 시각이 공개 열람 화면과 이미지 다운로드 결과 기준에서 모두 자연스럽게 읽히는지 한 번 더 QA한다.
|
||||
- 관리자 템플릿 요청 카드는 썸네일 클릭이 새창 열기 역할로 바뀌었으므로, 썸네일 클릭과 `확인하기` 액션이 서로 헷갈리지 않는지 한 번 더 QA한다.
|
||||
- 티어표 만들기 화면의 `공유하기`는 저장된 티어표에서만 노출되므로, 저장 직후/수정 중/복사본/읽기 전용 상태 각각에서 노출 조건과 클립보드 복사가 자연스러운지 한 번 더 QA한다.
|
||||
- 우측 카피라이트는 이제 공통 오른쪽 레일 footer이므로, 관리자 화면뿐 아니라 홈/프로필 등 오른쪽 사이드가 보이는 화면에서도 같은 최하단 위치에 유지되는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 홈과 주제 화면에서 서로 다른 아이콘을 쓰도록 나눴으므로, 실제로 두 문맥이 한눈에 구분되는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 축소 상태 최하단의 `티어표 만들기` 아이콘 버튼은 새로 추가했으므로, 홈/주제 화면에서 실제로 같은 위치 감각으로 동작하는지 한 번 더 QA한다.
|
||||
- 관리자 우측 카피라이트 문구는 사이드바 내부 최하단으로 다시 옮겼으므로, 실제 관리자 화면에서 스크롤/창 크기 변화에도 계속 보이는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 축소 상태는 아이콘 줄 높이를 50px 기준으로 통일했으므로, 실제 데스크톱에서 아바타/검색/메뉴 아이콘이 시각적으로 같은 리듬으로 보이는지 한 번 더 QA한다.
|
||||
- 왼쪽 레일 검색은 이제 항상 주제 템플릿 검색으로 홈으로 이동하므로, 홈이 아닌 화면에서 사이드 검색 후 주제 목록 결과로 자연스럽게 이동하는지 한 번 더 QA한다.
|
||||
- 앱 왼쪽 사이드 레일은 접힘 상태 레이아웃을 다시 손봤으므로, 데스크톱에서 접기/펼치기 반복 시 아바타 영역 높이, 아이콘 중앙 정렬, 검색 버튼 간격, 네비게이션 히트 영역이 모두 자연스러운지 한 번 더 QA한다.
|
||||
- 관리자 우측 사이드바 하단 카피라이트 링크는 새 창 외부 링크로 바꿨으므로, 실제 클릭 시 `zenn.town` 연결과 hover 대비가 자연스러운지 한 번 더 QA한다.
|
||||
- 관리자 아이템 상세 모달은 공통 패딩 예외 처리를 다시 넣었으므로, 대형 상세 모달과 일반 템플릿 선택 모달이 각각 기대한 크기로 보이는지 한 번 더 QA한다.
|
||||
- 아이템 관리 모달의 공용 템플릿 선택기에서는 이미 연결된 템플릿이 비활성화되므로, 실제 운영 데이터에서 중복 연결 방지와 `이미 추가됨` 표시가 기대대로 읽히는지 한 번 더 QA한다.
|
||||
- 공용 템플릿 선택 모달을 아이템 관리 모달에도 붙였으므로, 관리자 `템플릿 관리 / 전체 티어표 관리 / 아이템 관리` 세 흐름에서 같은 검색/선택 UX가 자연스럽게 느껴지는지 한 번 더 QA한다.
|
||||
- 관리자 `/admin/games?gameId=...` 직접 진입과 새로고침에서 선택 템플릿이 정상 복원되고 콘솔 오류가 없는지 한 번 더 QA한다.
|
||||
- 공용 `템플릿 선택` 검색 모달은 새로 붙였으므로, 템플릿 관리 선택/전체 티어표 관리 필터 양쪽에서 검색어 입력, 선택, 해제, 뒤로가기 흐름을 한 번 더 QA한다.
|
||||
- 관리자 `전체 티어표 관리`의 템플릿 필터와 관리 모달은 새로 붙였으므로, 실제 운영 데이터에서 필터 전환 후 공개/비공개 집계, 제목 수정, 비공개 전환, 삭제 흐름이 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||
- 관리자 템플릿 관리 상단의 `전체 / 공개 / 비공개` 티어표 수치와 전체 티어표 관리 상단 집계가 실제 운영 데이터와 맞는지, 공개 전환 직후 숫자가 자연스럽게 갱신되는지 한 번 더 QA한다.
|
||||
- 관리자 아이템 상세 모달은 왼쪽 선택 카드와 오른쪽 상세 본문으로 다시 정리했으므로, 데스크톱/모바일에서 긴 템플릿 목록과 긴 참조 목록이 각각 자연스럽게 스크롤되는지 한 번 더 QA한다.
|
||||
- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다.
|
||||
- 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다.
|
||||
- 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다.
|
||||
- 커스텀 이미지가 많은 티어표를 저장할 때 커스텀 이름 정리 목록과 미배치 아이템 영역 순서가 실제로 안정적으로 유지되는지, 10장 이상 업로드한 상태로 한 번 더 QA한다.
|
||||
- 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다.
|
||||
- 관리자 게임 설정 카드를 본문 상단으로 옮겼으므로, 긴 게임 목록 상태에서 선택-썸네일 교체-공개 전환-삭제 흐름이 스크롤 없이 자연스러운지 한 번 더 QA한다.
|
||||
- `누락 참조 정리`는 실제 파일이 없는 참조만 지우도록 넣었으므로, 운영 데이터에서 실행했을 때 누락 수치가 줄고 대표 썸네일/티어표/템플릿 요청 표시가 기대대로 비워지는지 한 번 더 QA한다.
|
||||
- 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다.
|
||||
|
||||
## 중기 개선
|
||||
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
|
||||
@@ -26,16 +84,10 @@
|
||||
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
|
||||
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
|
||||
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
|
||||
|
||||
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
|
||||
|
||||
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
|
||||
|
||||
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
|
||||
|
||||
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
|
||||
|
||||
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
|
||||
- 관리자 템플릿 요청 카드와 전체 티어표 카드가 같은 문법으로 맞춰졌으므로, 이후에는 라이트모드까지 포함해 두 카드의 썸네일 높이·입력창 밀도·아이템 그리드를 한 번 더 비교 QA한다.
|
||||
|
||||
- 템플릿 요청 저장 흐름은 저장된 티어표 기준으로 바뀌었으므로, 이후 실제 데이터로 빈 제목 저장 시 자동 생성 제목·요청 버튼 노출 시점·관리자 요청 미리보기 밀도를 한 번 더 비교 QA한다.
|
||||
|
||||
178
docs/update.md
@@ -1,5 +1,183 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 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` 문법으로 맞춰 주요 목록 화면들의 상단 리듬을 한 번 더 통일했다.
|
||||
|
||||
## 2026-04-02 v1.4.7
|
||||
- 주제 선택 뒤에 들어가는 `Collection` 화면을 공통 `pageHead` 레이아웃으로 다시 맞추고, 검색 입력을 즐겨찾기 화면처럼 상단 우측 툴바로 정리했다.
|
||||
- `공개 티어표` 보조 설명 줄은 제거해 헤더 밀도를 줄였고, 사용자 진입 경로는 `/topics/:gameId`를 기본으로 전환하면서 기존 `/games/:gameId`는 alias로 유지했다.
|
||||
|
||||
## 2026-04-02 v1.4.6
|
||||
- 관리자 내부 리네이밍 2단계로 `AdminView`와 관련 composable/component의 핵심 상태명을 `selectedTemplate / templates / loadTemplate / refreshTemplates / createTemplate` 기준으로 정리했다.
|
||||
- 요청 검토, 템플릿 생성 모달, 아이템 추가/정렬, 템플릿 선택 모달 흐름도 같은 기준으로 맞춰, 관리자 화면을 읽을 때 내부 이름과 사용자 노출 용어가 덜 어긋나게 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.5
|
||||
- 내부 리네이밍 1단계를 시작해 홈, 주제 화면, 티어표 편집기, 앱 셸에서 `games / gameId / gameName` 중심의 로컬 상태명을 `templates / topicId / templateId / templateName` 계열로 먼저 정리했다.
|
||||
- 경로와 API는 그대로 둔 채 프런트 내부에서 자주 읽는 상태명부터 바꿔, 이후 `/games` 라우트와 관리자 상태를 손볼 때 의미 충돌이 덜 나도록 기반을 만들었다.
|
||||
|
||||
## 2026-04-02 v1.4.4
|
||||
- 홈 화면 `Topic Library`와 일부 영어 헤더를 `주제 선택 / 티어표 / 관리자 작업실 / 티어표 만들기 / 작업 공간`으로 정리해, 화면 타이틀과 상단 레이블까지 한국어 기준으로 거의 통일했다.
|
||||
|
||||
## 2026-04-02 v1.4.3
|
||||
- 관리자 토스트, 확인창, 요청 처리 안내처럼 실제로 자주 보이는 운영 문구까지 `주제 / 템플릿` 기준으로 한 번 더 정리해, 화면 제목뿐 아니라 작업 피드백도 더 일관되게 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.2
|
||||
- 관리자 화면과 보조 모달에 남아 있던 사용자 노출 `게임` 문구를 추가로 걷어내고, `템플릿 / 주제` 기준 표현으로 더 통일했다.
|
||||
|
||||
## 2026-04-02 v1.4.1
|
||||
- 왼쪽 사이드 메뉴를 `주제 선택 / 나의 티어표 / 즐겨찾기 / 설정` 한글 문구로 통일하고, 해당 화면 진입 시 헤더 타이틀도 같은 이름 기준으로 맞췄다.
|
||||
|
||||
## 2026-04-02 v1.4.0
|
||||
- 사용자 노출 용어 1차 정리를 시작해 홈/좌측 레일/가이드/주제 화면에서는 `게임` 대신 `주제`, 관리자 핵심 화면에서는 `게임 관리` 대신 `템플릿 관리` 중심 표현으로 바꿨다.
|
||||
- 내부 데이터 모델과 API의 `gameId`, `/games` 구조는 아직 유지하고, 이번 단계는 화면 문구와 안내 텍스트를 먼저 정리하는 안전한 1차 리네이밍 범위로 제한했다.
|
||||
|
||||
## 2026-04-02 v1.3.93
|
||||
- 게임 목록, 티어표 리스트, 사용자 아바타 버튼 등 목록성 썸네일 이미지에 `draggable=\"false\"`를 적용해 브라우저 기본 이미지 드래그 프리뷰가 뜨지 않도록 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.92
|
||||
- 왼쪽 네비게이션의 활성 메뉴 배경은 개별 항목에 즉시 붙는 방식에서, 공용 인디케이터가 현재 메뉴 위치로 미끄러져 이동하는 토글형 인터랙션으로 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.91
|
||||
- 로그인 화면 상단의 `로그인 / 회원가입` 전환은 선택된 버튼 배경이 즉시 바뀌던 방식에서, 뒤쪽 하이라이트가 토글처럼 좌우로 미끄러져 이동하는 인터랙션으로 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.90
|
||||
- 관리자 화면 CSS 경고를 줄이기 위해 `display: block` 요소에 의미 없던 `vertical-align`을 제거하고, `line-clamp` 표준 속성을 함께 선언해 VS Code 진단을 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.89
|
||||
- 현재 코드에서 참조되지 않던 `frontend/public/icons.svg`, `frontend/src/assets/hero.png`, `frontend/src/assets/vite.svg`, `frontend/src/assets/vue.svg`를 삭제해 템플릿 잔재 자산을 정리함.
|
||||
- 홈페이지 공유용 `og-card.svg`, `og-card.png`는 이번 워크트리에서 직접 수정된 최신 이미지 상태를 그대로 반영해 함께 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.88
|
||||
- 중앙 워크스페이스 헤더의 `by zenn` 링크는 공통 카피라이트 footer가 이미 역할을 대신하므로 제거하고, 기본 서브타이틀도 서비스 설명 문구로 정리함.
|
||||
- 홈페이지 공유용 메타를 정리해 `title`, `description`, `canonical`, Open Graph, Twitter 카드 정보를 `tmaker.sori.studio` 기준으로 연결함.
|
||||
- 외부 공유용 `og-card.svg`와 실제 썸네일 `og-card.png`, 브라우저/모바일용 `favicon-32x32.png`, `apple-touch-icon.png`를 추가해 링크 공유와 파비콘 노출을 함께 보강함.
|
||||
|
||||
## 2026-04-02 v1.3.86
|
||||
- 티어표 편집의 `아이콘 크기`는 이제 임시 화면 상태가 아니라 저장 데이터에 함께 포함되며, 저장 후 다시 열기와 프리뷰 화면에서도 같은 크기로 복원되도록 정리함.
|
||||
- 이를 위해 티어표 저장 payload, 서버 검증, DB 저장/조회에 `iconSize`를 추가하고 기존 데이터는 기본값 `80`으로 안전하게 보정되게 맞춤.
|
||||
- 이후 공유 프리뷰 화면이 여전히 80으로 고정되던 문제는 `previewOnly` 레이아웃에서 `--thumb-size` 스타일 바인딩이 빠져 있던 탓이었고, 프리뷰 루트에도 같은 값을 전달해 저장된 크기가 그대로 반영되게 보정함.
|
||||
|
||||
## 2026-04-02 v1.3.83
|
||||
- 티어표 편집/프리뷰 화면에서 열을 여러 개 쓰는 경우, 모바일처럼 좁은 화면에서는 기존 상단 열 헤더만으로 각 칸의 의미를 읽기 어려웠으므로 각 칸 상단에 작은 열 이름 배지를 추가함.
|
||||
- 이 배지는 모바일 구간에서만 보이고 데스크톱 레이아웃은 그대로 유지되므로, 작은 화면에서는 `메인 / 밸런스 / 서포트` 같은 열 맥락을 스크롤 중에도 잃지 않게 정리함.
|
||||
- 이후 배지가 칸 기준이 아니라 화면 한쪽에 겹치던 문제를 바로잡기 위해 각 칸을 기준점으로 다시 잡았고, 배지가 보이는 구간에서는 기존 상단 열 제목을 함께 숨겨 중복 표기를 제거함.
|
||||
- 추가로 같은 미디어 구간 안에서 행/열 모바일 레이아웃을 다시 `140px/150px`로 덮어쓰던 중복 규칙을 제거해, 모바일에서는 행 라벨이 화면 절반을 차지하지 않고 실제로 한 줄 전체 폭 기준 레이아웃으로 정리되게 맞춤.
|
||||
|
||||
## 2026-04-02 v1.3.82
|
||||
- 프리뷰 전용 완성본 화면에도 이미지 다운로드 결과와 같은 하단 메타를 붙여, 작성자 이름과 마지막 저장 시각을 바로 확인할 수 있게 정리함.
|
||||
- 관리자 `티어표 관리 > 템플릿 요청 관리`에서는 더 이상 썸네일 클릭으로 요청 미리보기 모달을 열지 않고, 썸네일 자체가 `요청 티어표 보기` 새창 링크 역할을 하도록 바꿨으며, 하단의 중복 `요청 티어표 보기` 버튼은 제거함.
|
||||
|
||||
## 2026-04-02 v1.3.81
|
||||
- 티어표 만들기 화면에는 저장된 티어표에서만 보이는 `공유하기` 액션을 추가하고, 누르면 현재 티어표의 완성본 링크(`preview=1`)를 클립보드에 복사한 뒤 토스트로 안내하도록 정리함.
|
||||
- 공유 링크는 관리자가 새 창에서 보던 완성본 주소와 같은 문법을 사용하므로, 저장된 티어표를 그대로 외부에 전달하거나 다시 열람하는 흐름으로 바로 이어짐.
|
||||
|
||||
## 2026-04-02 v1.3.79
|
||||
- 우측 카피라이트는 관리자 전용 레이아웃에서 분리해 앱 공통 `rightRail` footer로 올렸고, 이제 관리자 페이지뿐 아니라 오른쪽 사이드가 보이는 모든 화면에서 같은 최하단 위치에 표시됨.
|
||||
- 따라서 관리자 패널 길이나 페이지별 로컬 사이드바 내용과 무관하게, 카피라이트는 항상 오른쪽 레일 전체 기준 바닥에 고정되는 공통 footer 역할로 정리됨.
|
||||
|
||||
## 2026-04-02 v1.3.78
|
||||
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 문맥에 따라 구분되도록 바꿔, 홈의 `커스텀 티어표 만들기`는 `dashboard_customize` 아이콘을 쓰고 게임 허브의 일반 `티어표 만들기`만 `add_notes` 아이콘을 유지하도록 정리함.
|
||||
- 관리자 우측 카피라이트 문구는 사이드바 바깥 형제로 밀려 보이지 않을 수 있었으므로, 다시 관리자 사이드바 `aside` 내부 최하단으로 옮겨 레이아웃 안에서 안정적으로 보이게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.77
|
||||
- 왼쪽 사이드 레일을 축소했을 때도 홈과 게임 허브에서 바로 새 티어표를 만들 수 있도록, 최하단 액션 영역에 `add_notes` 아이콘 기반의 축소 전용 `티어표 만들기` 버튼을 추가함.
|
||||
- 펼친 상태에서는 기존 텍스트 버튼을 그대로 유지하고, 축소 상태에서는 같은 위치에 아이콘 버튼만 남기도록 분기해 하단 액션 위치 감각은 유지하면서도 좁은 레일 폭에 맞게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.76
|
||||
- 앱 왼쪽 사이드 레일은 축소 상태에서 아바타, 검색 버튼, 네비게이션 아이콘 버튼 높이를 모두 50px 기준으로 맞추고 검색 아래 여백도 정리해, 아이콘만 보이는 상태에서도 각 줄 높이가 제각각처럼 보이지 않게 정리함.
|
||||
- 왼쪽 사이드 검색은 라우트에 따라 의미가 바뀌지 않도록 `게임 템플릿 검색`으로 고정하고, 축소 검색 모달 역시 같은 플레이스홀더와 같은 동작으로 홈 게임 목록 검색을 수행하도록 통일함.
|
||||
|
||||
## 2026-04-02 v1.3.75
|
||||
- 관리자 공용 모달 카드의 기본 `padding: 20px`는 그대로 두되, 아이템 상세처럼 내부 레이아웃이 이미 큰 셸을 가진 모달은 `modalCard--customItem`에서 다시 덮어쓰지 않도록 분리해 상세 모달 크기와 내부 배치가 무너지지 않게 정리함.
|
||||
- 관리자 우측 사이드바 최하단의 카피라이트 문구는 이제 별도 상수 URL을 참조하는 외부 링크로 바꿔 새 창에서 열리게 했고, 추후 주소를 바꿔야 할 때 한 곳만 수정하면 되도록 정리함.
|
||||
- 앱 왼쪽 사이드 레일의 접힘 상태는 메타 텍스트를 단순히 투명하게 남겨두는 대신 실제로 숨기고, 아바타/검색/내비 아이콘을 다시 중앙 정렬해 접었을 때 높이가 비정상적으로 늘어나거나 간격이 남아 보이던 레이아웃을 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.74
|
||||
- 아이템 관리 상세에서 템플릿 추가 대상 게임을 고를 때, 이미 해당 이미지가 연결된 게임은 공용 게임 선택 모달에서 `이미 추가됨`으로 표시하고 비활성화해 중복 추가 실수를 미리 막도록 정리함.
|
||||
- 관리자 우측 사이드바 최하단에는 작은 카피라이트 문구를 추가해, 헤더에 관리 정보만 남기고 프로젝트 표기는 하단에서 조용히 보이도록 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.73
|
||||
- 전체 티어표 관리 카드 썸네일은 `draggable="false"`로 바꿔, 미리보기 진입 시 브라우저 기본 이미지 드래그가 클릭을 방해하지 않도록 정리함.
|
||||
- 관리자 사이드바의 검색 입력과 검색 버튼은 한 줄로 묶어, 입력/선택/실행 버튼이 모두 같은 크기의 세로 스택처럼 보이던 답답함을 조금 줄이고 역할 구분을 더 분명하게 함.
|
||||
- 아이템 관리 상세 모달의 템플릿 추가 대상 선택도 내부 전용 게임 리스트 대신 공용 `게임 선택` 검색 모달을 쓰도록 바꿔, 향후 게임 수가 많아져도 같은 선택 문법으로 이어지게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.72
|
||||
- 관리자 화면 초기화 중 `/admin/games?gameId=...` 경로를 즉시 처리하는 watcher가 `loadGame` 초기화보다 먼저 실행되어 브라우저 콘솔에 `Cannot access 'loadGame' before initialization` 오류가 나던 문제를 수정함.
|
||||
- 게임 라우트 진입 시 실제 게임 로딩 호출은 컴포넌트 초기화가 끝난 뒤 microtask로 미뤄 실행하도록 바꿔, 첫 진입/새로고침에서도 게임 선택 복원 흐름이 안전하게 이어지게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.71
|
||||
- 관리자 아이템 모달은 최근 추가했던 선택 요약 카드를 다시 걷어내고, 더 단순한 `게임 선택 패널 + 상세 작업 영역` 구조로 되돌려 이전 흐름에 가깝게 정리함.
|
||||
- 관리자 `게임 관리`와 `전체 티어표 관리`의 게임 선택은 긴 셀렉트/목록 대신 공용 `게임 선택` 검색 모달로 바꿔, 게임 수가 많아져도 이름·ID 검색으로 바로 찾아 선택할 수 있게 함.
|
||||
- 전체 티어표 관리의 게임 필터 해제도 같은 모달 흐름에 맞춰 `모든 게임 보기`로 처리하고, 사이드바에는 현재 선택된 게임만 요약 카드로 보여줘 긴 리스트가 계속 쌓이지 않게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.70
|
||||
- 관리자 `전체 티어표 관리`는 이제 게임별 필터를 지원해, 특정 게임에서 만들어진 티어표만 따로 골라 보며 공개/비공개 분포를 확인할 수 있게 함.
|
||||
- 전체 티어표 카드는 공개 여부를 텍스트 대신 다른 관리자 화면과 같은 배지 형식으로 표시하고, 카드의 `관리` 액션에서 제목·설명 수정, 공개/비공개 전환, 삭제를 바로 처리할 수 있게 보강함.
|
||||
- 이를 위해 관리자 전용 티어표 수정/삭제 API와 게임 기준/검색 기준 공개 집계 로직을 함께 추가해, 관리자 화면에서 비공개 개입과 운영성 검수가 한 흐름으로 이어지게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.69
|
||||
- 관리자 아이템 라이브러리는 참조 수/공유 기록 UI가 실제 운영 판단에 비해 노이즈가 커 보여 카드 수치, 상세 모달의 같은 이미지 참조 섹션, 삭제 확인 문구의 참조 강조를 걷어내고 다시 항목 자체 관리 흐름 위주로 정리함.
|
||||
- 관리자 게임 관리 상단 요약은 더 이상 `선택 상태`처럼 추상적인 문구를 보여주지 않고, 선택된 게임 기준으로 만들어진 티어표의 `전체 / 공개 / 비공개` 개수를 바로 보여주도록 바꿈.
|
||||
- 전체 티어표 관리 상단에도 검색 결과 기준 `전체 / 공개 / 비공개` 수치를 함께 노출하고, 이를 위해 관리자 티어표 집계 API를 별도로 추가해 페이지 단위가 아니라 실제 전체 결과 기준 숫자를 안정적으로 표시함.
|
||||
|
||||
## 2026-04-02 v1.3.68
|
||||
- 관리자 아이템 상세 모달은 같은 이미지를 왼쪽 선택 카드와 오른쪽 본문에서 두 번 보여주던 중복 미리보기를 제거해, 한 모달 안에서 정보가 겹쳐 보이던 문제를 정리함.
|
||||
- 왼쪽 게임 선택 패널과 오른쪽 상세 정보 패널은 각각 독립 스크롤이 되도록 바꾸고, 스크롤바도 다시 보이게 조정해 긴 목록이나 긴 참조 정보가 있어도 레이아웃이 깨지지 않고 탐색할 수 있게 함.
|
||||
- 현재 선택한 이미지 요약 카드에는 별도 배경과 테두리를 추가해, 기존 클릭 모달의 “선택 대상”과 오른쪽 작업 영역이 한눈에 구분되도록 시각 계층을 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.67
|
||||
- 관리자 아이템 관리 카드에는 이제 같은 `src`를 공유하는 참조 수와 연결 게임 수를 함께 표시해, 같은 이미지가 얼마나 넓게 쓰이는지 목록 단계에서 바로 파악할 수 있게 함.
|
||||
- 아이템 상세 모달은 왼쪽 패널 상단에 현재 선택한 이미지와 `총 참조 / 사용자 업로드 / 템플릿 항목 / 보관 자산` 요약을 보여주고, 오른쪽에는 같은 이미지를 가리키는 다른 기록 목록을 함께 표시해 실제로 어떤 참조들이 묶여 있는지 모달 안에서 바로 확인할 수 있게 함.
|
||||
- 삭제 확인 문구도 이제 단순 타입 설명만 하지 않고 `같은 이미지 참조 n건 중 현재 항목만 다룬다`는 영향을 함께 보여, 삭제 전에 범위를 더 명확히 이해할 수 있게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.66
|
||||
- `누락 참조 정리`는 처음엔 누락 커스텀 아이템이 있을 때만 티어표/요청 썸네일까지 함께 보던 조건 때문에, 누락 썸네일만 남아 있으면 수치가 줄지 않던 문제가 있었으므로 분기를 풀어 티어표 썸네일과 요청 썸네일도 항상 실제 파일 존재 여부를 확인해 정리하도록 수정함.
|
||||
- 정리 완료 메시지에도 `티어표 썸네일`, `요청 썸네일` 항목을 추가해 어떤 종류의 누락 참조가 실제로 정리됐는지 바로 알 수 있게 함.
|
||||
|
||||
## 2026-04-02 v1.3.65
|
||||
- 관리자 이미지 최적화 패널에 `누락 참조 정리` 액션을 추가해, 실제 파일이 없는 `/uploads/...` 참조만 대상으로 썸네일/아바타는 비우고 누락된 게임 아이템·커스텀 아이템은 관련 티어표/템플릿 요청 참조와 함께 정리할 수 있게 함.
|
||||
- 따라서 예전 수동 파일 정리나 레거시 데이터로 인해 쌓인 `누락 파일`은 단순 통계로만 남지 않고, 관리자 화면에서 실제로 줄일 수 있는 관리 도구를 갖게 됨.
|
||||
|
||||
## 2026-04-02 v1.3.64
|
||||
- 관리자 이미지 최적화 최근 작업에서 `기존 최적화 파일 재사용` 건은 이제 `이번 업로드 용량 · 재사용 자산 용량` 형태로 표시하고, 동일한 최적화 결과가 이미 있어 새 파일을 다시 만들지 않았다는 설명을 함께 보여 혼동을 줄임.
|
||||
- 관리자 아이템 관리는 기존의 `미사용 사용자 업로드만 보기` 체크박스 대신 `전체 / 사용자 업로드 / 템플릿 사용 이미지 / 관리자 보관 자산 / 미사용 사용자 / 미사용 관리자` 필터로 바꿔, 관리자 업로드 자산도 별도로 확인할 수 있게 정리함.
|
||||
- 게임 관리의 선택된 게임 설정은 더 이상 우측 사이드바 아래쪽에 쌓지 않고, 본문 상단에 썸네일과 공개 상태·썸네일 적용·게임 삭제 액션을 함께 둔 카드로 옮겨 게임 목록이 많아져도 작업 영역을 더 안정적으로 읽을 수 있게 조정함.
|
||||
|
||||
## 2026-04-02 v1.3.63
|
||||
- 관리자 이미지 최적화 최근 작업 목록은 더 이상 내부 카테고리 문자열 `custom / tierlists / games / avatars`를 그대로 노출하지 않고, 각각 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타`처럼 사람이 이해할 수 있는 이름으로 표시함.
|
||||
- 같은 이미지 해시를 다시 업로드해 기존 최적화 파일을 재사용한 경우에는 최근 작업 목록에 `기존 최적화 파일 재사용` 문구를 함께 보여, 새로 압축된 건지 중복 자산이 재사용된 건지 운영자가 바로 구분할 수 있게 함.
|
||||
|
||||
## 2026-04-02 v1.3.62
|
||||
- 티어표 저장과 템플릿 요청 전 커스텀 이미지 업로드에서는 더 이상 `itemsById` 객체 키 순서에 기대지 않고, 실제 화면에 보이는 `아이템 영역 + 보드 배치 순서` 기준으로 아이템 배열을 만들도록 바꿔 저장 중 이미지 목록이 흔들리던 현상을 줄임.
|
||||
- 따라서 커스텀 아이템 이름 정리 목록, 저장 payload, 템플릿 요청 payload 모두 같은 순서 기준을 공유하게 되어, 이미지를 여러 장 올린 뒤 저장해도 사용자가 보고 있던 흐름이 덜 흔들리도록 정리함.
|
||||
|
||||
@@ -1,13 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%230b1220'/%3E%3Cpath d='M18 18h28v8H36v20h-8V26H18z' fill='%23f8fafc'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tier Maker</title>
|
||||
<title>Tier Maker | 게임 템플릿으로 만드는 티어표</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="게임 템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요. 관리자 요청과 템플릿 업데이트 흐름까지 한 번에 관리할 수 있습니다."
|
||||
/>
|
||||
<meta name="theme-color" content="#090d16" />
|
||||
<meta name="application-name" content="Tier Maker" />
|
||||
|
||||
<link rel="canonical" href="https://tmaker.sori.studio/" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
||||
<meta property="og:site_name" content="Tier Maker" />
|
||||
<meta property="og:locale" content="ko_KR" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://tmaker.sori.studio/" />
|
||||
<meta property="og:title" content="Tier Maker | 게임 템플릿으로 만드는 티어표" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="게임 템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요. 관리자 요청과 템플릿 업데이트 흐름까지 한 번에 관리할 수 있습니다."
|
||||
/>
|
||||
<meta property="og:image" content="https://tmaker.sori.studio/og-card.png" />
|
||||
<meta property="og:image:alt" content="Tier Maker 공유 썸네일" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Tier Maker | 게임 템플릿으로 만드는 티어표" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="게임 템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://tmaker.sori.studio/og-card.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
BIN
frontend/public/og-card.png
Normal file
|
After Width: | Height: | Size: 613 KiB |
69
frontend/public/og-card.svg
Normal file
|
After Width: | Height: | Size: 32 KiB |
@@ -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'
|
||||
@@ -9,6 +10,8 @@ import iconDockToRight from './assets/icons/dock_to_right.svg'
|
||||
import iconGridView from './assets/icons/grid_view.svg'
|
||||
import iconFavorite from './assets/icons/favorite.svg'
|
||||
import iconLists from './assets/icons/lists.svg'
|
||||
import iconAddNotes from './assets/icons/add_notes.svg'
|
||||
import iconDashboardCustomize from './assets/icons/dashboard_customize.svg'
|
||||
import iconSearch from './assets/icons/search.svg'
|
||||
import iconSettings from './assets/icons/settings.svg'
|
||||
import iconMenuBook from './assets/icons/menu_book.svg'
|
||||
@@ -19,11 +22,13 @@ const route = useRoute()
|
||||
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 || route.params.gameId || '')
|
||||
|
||||
const leftRailCollapsed = ref(false)
|
||||
const rightRailOpen = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const searchPlaceholder = computed(() => (route.name === 'home' ? '게임 템플릿 검색' : '전체 티어표 검색'))
|
||||
const leftRailSearchPlaceholder = '주제 템플릿 검색'
|
||||
const isCollapsedSearchOpen = ref(false)
|
||||
const isGuideModalOpen = ref(false)
|
||||
const themeMode = ref('dark')
|
||||
@@ -57,22 +62,23 @@ const shellStyle = computed(() => ({
|
||||
}))
|
||||
const leftNavItems = computed(() => {
|
||||
const items = [
|
||||
{ key: 'home', label: 'Games', path: '/', iconSrc: iconGridView },
|
||||
{ key: 'me', label: 'My Lists', path: '/me', iconSrc: iconLists, requiresAuth: true },
|
||||
{ key: 'favorites', label: 'Favorites', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
|
||||
{ key: 'profile', label: 'Settings', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
|
||||
{ key: 'home', label: '주제 선택', path: '/', iconSrc: iconGridView },
|
||||
{ key: 'me', label: '나의 티어표', path: '/me', iconSrc: iconLists, requiresAuth: true },
|
||||
{ key: 'favorites', label: '즐겨찾기', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
|
||||
{ key: 'profile', label: '설정', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
|
||||
]
|
||||
return items.filter((item) => !item.requiresAuth || (authReady.value && auth.user))
|
||||
})
|
||||
const activeLeftNavIndex = computed(() => leftNavItems.value.findIndex((item) => isRouteActive(item.path)))
|
||||
const showRightRailAction = computed(() => false)
|
||||
const showSettingsGuideButton = computed(() => route.name === 'profile')
|
||||
const guideSteps = [
|
||||
{
|
||||
id: 'select-game',
|
||||
title: '게임 또는 양식 선택',
|
||||
summary: '게임 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
||||
title: '주제 또는 양식 선택',
|
||||
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
||||
description:
|
||||
'홈 화면에서는 게임 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 게임을 먼저 고르면 해당 게임의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
|
||||
'홈 화면에서는 주제 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 원하는 주제를 먼저 고르면 해당 주제의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
|
||||
},
|
||||
{
|
||||
id: 'arrange-board',
|
||||
@@ -86,7 +92,7 @@ const guideSteps = [
|
||||
title: '아이템 배치와 커스텀 추가',
|
||||
summary: '프리셋 아이템과 직접 올린 이미지를 드래그로 배치합니다.',
|
||||
description:
|
||||
'오른쪽 아이템 영역의 이미지를 원하는 칸으로 끌어다 놓으면 바로 배치됩니다. 게임 템플릿에 없는 이미지는 커스텀 이미지로 추가해 같이 쓸 수 있고, 이름 표시 옵션을 켜면 결과 이미지를 더 설명적으로 정리할 수 있어요.',
|
||||
'오른쪽 아이템 영역의 이미지를 원하는 칸으로 끌어다 놓으면 바로 배치됩니다. 주제 템플릿에 없는 이미지는 커스텀 이미지로 추가해 같이 쓸 수 있고, 이름 표시 옵션을 켜면 결과 이미지를 더 설명적으로 정리할 수 있어요.',
|
||||
},
|
||||
{
|
||||
id: 'save-share',
|
||||
@@ -105,23 +111,23 @@ const guideSteps = [
|
||||
{
|
||||
id: 'request-template-update',
|
||||
title: '템플릿 업그레이드 요청',
|
||||
summary: '현재 게임 템플릿에 공통 아이템을 추가해 달라고 관리자에게 요청합니다.',
|
||||
summary: '현재 템플릿에 공통 아이템을 추가해 달라고 관리자에게 요청합니다.',
|
||||
description:
|
||||
'직접 추가한 아이템 중 여러 사람이 함께 써도 좋을 것 같은 항목이 있다면 템플릿 업데이트 요청을 보낼 수 있어요. 요청 모달에서는 현재 티어표 제목과 설명을 기본값으로 가져오고, 필요하면 요청 제목과 설명을 더 다듬어 공통 템플릿에 왜 필요한지 설명할 수 있습니다.',
|
||||
},
|
||||
{
|
||||
id: 'request-new-template',
|
||||
title: '새 템플릿 추가 요청',
|
||||
summary: '아직 없는 게임이나 새로운 양식을 관리자에게 제안합니다.',
|
||||
summary: '아직 없는 주제나 새로운 양식을 관리자에게 제안합니다.',
|
||||
description:
|
||||
'원하는 게임 템플릿이 아직 없다면 새 템플릿 추가 요청으로 관리자에게 직접 제안할 수 있어요. 이때는 제목과 설명에 어떤 게임인지, 어떤 캐릭터나 항목이 기본으로 필요할지 적어두면 검토 속도가 훨씬 빨라집니다.',
|
||||
'원하는 주제 템플릿이 아직 없다면 새 템플릿 추가 요청으로 관리자에게 직접 제안할 수 있어요. 이때는 제목과 설명에 어떤 주제인지, 어떤 캐릭터나 항목이 기본으로 필요할지 적어두면 검토 속도가 훨씬 빨라집니다.',
|
||||
},
|
||||
{
|
||||
id: 'manage-library',
|
||||
title: '즐겨찾기와 내 티어표 관리',
|
||||
summary: '마음에 드는 템플릿과 저장한 결과물을 나중에 다시 쉽게 찾습니다.',
|
||||
description:
|
||||
'게임 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
|
||||
'주제 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
|
||||
},
|
||||
]
|
||||
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
|
||||
@@ -130,16 +136,16 @@ 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 showGameHubViewToggle = computed(() => route.name === 'gameHub')
|
||||
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const showTopicViewToggle = computed(() => route.name === 'topicHub')
|
||||
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const leftBottomPrimaryAction = computed(() => {
|
||||
if (!authReady.value) return null
|
||||
if (route.name === 'home' && auth.user) {
|
||||
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new' }
|
||||
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}` }
|
||||
if (route.name === 'topicHub') {
|
||||
const target = editorNewPath(currentTopicId.value)
|
||||
return { label: '새 티어표 만들기', to: auth.user ? target : loginPath(target), iconSrc: iconAddNotes }
|
||||
}
|
||||
return null
|
||||
})
|
||||
@@ -147,96 +153,96 @@ const leftBottomPrimaryAction = computed(() => {
|
||||
const routeMeta = computed(() => {
|
||||
if (route.name === 'home') {
|
||||
return {
|
||||
title: 'Tier Maker',
|
||||
subtitle: '게임 템플릿 선택과 커스텀 보드 시작',
|
||||
title: '주제 선택',
|
||||
subtitle: '주제 템플릿 선택과 커스텀 보드 시작',
|
||||
contextTitle: '빠른 시작',
|
||||
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 게임을 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
||||
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: 'Game Boards',
|
||||
subtitle: '게임별 공개 티어표 탐색',
|
||||
title: '주제 티어표',
|
||||
subtitle: '주제별 공개 티어표 탐색',
|
||||
contextTitle: '작성 작업',
|
||||
contextText: auth.user ? '이 게임의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.',
|
||||
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))
|
||||
},
|
||||
}
|
||||
}
|
||||
if (route.name === 'editEditor' || route.name === 'newEditor') {
|
||||
return {
|
||||
title: 'Deck Builder',
|
||||
title: '티어표 만들기',
|
||||
subtitle: '티어표 편집 및 공유',
|
||||
contextTitle: '편집 패널',
|
||||
contextText: '현재 편집 옵션은 중앙 화면 안에 유지되어 있습니다. 다음 단계에서 우측 패널로 정리해갈게요.',
|
||||
actionLabel: '게임 목록으로',
|
||||
action: () => router.push('/'),
|
||||
actionLabel: '주제 목록으로',
|
||||
action: () => router.push(homePath()),
|
||||
}
|
||||
}
|
||||
if (isAdminRoute.value) {
|
||||
return {
|
||||
title: 'Admin Workspace',
|
||||
subtitle: '게임·아이템·회원 관리',
|
||||
title: '관리자 작업실',
|
||||
subtitle: '템플릿·아이템·회원 관리',
|
||||
contextTitle: '운영 노트',
|
||||
contextText: '관리자 화면은 기능이 많아 우선 공통 셸 톤을 맞췄고, 세부 패널은 다음 단계에서 시안 방식으로 더 세밀하게 나눌 예정입니다.',
|
||||
actionLabel: '게임 목록으로',
|
||||
action: () => router.push('/'),
|
||||
actionLabel: '주제 목록으로',
|
||||
action: () => router.push(homePath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'me') {
|
||||
return {
|
||||
title: 'My Lists',
|
||||
subtitle: '내가 저장한 티어표',
|
||||
title: '나의 티어표',
|
||||
subtitle: '저장한 티어표 모아보기',
|
||||
contextTitle: '작성 이력',
|
||||
contextText: '최근 저장한 티어표를 열람하고 정리할 수 있어요.',
|
||||
actionLabel: '즐겨찾기 보기',
|
||||
action: () => router.push('/favorites'),
|
||||
action: () => router.push(favoritesPath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'favorites') {
|
||||
return {
|
||||
title: 'Favorites',
|
||||
title: '즐겨찾기',
|
||||
subtitle: '마음에 드는 티어표 모음',
|
||||
contextTitle: '정리 도구',
|
||||
contextText: '정렬과 검색으로 원하는 티어표를 빠르게 다시 찾을 수 있어요.',
|
||||
actionLabel: '내 티어표 보기',
|
||||
action: () => router.push('/me'),
|
||||
actionLabel: '나의 티어표 보기',
|
||||
action: () => router.push(mePath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'profile') {
|
||||
return {
|
||||
title: 'Profile',
|
||||
title: '설정',
|
||||
subtitle: '프로필 및 계정 설정',
|
||||
contextTitle: '계정 관리',
|
||||
contextText: '아바타와 닉네임을 관리하고 표시 정보를 확인할 수 있어요.',
|
||||
actionLabel: '내 티어표 보기',
|
||||
action: () => router.push('/me'),
|
||||
actionLabel: '나의 티어표 보기',
|
||||
action: () => router.push(mePath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'search') {
|
||||
return {
|
||||
title: 'Search',
|
||||
title: '검색',
|
||||
subtitle: '전체 공개 티어표 검색 결과',
|
||||
contextTitle: '검색',
|
||||
contextText: '제목, 작성자 기준으로 공개 티어표를 전체 검색할 수 있어요.',
|
||||
actionLabel: '홈으로',
|
||||
action: () => router.push('/'),
|
||||
action: () => router.push(homePath()),
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: 'Tier Maker',
|
||||
subtitle: 'by zenn',
|
||||
contextTitle: 'Workspace',
|
||||
subtitle: '주제 템플릿으로 만드는 티어표',
|
||||
contextTitle: '작업 공간',
|
||||
contextText: '현재 화면에 맞는 도구와 안내를 여기에 배치할 수 있습니다.',
|
||||
actionLabel: '홈으로',
|
||||
action: () => router.push('/'),
|
||||
action: () => router.push(homePath()),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -340,8 +346,8 @@ function toggleRightRail() {
|
||||
}
|
||||
}
|
||||
|
||||
function setGameHubViewMode(mode) {
|
||||
if (route.name !== 'gameHub') return
|
||||
function setTopicViewMode(mode) {
|
||||
if (route.name !== 'topicHub') return
|
||||
const nextQuery = { ...route.query }
|
||||
if (mode === 'list') nextQuery.view = 'list'
|
||||
else delete nextQuery.view
|
||||
@@ -391,11 +397,7 @@ function handleLeftRailSearch() {
|
||||
function submitGlobalSearch() {
|
||||
const query = (searchQuery.value || '').trim()
|
||||
isCollapsedSearchOpen.value = false
|
||||
if (route.name === 'home') {
|
||||
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
|
||||
return
|
||||
}
|
||||
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search')
|
||||
router.push(homePath(query))
|
||||
}
|
||||
|
||||
|
||||
@@ -429,7 +431,7 @@ function submitGlobalSearch() {
|
||||
<div class="leftRail__content">
|
||||
<div v-if="authReady && auth.user" class="appUserCard">
|
||||
<div class="appUserCard__button">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" draggable="false" />
|
||||
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
|
||||
<div class="appUserCard__meta">
|
||||
<div class="appUserCard__name">{{ accountName }}</div>
|
||||
@@ -444,10 +446,15 @@ function submitGlobalSearch() {
|
||||
<SvgIcon :src="iconSearch" :size="24" />
|
||||
</span>
|
||||
</button>
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : searchPlaceholder" />
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
||||
</form>
|
||||
|
||||
<nav class="leftNav">
|
||||
<nav
|
||||
class="leftNav"
|
||||
:class="{ 'leftNav--hasActive': activeLeftNavIndex >= 0 }"
|
||||
:style="{ '--left-nav-active-index': String(Math.max(activeLeftNavIndex, 0)) }"
|
||||
>
|
||||
<span class="leftNav__indicator" aria-hidden="true"></span>
|
||||
<RouterLink
|
||||
v-for="item in leftNavItems"
|
||||
:key="item.key"
|
||||
@@ -468,6 +475,15 @@ function submitGlobalSearch() {
|
||||
</div>
|
||||
<div class="leftRail__bottom">
|
||||
<RouterLink v-if="leftBottomPrimaryAction" :to="leftBottomPrimaryAction.to" class="adminButton">{{ leftBottomPrimaryAction.label }}</RouterLink>
|
||||
<RouterLink
|
||||
v-if="leftBottomPrimaryAction"
|
||||
:to="leftBottomPrimaryAction.to"
|
||||
class="leftRail__collapsedAction"
|
||||
:title="leftBottomPrimaryAction.label"
|
||||
:aria-label="leftBottomPrimaryAction.label"
|
||||
>
|
||||
<SvgIcon :src="leftBottomPrimaryAction.iconSrc || iconAddNotes" :size="24" />
|
||||
</RouterLink>
|
||||
<button v-if="showSettingsGuideButton" class="adminButton adminButton--icon" type="button" @click="openGuideModal()">
|
||||
<SvgIcon :src="iconMenuBook" :size="18" class="adminButton__icon" />
|
||||
<span>가이드 보기</span>
|
||||
@@ -483,22 +499,13 @@ function submitGlobalSearch() {
|
||||
<header class="workspaceHead railHeader">
|
||||
<div class="workspaceHead__brand" @click="$router.push('/')">
|
||||
<span class="workspaceHead__brandTitle">Tier Maker</span>
|
||||
<a
|
||||
class="workspaceHead__brandSub"
|
||||
href="https://zenn.town/@murabito"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@click.stop
|
||||
>
|
||||
by zenn
|
||||
</a>
|
||||
</div>
|
||||
<div class="workspaceHead__actions">
|
||||
<div v-if="showGameHubViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
|
||||
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': gameHubViewMode === 'grid' }" type="button" aria-label="그리드 보기" @click="setGameHubViewMode('grid')">
|
||||
<div v-if="showTopicViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
|
||||
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': topicViewMode === 'grid' }" type="button" aria-label="그리드 보기" @click="setTopicViewMode('grid')">
|
||||
<SvgIcon :src="iconGridView" :size="24" />
|
||||
</button>
|
||||
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': gameHubViewMode === 'list' }" type="button" aria-label="리스트 보기" @click="setGameHubViewMode('list')">
|
||||
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': topicViewMode === 'list' }" type="button" aria-label="리스트 보기" @click="setTopicViewMode('list')">
|
||||
<SvgIcon :src="iconLists" :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -513,12 +520,12 @@ function submitGlobalSearch() {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="searchPlaceholder" @click.self="closeCollapsedSearch">
|
||||
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="leftRailSearchPlaceholder" @click.self="closeCollapsedSearch">
|
||||
<form class="collapsedSearchBar" @submit.prevent="submitGlobalSearch">
|
||||
<span class="collapsedSearchBar__icon">
|
||||
<SvgIcon :src="iconSearch" :size="24" />
|
||||
</span>
|
||||
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="searchPlaceholder" autofocus />
|
||||
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="leftRailSearchPlaceholder" autofocus />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -621,6 +628,11 @@ function submitGlobalSearch() {
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
<div class="rightRail__footer">
|
||||
<span>Copyright © 2026 </span>
|
||||
<a :href="RIGHT_RAIL_COPYRIGHT_URL" target="_blank" rel="noreferrer">zenn</a>
|
||||
<span>. All rights reserved.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -741,8 +753,11 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.rightRail__content {
|
||||
flex: 0 0 auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ghostIcon {
|
||||
@@ -904,19 +919,45 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.leftNav {
|
||||
--left-nav-gap: 8px;
|
||||
--left-nav-item-height: 50px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: var(--left-nav-gap);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.leftNav__indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--left-nav-item-height);
|
||||
border-radius: 14px;
|
||||
background: var(--theme-surface-soft-3);
|
||||
transform: translateY(calc(var(--left-nav-active-index, 0) * (var(--left-nav-item-height) + var(--left-nav-gap))));
|
||||
transition: transform 240ms ease, opacity 200ms ease;
|
||||
opacity: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leftNav--hasActive .leftNav__indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.leftNav__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--left-nav-item-height);
|
||||
gap: 12px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 14px;
|
||||
color: var(--theme-text-muted);
|
||||
text-decoration: none;
|
||||
transition: background 180ms ease, color 180ms ease, transform 180ms ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.leftNav__label {
|
||||
@@ -929,7 +970,6 @@ function submitGlobalSearch() {
|
||||
|
||||
.leftNav__item--active,
|
||||
.leftNav__item.router-link-active {
|
||||
background: var(--theme-surface-soft-3);
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
|
||||
@@ -953,21 +993,24 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .appUserCard {
|
||||
margin-bottom: 10px;
|
||||
min-height: 50px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .appUserCard__button,
|
||||
.appShell--leftCollapsed .appUserCard__guest {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .appUserCard__meta,
|
||||
.appShell--leftCollapsed .leftNav__label,
|
||||
.appShell--leftCollapsed .searchStub__input {
|
||||
opacity: 0;
|
||||
max-width: 0;
|
||||
transform: translateX(-4px);
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .appUserCard__avatar {
|
||||
@@ -976,26 +1019,41 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .searchStub {
|
||||
height: 50px;
|
||||
margin-bottom: 0;
|
||||
padding: 11px 0;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .searchStub__iconButton {
|
||||
width: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftNav {
|
||||
gap: 10px;
|
||||
--left-nav-gap: 10px;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftNav__item {
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
height: 50px;
|
||||
padding: 11px 0;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__bottom {
|
||||
display: none;
|
||||
.appShell--leftCollapsed .leftNav__glyph {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__content {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
justify-items: stretch;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1007,6 +1065,10 @@ function submitGlobalSearch() {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.leftRail__collapsedAction {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.adminButton {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
@@ -1031,6 +1093,29 @@ function submitGlobalSearch() {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__bottom {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__bottom .adminButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__bottom .leftRail__collapsedAction {
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
height: 50px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border-strong);
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.appMain {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -1076,18 +1161,6 @@ function submitGlobalSearch() {
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
|
||||
.workspaceHead__brandSub {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text-muted);
|
||||
text-decoration: none;
|
||||
transition: color 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.workspaceHead__brandSub:hover {
|
||||
color: var(--theme-text);
|
||||
}
|
||||
|
||||
.workspaceHead__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -1145,13 +1218,31 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.rightRail__bottom {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.rightRail__footer {
|
||||
padding: 0 4px 2px;
|
||||
font-size: 9px;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
color: var(--theme-text-faint);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.rightRail__footer a {
|
||||
color: #00ffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rightRail__footer a:hover {
|
||||
color: #00ffff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.settingsThemePanel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1575,9 +1666,10 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.localRightRailRoot {
|
||||
min-height: auto;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
flex: 1 1 auto;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/icons/add_notes.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v268q-19-9-39-15.5t-41-9.5v-243H200v560h242q3 22 9.5 42t15.5 38H200Zm0-120v40-560 243-3 280Zm80-40h163q3-21 9.5-41t14.5-39H280v80Zm0-160h244q32-30 71.5-50t84.5-27v-3H280v80Zm0-160h400v-80H280v80ZM720-40q-83 0-141.5-58.5T520-240q0-83 58.5-141.5T720-440q83 0 141.5 58.5T920-240q0 83-58.5 141.5T720-40Zm-20-80h40v-100h100v-40H740v-100h-40v100H600v40h100v100Z"/></svg>
|
||||
|
After Width: | Height: | Size: 566 B |
1
frontend/src/assets/icons/dashboard_customize.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M120-840h320v320H120v-320Zm80 80v160-160Zm320-80h320v320H520v-320Zm80 80v160-160ZM120-440h320v320H120v-320Zm80 80v160-160Zm440-80h80v120h120v80H720v120h-80v-120H520v-80h120v-120Zm-40-320v160h160v-160H600Zm-400 0v160h160v-160H200Zm0 400v160h160v-160H200Z"/></svg>
|
||||
|
After Width: | Height: | Size: 377 B |
1
frontend/src/assets/icons/share.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M680-80q-50 0-85-35t-35-85q0-6 3-28L282-392q-16 15-37 23.5t-45 8.5q-50 0-85-35t-35-85q0-50 35-85t85-35q24 0 45 8.5t37 23.5l281-164q-2-7-2.5-13.5T560-760q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-24 0-45-8.5T598-672L317-508q2 7 2.5 13.5t.5 14.5q0 8-.5 14.5T317-452l281 164q16-15 37-23.5t45-8.5q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T720-200q0-17-11.5-28.5T680-240q-17 0-28.5 11.5T640-200q0 17 11.5 28.5T680-160ZM200-440q17 0 28.5-11.5T240-480q0-17-11.5-28.5T200-520q-17 0-28.5 11.5T160-480q0 17 11.5 28.5T200-440Zm508.5-291.5Q720-743 720-760t-11.5-28.5Q697-800 680-800t-28.5 11.5Q640-777 640-760t11.5 28.5Q663-720 680-720t28.5-11.5ZM680-200ZM200-480Zm480-280Z"/></svg>
|
||||
|
After Width: | Height: | Size: 810 B |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 496 B |
@@ -1,13 +1,13 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
featuredGames: { type: Array, required: true },
|
||||
availableGamesForFeatured: { type: Array, required: true },
|
||||
featuredGameIds: { type: Array, required: true },
|
||||
featuredTemplates: { type: Array, required: true },
|
||||
availableTemplatesForFeatured: { type: Array, required: true },
|
||||
featuredTemplateIds: { type: Array, required: true },
|
||||
featuredListRef: { type: Function, required: true },
|
||||
saveFeaturedOrder: { type: Function, required: true },
|
||||
moveFeaturedGame: { type: Function, required: true },
|
||||
removeFeaturedGame: { type: Function, required: true },
|
||||
addFeaturedGame: { type: Function, required: true },
|
||||
moveFeaturedTemplate: { type: Function, required: true },
|
||||
removeFeaturedTemplate: { type: Function, required: true },
|
||||
addFeaturedTemplate: { type: Function, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -16,7 +16,7 @@ const props = defineProps({
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">홈 화면 상단 고정 순서</div>
|
||||
<div class="hint hint--tight">여기에 넣은 게임은 지정한 순서대로 먼저 노출되고, 나머지 게임은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 수 있어요.</div>
|
||||
<div class="hint hint--tight">여기에 넣은 템플릿은 지정한 순서대로 먼저 노출되고, 나머지 템플릿은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 수 있어요.</div>
|
||||
</div>
|
||||
<button class="btn btn--primary" @click="props.saveFeaturedOrder">순서 저장</button>
|
||||
</div>
|
||||
@@ -24,38 +24,38 @@ const props = defineProps({
|
||||
<div class="featuredOrderPanel">
|
||||
<div class="featuredOrderPanel__list">
|
||||
<div class="section__title">상단 고정 목록</div>
|
||||
<div v-if="!props.featuredGames.length" class="hint">아직 상단 고정 게임이 없어요.</div>
|
||||
<div v-if="!props.featuredTemplates.length" class="hint">아직 상단 고정 템플릿이 없어요.</div>
|
||||
<div v-else :ref="props.featuredListRef" class="featuredList">
|
||||
<article v-for="(game, index) in props.featuredGames" :key="game.id" class="featuredCard" :data-featured-id="game.id">
|
||||
<article v-for="(template, index) in props.featuredTemplates" :key="template.id" class="featuredCard" :data-featured-id="template.id">
|
||||
<div class="featuredCard__meta">
|
||||
<span class="featuredCard__rank">{{ index + 1 }}</span>
|
||||
<div>
|
||||
<div class="featuredCard__title">{{ game.name }}</div>
|
||||
<div class="featuredCard__id">{{ game.id }}</div>
|
||||
<div class="featuredCard__title">{{ template.name }}</div>
|
||||
<div class="featuredCard__id">{{ template.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="featuredCard__actions">
|
||||
<button class="btn btn--ghost btn--small" data-featured-handle>드래그</button>
|
||||
<button class="btn btn--ghost btn--small" :disabled="index === 0" @click="props.moveFeaturedGame(game.id, -1)">위로</button>
|
||||
<button class="btn btn--ghost btn--small" :disabled="index === props.featuredGames.length - 1" @click="props.moveFeaturedGame(game.id, 1)">아래로</button>
|
||||
<button class="btn btn--danger btn--small" @click="props.removeFeaturedGame(game.id)">제외</button>
|
||||
<button class="btn btn--ghost btn--small" :disabled="index === 0" @click="props.moveFeaturedTemplate(template.id, -1)">위로</button>
|
||||
<button class="btn btn--ghost btn--small" :disabled="index === props.featuredTemplates.length - 1" @click="props.moveFeaturedTemplate(template.id, 1)">아래로</button>
|
||||
<button class="btn btn--danger btn--small" @click="props.removeFeaturedTemplate(template.id)">제외</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="featuredOrderPanel__picker">
|
||||
<div class="section__title">게임 추가</div>
|
||||
<div class="section__title">템플릿 추가</div>
|
||||
<div class="featuredPickerList">
|
||||
<button
|
||||
v-for="game in props.availableGamesForFeatured"
|
||||
:key="game.id"
|
||||
v-for="template in props.availableTemplatesForFeatured"
|
||||
:key="template.id"
|
||||
class="featuredPickerItem"
|
||||
:disabled="props.featuredGameIds.length >= 50"
|
||||
@click="props.addFeaturedGame(game.id)"
|
||||
:disabled="props.featuredTemplateIds.length >= 50"
|
||||
@click="props.addFeaturedTemplate(template.id)"
|
||||
>
|
||||
<span>{{ game.name }}</span>
|
||||
<span class="featuredPickerItem__id">{{ game.id }}</span>
|
||||
<span>{{ template.name }}</span>
|
||||
<span class="featuredPickerItem__id">{{ template.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,24 @@ const props = defineProps({
|
||||
templateRequestSourceUrl: { type: Function, required: true },
|
||||
stagedRequestDraftCount: { type: Number, required: true },
|
||||
appliedRequestItemCount: { type: Number, required: true },
|
||||
openGameCreateModal: { type: Function, required: true },
|
||||
openTemplateCreateModal: { type: Function, required: true },
|
||||
isGameLoading: { type: Boolean, required: true },
|
||||
hasSelectedGame: { type: Boolean, required: true },
|
||||
selectedGame: { type: Object, default: null },
|
||||
hasSelectedTemplate: { type: Boolean, required: true },
|
||||
selectedTemplate: { type: Object, default: null },
|
||||
displayThumbnailUrl: { type: String, default: '' },
|
||||
canApplyThumbnail: { type: Boolean, required: true },
|
||||
gameVisibilitySaving: { type: Boolean, required: true },
|
||||
thumbFileInputRef: { type: Function, required: true },
|
||||
openThumbFilePicker: { type: Function, required: true },
|
||||
onThumb: { type: Function, required: true },
|
||||
onThumbDragEnter: { type: Function, required: true },
|
||||
onThumbDragOver: { type: Function, required: true },
|
||||
onThumbDragLeave: { type: Function, required: true },
|
||||
onThumbDrop: { type: Function, required: true },
|
||||
isThumbDragOver: { type: Boolean, required: true },
|
||||
uploadThumbnail: { type: Function, required: true },
|
||||
removeTemplate: { type: Function, required: true },
|
||||
toggleSelectedTemplateVisibility: { type: Function, required: true },
|
||||
itemFileInputRef: { type: Function, required: true },
|
||||
onFile: { type: Function, required: true },
|
||||
isItemDragOver: { type: Boolean, required: true },
|
||||
@@ -25,17 +39,21 @@ const props = defineProps({
|
||||
canAddItem: { type: Boolean, required: true },
|
||||
uploadItem: { type: Function, required: true },
|
||||
removeUploadDraft: { type: Function, required: true },
|
||||
hasGameItemOrderChanges: { type: Boolean, required: true },
|
||||
saveGameItemOrder: { type: Function, required: true },
|
||||
hasTemplateItemOrderChanges: { type: Boolean, required: true },
|
||||
saveTemplateItemOrder: { type: Function, required: true },
|
||||
gameItemListRef: { type: Function, required: true },
|
||||
saveGameItemLabel: { type: Function, required: true },
|
||||
removeGameItem: { type: Function, required: true },
|
||||
selectedGameId: { type: String, default: '' },
|
||||
saveTemplateItemLabel: { type: Function, required: true },
|
||||
removeTemplateItem: { type: Function, required: true },
|
||||
selectedTemplateId: { type: String, default: '' },
|
||||
})
|
||||
|
||||
function setGameItemListElement(el) {
|
||||
props.gameItemListRef(el)
|
||||
}
|
||||
|
||||
function setThumbFileElement(el) {
|
||||
props.thumbFileInputRef(el)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -49,17 +67,17 @@ function setGameItemListElement(el) {
|
||||
props.activeTemplateRequest.type === 'create'
|
||||
? (props.activeTemplateRequest.targetGameId
|
||||
? '이미 연결된 신규 템플릿이 있어요. 필요한 아이템만 골라 저장하세요.'
|
||||
: '새 게임을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.')
|
||||
: '새 템플릿을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.')
|
||||
: '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="requestWorkspace__stats">
|
||||
<span class="pill pill--accent">{{ props.activeTemplateRequest.type === 'create' ? '신규 게임 요청' : '기존 게임 업데이트' }}</span>
|
||||
<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 }}
|
||||
연결된 템플릿 · {{ props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,27 +95,57 @@ function setGameItemListElement(el) {
|
||||
v-if="props.activeTemplateRequest.type === 'create' && !props.activeTemplateRequest.targetGameId"
|
||||
class="btn btn--ghost btn--small"
|
||||
type="button"
|
||||
@click="props.openGameCreateModal"
|
||||
@click="props.openTemplateCreateModal"
|
||||
>
|
||||
새 게임 만들기
|
||||
새 템플릿 만들기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.isGameLoading" class="panel panel--empty">
|
||||
<div class="emptyState">
|
||||
<div class="emptyState__title">게임 정보를 불러오는 중이에요.</div>
|
||||
<div class="emptyState__desc">선택한 게임의 썸네일과 기본 아이템을 곧 표시합니다.</div>
|
||||
<div class="emptyState__title">템플릿 정보를 불러오는 중이에요.</div>
|
||||
<div class="emptyState__desc">선택한 템플릿의 썸네일과 기본 아이템을 곧 표시합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.hasSelectedGame" class="panel">
|
||||
<div class="detailHead">
|
||||
<div>
|
||||
<div class="panel__title">선택된 게임 정보</div>
|
||||
<div class="selectedGame__name">{{ props.selectedGame.game.name }}</div>
|
||||
<div class="selectedGame__id">{{ props.selectedGame.game.id }}</div>
|
||||
<div v-else-if="props.hasSelectedTemplate" class="panel">
|
||||
<section class="adminCard gameSettingsCard">
|
||||
<div class="gameSettingsCard__media">
|
||||
<input :ref="setThumbFileElement" type="file" accept="image/*" class="srOnlyInput" @change="props.onThumb" />
|
||||
<button
|
||||
class="thumbDropZone"
|
||||
:class="{ 'thumbDropZone--active': props.isThumbDragOver }"
|
||||
type="button"
|
||||
@click="props.openThumbFilePicker"
|
||||
@dragenter="props.onThumbDragEnter"
|
||||
@dragover="props.onThumbDragOver"
|
||||
@dragleave="props.onThumbDragLeave"
|
||||
@drop="props.onThumbDrop"
|
||||
>
|
||||
<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">
|
||||
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" />
|
||||
</div>
|
||||
<div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gameSettingsCard__body">
|
||||
<div class="panel__title">템플릿 설정</div>
|
||||
<div class="gameSettingsCard__meta">{{ props.selectedTemplate.game.name }} · {{ props.selectedTemplate.game.id }}</div>
|
||||
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.gameVisibilitySaving }">
|
||||
<input :checked="!!props.selectedTemplate.game.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" />
|
||||
<span class="toggleSwitch__label">{{ props.selectedTemplate.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
|
||||
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||
</label>
|
||||
<div class="gameSettingsCard__actions">
|
||||
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
|
||||
<button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="section">
|
||||
<section class="adminCard">
|
||||
@@ -164,11 +212,11 @@ function setGameItemListElement(el) {
|
||||
<div class="section__title">현재 기본 아이템 목록</div>
|
||||
<div class="hint hint--tight">드래그해서 기본 노출 순서를 바꿀 수 있어요. 저장 전까지는 화면에서만 순서가 바뀝니다.</div>
|
||||
</div>
|
||||
<button class="btn btn--primary btn--small" :disabled="!props.hasGameItemOrderChanges" @click="props.saveGameItemOrder">순서 저장</button>
|
||||
<button class="btn btn--primary btn--small" :disabled="!props.hasTemplateItemOrderChanges" @click="props.saveTemplateItemOrder">순서 저장</button>
|
||||
</div>
|
||||
<div v-if="!props.selectedGame?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
|
||||
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
|
||||
<div v-else :ref="setGameItemListElement" class="thumbGrid">
|
||||
<div v-for="item in props.selectedGame.items" :key="item.id" class="thumbCard" :data-game-item-id="item.id">
|
||||
<div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-game-item-id="item.id">
|
||||
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
|
||||
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
|
||||
<div class="thumbCard__actions">
|
||||
@@ -176,11 +224,11 @@ function setGameItemListElement(el) {
|
||||
class="btn btn--ghost btn--small"
|
||||
data-no-drag
|
||||
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
|
||||
@click="props.saveGameItemLabel(item)"
|
||||
@click="props.saveTemplateItemLabel(item)"
|
||||
>
|
||||
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
|
||||
</button>
|
||||
<button class="btn btn--danger btn--small" data-no-drag @click="props.removeGameItem(item.id)">아이템 삭제</button>
|
||||
<button class="btn btn--danger btn--small" data-no-drag @click="props.removeTemplateItem(item.id)">아이템 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,9 +236,9 @@ function setGameItemListElement(el) {
|
||||
</div>
|
||||
<div v-else class="panel panel--empty">
|
||||
<div class="emptyState">
|
||||
<div class="emptyState__title">게임을 선택해 주세요.</div>
|
||||
<div v-if="props.activeTemplateRequest?.type === 'create'" class="hint hint--tight">진행 중인 신규 게임 요청이 있어요. 위의 `새 게임 만들기`로 게임을 만든 뒤 아이템을 추가할 수 있습니다.</div>
|
||||
<div v-if="props.selectedGameId" class="hint hint--tight">선택한 게임을 찾지 못했거나 로딩 중 오류가 발생했어요. 다시 선택해보세요.</div>
|
||||
<div class="emptyState__title">템플릿을 선택해 주세요.</div>
|
||||
<div v-if="props.activeTemplateRequest?.type === 'create'" class="hint hint--tight">진행 중인 신규 템플릿 요청이 있어요. 위의 `새 템플릿 만들기`로 템플릿을 만든 뒤 아이템을 추가할 수 있습니다.</div>
|
||||
<div v-if="props.selectedTemplateId" class="hint hint--tight">선택한 템플릿을 찾지 못했거나 로딩 중 오류가 발생했어요. 다시 선택해보세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,6 +21,8 @@ const props = defineProps({
|
||||
adminTierListPage: { type: Number, required: true },
|
||||
adminTierListPageCount: { type: Number, required: true },
|
||||
adminTierListTotal: { type: Number, required: true },
|
||||
adminTierListStats: { type: Object, required: true },
|
||||
openAdminTierListManageModal: { type: Function, required: true },
|
||||
moveAdminTierListPage: { type: Function, required: true },
|
||||
})
|
||||
</script>
|
||||
@@ -37,25 +39,32 @@ const props = defineProps({
|
||||
<div v-else class="templateRequestList">
|
||||
<article v-for="request in props.templateRequests" :key="request.id" class="tierAdminCard templateRequestCard templateRequestCard--aligned">
|
||||
<div class="templateRequestCard__side">
|
||||
<button class="tierAdminCard__preview templateRequestCard__preview" type="button" @click="props.openTemplateRequestPreview(request)">
|
||||
<img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" />
|
||||
<a
|
||||
class="tierAdminCard__preview templateRequestCard__preview"
|
||||
:href="props.templateRequestSourceUrl(request) || undefined"
|
||||
: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>
|
||||
</button>
|
||||
</a>
|
||||
<div class="templateRequestCard__thumbMeta">
|
||||
<template v-if="request.type === 'create'">
|
||||
<label class="templateRequestField">
|
||||
<span class="templateRequestField__label">게임 이름</span>
|
||||
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
|
||||
<span class="templateRequestField__label">템플릿 이름</span>
|
||||
<input v-model="request.draftGameName" class="input" placeholder="새 템플릿 이름" />
|
||||
</label>
|
||||
<label class="templateRequestField">
|
||||
<span class="templateRequestField__label">게임 ID</span>
|
||||
<input v-model="request.draftGameId" class="input" placeholder="임시 게임 ID" />
|
||||
<span class="templateRequestField__label">템플릿 ID</span>
|
||||
<input v-model="request.draftGameId" class="input" placeholder="임시 템플릿 ID" />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="templateRequestCard__thumbLabel">게임 이름</div>
|
||||
<div class="templateRequestCard__thumbLabel">템플릿 이름</div>
|
||||
<div class="templateRequestCard__thumbValue">{{ request.draftGameName || request.sourceGameName || '-' }}</div>
|
||||
<div class="templateRequestCard__thumbLabel">게임 ID</div>
|
||||
<div class="templateRequestCard__thumbLabel">템플릿 ID</div>
|
||||
<div class="templateRequestCard__thumbValue">{{ request.draftGameId || request.sourceGameId || '-' }}</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -95,24 +104,14 @@ const props = defineProps({
|
||||
</div>
|
||||
|
||||
<div class="templateRequestCard__footer">
|
||||
<div class="templateRequestCard__footerLeft">
|
||||
<a
|
||||
v-if="props.templateRequestSourceUrl(request)"
|
||||
class="btn btn--ghost btn--small"
|
||||
:href="props.templateRequestSourceUrl(request)"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
요청 티어표 보기
|
||||
</a>
|
||||
</div>
|
||||
<div class="templateRequestCard__footerLeft"></div>
|
||||
<div class="templateRequestCard__actions">
|
||||
<button class="btn btn--primary" :disabled="request.isHandling" @click="props.startTemplateRequestReview(request)">
|
||||
{{
|
||||
request.isHandling
|
||||
? '이동중...'
|
||||
: request.type === 'create' && (request.targetGameName || request.targetGameId)
|
||||
? '연결된 게임 열기'
|
||||
? '연결된 템플릿 열기'
|
||||
: '확인하기'
|
||||
}}
|
||||
</button>
|
||||
@@ -128,6 +127,11 @@ const props = defineProps({
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">전체 티어표 관리</div>
|
||||
<div class="tierAdminHeaderStats">
|
||||
<span class="pill">전체 {{ props.adminTierListStats.total || 0 }}개</span>
|
||||
<span class="pill pill--soft">공개 {{ props.adminTierListStats.publicCount || 0 }}개</span>
|
||||
<span class="pill pill--soft">비공개 {{ props.adminTierListStats.privateCount || 0 }}개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,7 +139,7 @@ const props = defineProps({
|
||||
<div v-else class="tierAdminList">
|
||||
<article v-for="tierList in props.adminTierLists" :key="tierList.id" class="tierAdminCard">
|
||||
<button class="tierAdminCard__preview" type="button" @click="props.openAdminTierList(tierList)">
|
||||
<img v-if="props.tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="props.tierListThumbUrl(tierList)" :alt="tierList.title" />
|
||||
<img v-if="props.tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="props.tierListThumbUrl(tierList)" :alt="tierList.title" draggable="false" />
|
||||
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
|
||||
</button>
|
||||
|
||||
@@ -145,13 +149,14 @@ 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) }} · {{ props.tierListVisibilityLabel(tierList) }}
|
||||
{{ tierList.gameName || tierList.gameId }} · {{ props.tierListAuthorDisplayName(tierList) }}
|
||||
</div>
|
||||
<div class="tierAdminCard__meta">{{ props.fmt(tierList.updatedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tierAdminCard__stats">
|
||||
<span class="pill" :class="tierList.isPublic ? 'pill--public' : 'pill--private'">{{ props.tierListVisibilityLabel(tierList) }}</span>
|
||||
<span class="pill">전체 아이템 {{ tierList.itemCount }}개</span>
|
||||
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}개</span>
|
||||
</div>
|
||||
@@ -171,6 +176,10 @@ const props = defineProps({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tierAdminSection__actions">
|
||||
<button class="btn btn--ghost btn--small" @click="props.openAdminTierListManageModal(tierList)">관리</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -8,22 +8,20 @@ export function useAdminCustomItems({
|
||||
customItemLimit,
|
||||
customItemPageCount,
|
||||
customItemQuery,
|
||||
customItemOrphanOnly,
|
||||
customItemFilter,
|
||||
customItemModalOpen,
|
||||
customItemDeleteModalOpen,
|
||||
customItemModalHistoryActive,
|
||||
modalTargetCustomItem,
|
||||
customItemModalDraftLabel,
|
||||
customItemModalLabelSaving,
|
||||
customItemModalTargetGameId,
|
||||
customItemModalGameQuery,
|
||||
customItemModalGameSort,
|
||||
games,
|
||||
selectedGameId,
|
||||
customItemModalTargetTemplateId,
|
||||
templates,
|
||||
selectedTemplateId,
|
||||
refreshCustomItems,
|
||||
loadGame,
|
||||
loadTemplate,
|
||||
setTab,
|
||||
selectAdminGame,
|
||||
selectAdminTemplate,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
@@ -33,7 +31,8 @@ export function useAdminCustomItems({
|
||||
refreshCustomItems()
|
||||
}
|
||||
|
||||
function toggleCustomItemOrphanOnly() {
|
||||
function changeCustomItemFilter(filter) {
|
||||
customItemFilter.value = filter
|
||||
customItemPage.value = 1
|
||||
refreshCustomItems()
|
||||
}
|
||||
@@ -60,9 +59,7 @@ export function useAdminCustomItems({
|
||||
function openCustomItemModal(item) {
|
||||
modalTargetCustomItem.value = item || null
|
||||
customItemModalDraftLabel.value = item?.label || ''
|
||||
customItemModalTargetGameId.value = ''
|
||||
customItemModalGameQuery.value = ''
|
||||
customItemModalGameSort.value = 'recent'
|
||||
customItemModalTargetTemplateId.value = ''
|
||||
customItemModalOpen.value = true
|
||||
pushCustomItemModalHistoryState()
|
||||
}
|
||||
@@ -73,9 +70,7 @@ export function useAdminCustomItems({
|
||||
modalTargetCustomItem.value = null
|
||||
customItemModalDraftLabel.value = ''
|
||||
customItemModalLabelSaving.value = false
|
||||
customItemModalTargetGameId.value = ''
|
||||
customItemModalGameQuery.value = ''
|
||||
customItemModalGameSort.value = 'recent'
|
||||
customItemModalTargetTemplateId.value = ''
|
||||
|
||||
if (fromPopState) {
|
||||
customItemModalHistoryActive.value = false
|
||||
@@ -102,12 +97,12 @@ export function useAdminCustomItems({
|
||||
customItemDeleteModalOpen.value = false
|
||||
}
|
||||
|
||||
function jumpToGameAdmin(gameId) {
|
||||
if (!gameId) return
|
||||
function jumpToTemplateAdmin(templateId) {
|
||||
if (!templateId) return
|
||||
closeCustomItemModal()
|
||||
setTab('game-admin')
|
||||
nextTick(() => {
|
||||
selectAdminGame(gameId)
|
||||
selectAdminTemplate(templateId)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,18 +160,19 @@ export function useAdminCustomItems({
|
||||
|
||||
async function promoteCustomItem(item) {
|
||||
resetMessages()
|
||||
if (!customItemModalTargetGameId.value) {
|
||||
error.value = '추가할 게임을 먼저 선택해주세요.'
|
||||
if (!customItemModalTargetTemplateId.value) {
|
||||
error.value = '추가할 템플릿을 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
item.isPromoting = true
|
||||
await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetGameId.value })
|
||||
const targetGameName = games.value.find((game) => game.id === customItemModalTargetGameId.value)?.name || customItemModalTargetGameId.value
|
||||
if (selectedGameId.value === customItemModalTargetGameId.value) await loadGame()
|
||||
await api.promoteAdminTemplateItem(item.id, { gameId: customItemModalTargetTemplateId.value })
|
||||
const targetTemplateName =
|
||||
templates.value.find((template) => template.id === customItemModalTargetTemplateId.value)?.name || customItemModalTargetTemplateId.value
|
||||
if (selectedTemplateId.value === customItemModalTargetTemplateId.value) await loadTemplate()
|
||||
closeCustomItemModal()
|
||||
success.value = `"${item.label}" 이미지를 ${targetGameName} 템플릿으로 추가했어요.`
|
||||
success.value = `"${item.label}" 이미지를 ${targetTemplateName} 템플릿으로 추가했어요.`
|
||||
} catch (e) {
|
||||
error.value = '선택한 이미지를 템플릿으로 추가하지 못했어요.'
|
||||
} finally {
|
||||
@@ -186,7 +182,7 @@ export function useAdminCustomItems({
|
||||
|
||||
return {
|
||||
submitCustomItemSearch,
|
||||
toggleCustomItemOrphanOnly,
|
||||
changeCustomItemFilter,
|
||||
changeCustomItemLimit,
|
||||
moveCustomItemPage,
|
||||
pushCustomItemModalHistoryState,
|
||||
@@ -194,7 +190,7 @@ export function useAdminCustomItems({
|
||||
closeCustomItemModal,
|
||||
openCustomItemDeleteModal,
|
||||
closeCustomItemDeleteModal,
|
||||
jumpToGameAdmin,
|
||||
jumpToTemplateAdmin,
|
||||
removeCustomItem,
|
||||
removeUnusedCustomItems,
|
||||
saveCustomItemModalLabel,
|
||||
|
||||
@@ -5,8 +5,8 @@ export function useAdminFeaturedGames({
|
||||
api,
|
||||
featuredListEl,
|
||||
featuredSortable,
|
||||
featuredGameIds,
|
||||
games,
|
||||
featuredTemplateIds,
|
||||
templates,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
@@ -31,63 +31,63 @@ export function useAdminFeaturedGames({
|
||||
chosenClass: 'chosen',
|
||||
onEnd: (evt) => {
|
||||
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
|
||||
const nextIds = [...featuredGameIds.value]
|
||||
const nextIds = [...featuredTemplateIds.value]
|
||||
const [moved] = nextIds.splice(evt.oldIndex, 1)
|
||||
nextIds.splice(evt.newIndex, 0, moved)
|
||||
featuredGameIds.value = nextIds
|
||||
featuredTemplateIds.value = nextIds
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function addFeaturedGame(gameId) {
|
||||
function addFeaturedTemplate(templateId) {
|
||||
resetMessages()
|
||||
if (!gameId || featuredGameIds.value.includes(gameId)) return
|
||||
if (featuredGameIds.value.length >= 50) {
|
||||
error.value = '상단 고정 게임은 최대 50개까지만 설정할 수 있어요.'
|
||||
if (!templateId || featuredTemplateIds.value.includes(templateId)) return
|
||||
if (featuredTemplateIds.value.length >= 50) {
|
||||
error.value = '상단 고정 템플릿은 최대 50개까지만 설정할 수 있어요.'
|
||||
return
|
||||
}
|
||||
featuredGameIds.value = [...featuredGameIds.value, gameId]
|
||||
featuredTemplateIds.value = [...featuredTemplateIds.value, templateId]
|
||||
syncFeaturedSortable()
|
||||
}
|
||||
|
||||
function removeFeaturedGame(gameId) {
|
||||
function removeFeaturedTemplate(templateId) {
|
||||
resetMessages()
|
||||
featuredGameIds.value = featuredGameIds.value.filter((id) => id !== gameId)
|
||||
featuredTemplateIds.value = featuredTemplateIds.value.filter((id) => id !== templateId)
|
||||
syncFeaturedSortable()
|
||||
}
|
||||
|
||||
function moveFeaturedGame(gameId, direction) {
|
||||
const currentIndex = featuredGameIds.value.indexOf(gameId)
|
||||
function moveFeaturedTemplate(templateId, direction) {
|
||||
const currentIndex = featuredTemplateIds.value.indexOf(templateId)
|
||||
const nextIndex = currentIndex + direction
|
||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredGameIds.value.length) return
|
||||
const nextIds = [...featuredGameIds.value]
|
||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredTemplateIds.value.length) return
|
||||
const nextIds = [...featuredTemplateIds.value]
|
||||
const [moved] = nextIds.splice(currentIndex, 1)
|
||||
nextIds.splice(nextIndex, 0, moved)
|
||||
featuredGameIds.value = nextIds
|
||||
featuredTemplateIds.value = nextIds
|
||||
syncFeaturedSortable()
|
||||
}
|
||||
|
||||
async function saveFeaturedOrder() {
|
||||
resetMessages()
|
||||
try {
|
||||
const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredGameIds.value })
|
||||
games.value = data.games || []
|
||||
featuredGameIds.value = games.value
|
||||
.filter((game) => game.displayRank != null)
|
||||
const data = await api.updateAdminTemplateDisplayOrder({ gameIds: featuredTemplateIds.value })
|
||||
templates.value = data.games || []
|
||||
featuredTemplateIds.value = templates.value
|
||||
.filter((template) => template.displayRank != null)
|
||||
.sort((a, b) => a.displayRank - b.displayRank)
|
||||
.map((game) => game.id)
|
||||
success.value = '홈 화면 게임 순서를 저장했어요.'
|
||||
.map((template) => template.id)
|
||||
success.value = '홈 화면 템플릿 순서를 저장했어요.'
|
||||
} catch (e) {
|
||||
error.value = '게임 순서 저장에 실패했어요.'
|
||||
error.value = '템플릿 순서 저장에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
destroyFeaturedSortable,
|
||||
syncFeaturedSortable,
|
||||
addFeaturedGame,
|
||||
removeFeaturedGame,
|
||||
moveFeaturedGame,
|
||||
addFeaturedTemplate,
|
||||
removeFeaturedTemplate,
|
||||
moveFeaturedTemplate,
|
||||
saveFeaturedOrder,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import Sortable from 'sortablejs'
|
||||
export function useAdminGameManager({
|
||||
api,
|
||||
toApiUrl,
|
||||
selectedGameId,
|
||||
selectedGame,
|
||||
selectedTemplateId,
|
||||
selectedTemplate,
|
||||
uploadFiles,
|
||||
uploadItemDrafts,
|
||||
thumbFile,
|
||||
@@ -18,15 +18,15 @@ export function useAdminGameManager({
|
||||
activeTemplateRequest,
|
||||
templateRequests,
|
||||
customItemModalOpen,
|
||||
customItemModalTargetGameId,
|
||||
newGameId,
|
||||
newGameName,
|
||||
newGameIsPublic,
|
||||
customItemModalTargetTemplateId,
|
||||
newTemplateId,
|
||||
newTemplateName,
|
||||
newTemplateIsPublic,
|
||||
clearPreviewUrl,
|
||||
resetFileInput,
|
||||
resetUploadState,
|
||||
refreshGames,
|
||||
closeGameCreateModal,
|
||||
refreshTemplates,
|
||||
closeTemplateCreateModal,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
@@ -59,7 +59,7 @@ export function useAdminGameManager({
|
||||
async function syncGameItemSortable() {
|
||||
await nextTick()
|
||||
destroyGameItemSortable()
|
||||
if (!gameItemListEl.value || !selectedGame.value?.items?.length) return
|
||||
if (!gameItemListEl.value || !selectedTemplate.value?.items?.length) return
|
||||
|
||||
gameItemSortable.value = Sortable.create(gameItemListEl.value, {
|
||||
animation: 160,
|
||||
@@ -73,11 +73,11 @@ export function useAdminGameManager({
|
||||
chosenClass: 'chosen',
|
||||
onEnd: (evt) => {
|
||||
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
|
||||
const nextItems = [...(selectedGame.value?.items || [])]
|
||||
const nextItems = [...(selectedTemplate.value?.items || [])]
|
||||
const [moved] = nextItems.splice(evt.oldIndex, 1)
|
||||
nextItems.splice(evt.newIndex, 0, moved)
|
||||
selectedGame.value = {
|
||||
...selectedGame.value,
|
||||
selectedTemplate.value = {
|
||||
...selectedTemplate.value,
|
||||
items: nextItems,
|
||||
}
|
||||
},
|
||||
@@ -87,7 +87,7 @@ export function useAdminGameManager({
|
||||
function mergeRequestItemsIntoDrafts(request) {
|
||||
const requestId = request?.id
|
||||
if (!requestId) return
|
||||
const existingGameSrcs = new Set((selectedGame.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean))
|
||||
const existingTemplateSrcs = new Set((selectedTemplate.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean))
|
||||
const existingKeys = new Set(uploadItemDrafts.value.map((draft) => `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}`))
|
||||
const nextRequestDrafts = (request.items || [])
|
||||
.filter((item) => item?.src)
|
||||
@@ -100,7 +100,7 @@ export function useAdminGameManager({
|
||||
sourceName: requestItemFilename(item),
|
||||
src: item.src,
|
||||
}))
|
||||
.filter((draft) => !existingGameSrcs.has(normalizeDraftSrc(draft.src)))
|
||||
.filter((draft) => !existingTemplateSrcs.has(normalizeDraftSrc(draft.src)))
|
||||
.filter((draft) => !existingKeys.has(`${draft.kind}:${draft.requestId}:${draft.itemId}`))
|
||||
|
||||
if (nextRequestDrafts.length) {
|
||||
@@ -117,13 +117,13 @@ export function useAdminGameManager({
|
||||
uploadFiles.value = uploadItemDrafts.value.filter((draft) => draft.kind === 'file').map((draft) => draft.file).filter(Boolean)
|
||||
}
|
||||
|
||||
async function loadGame(options = {}) {
|
||||
async function loadTemplate(options = {}) {
|
||||
const preserveUploadState = !!options.preserveUploadState
|
||||
resetMessages()
|
||||
if (!preserveUploadState) resetUploadState()
|
||||
|
||||
if (!selectedGameId.value) {
|
||||
selectedGame.value = null
|
||||
if (!selectedTemplateId.value) {
|
||||
selectedTemplate.value = null
|
||||
savedGameItemOrderIds.value = []
|
||||
destroyGameItemSortable()
|
||||
return
|
||||
@@ -131,8 +131,8 @@ export function useAdminGameManager({
|
||||
|
||||
try {
|
||||
isGameLoading.value = true
|
||||
const data = await api.getGame(selectedGameId.value)
|
||||
selectedGame.value = {
|
||||
const data = await api.getTopic(selectedTemplateId.value)
|
||||
selectedTemplate.value = {
|
||||
...data,
|
||||
items: (data.items || []).map((item) => ({
|
||||
...item,
|
||||
@@ -142,27 +142,27 @@ export function useAdminGameManager({
|
||||
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||
await syncGameItemSortable()
|
||||
} catch (e) {
|
||||
selectedGame.value = null
|
||||
error.value = '게임 정보를 불러오지 못했어요.'
|
||||
selectedTemplate.value = null
|
||||
error.value = '템플릿 정보를 불러오지 못했어요.'
|
||||
} finally {
|
||||
isGameLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createGame(options = {}) {
|
||||
const nextGameId = typeof options.gameId === 'string' ? options.gameId.trim() : newGameId.value.trim()
|
||||
const nextGameName = typeof options.gameName === 'string' ? options.gameName.trim() : newGameName.value.trim()
|
||||
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 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,
|
||||
isPublic: !!newGameIsPublic.value,
|
||||
isPublic: !!newTemplateIsPublic.value,
|
||||
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
|
||||
}),
|
||||
})
|
||||
@@ -187,18 +187,18 @@ export function useAdminGameManager({
|
||||
})
|
||||
}
|
||||
}
|
||||
await refreshGames()
|
||||
selectedGameId.value = data.game.id
|
||||
if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id
|
||||
closeGameCreateModal()
|
||||
await loadGame({ preserveUploadState })
|
||||
await refreshTemplates()
|
||||
selectedTemplateId.value = data.game.id
|
||||
if (customItemModalOpen.value) customItemModalTargetTemplateId.value = data.game.id
|
||||
closeTemplateCreateModal()
|
||||
await loadTemplate({ preserveUploadState })
|
||||
if (!preserveUploadState && activeTemplateRequest.value?.id) {
|
||||
const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value
|
||||
mergeRequestItemsIntoDrafts(sourceRequest)
|
||||
}
|
||||
success.value = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
|
||||
success.value = '템플릿이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
|
||||
} catch (e) {
|
||||
error.value = '게임 생성 실패(관리자 권한/중복 ID 확인)'
|
||||
error.value = '템플릿 생성 실패(관리자 권한/중복 ID 확인)'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,22 +254,22 @@ export function useAdminGameManager({
|
||||
}
|
||||
|
||||
try {
|
||||
if (!selectedGameId.value && activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetGameId) {
|
||||
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) {
|
||||
error.value = '먼저 신규 템플릿의 게임 이름과 게임 ID를 저장해주세요.'
|
||||
error.value = '먼저 신규 템플릿의 이름과 템플릿 ID를 저장해주세요.'
|
||||
return
|
||||
}
|
||||
await createGame({
|
||||
await createTemplate({
|
||||
gameId: draftGameId,
|
||||
gameName: draftGameName,
|
||||
preserveUploadState: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (!selectedGameId.value) {
|
||||
error.value = '게임을 먼저 선택해주세요.'
|
||||
if (!selectedTemplateId.value) {
|
||||
error.value = '템플릿을 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ export function useAdminGameManager({
|
||||
fd.append('images', entry.file)
|
||||
fd.append('labels', entry.label.trim())
|
||||
})
|
||||
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/images`), {
|
||||
const res = await fetch(toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/images`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
@@ -297,7 +297,7 @@ export function useAdminGameManager({
|
||||
for (const requestId of requestIds) {
|
||||
const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId)
|
||||
const result = await api.promoteAdminTemplateRequestItems(requestId, {
|
||||
gameId: selectedGameId.value,
|
||||
gameId: selectedTemplateId.value,
|
||||
itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean),
|
||||
itemSrcs: draftsForRequest.map((entry) => entry.src).filter(Boolean),
|
||||
itemLabels: draftsForRequest.reduce((acc, entry) => {
|
||||
@@ -310,8 +310,8 @@ export function useAdminGameManager({
|
||||
}
|
||||
|
||||
resetUploadState()
|
||||
await loadGame()
|
||||
success.value = `게임 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
|
||||
await loadTemplate()
|
||||
success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
|
||||
} catch (e) {
|
||||
const apiError = e?.data?.error || ''
|
||||
if (apiError === 'no_items_selected') {
|
||||
@@ -320,27 +320,27 @@ export function useAdminGameManager({
|
||||
}
|
||||
if (apiError === 'promote_items_failed') {
|
||||
const detail = e?.data?.detail ? ` (${e.data.detail})` : ''
|
||||
error.value = `요청 아이템을 게임 기본 아이템으로 옮기지 못했어요.${detail}`
|
||||
error.value = `요청 아이템을 템플릿 기본 아이템으로 옮기지 못했어요.${detail}`
|
||||
return
|
||||
}
|
||||
if (apiError === 'game_not_found') {
|
||||
error.value = '선택한 게임을 찾지 못했어요.'
|
||||
error.value = '선택한 템플릿을 찾지 못했어요.'
|
||||
return
|
||||
}
|
||||
error.value = '아이템 추가에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGameItemOrder() {
|
||||
async function saveTemplateItemOrder() {
|
||||
resetMessages()
|
||||
if (!selectedGameId.value || !selectedGame.value?.items?.length) return
|
||||
if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) return
|
||||
|
||||
try {
|
||||
const data = await api.updateAdminGameItemDisplayOrder(selectedGameId.value, {
|
||||
itemIds: selectedGame.value.items.map((item) => item.id),
|
||||
const data = await api.updateAdminTemplateItemDisplayOrder(selectedTemplateId.value, {
|
||||
itemIds: selectedTemplate.value.items.map((item) => item.id),
|
||||
})
|
||||
selectedGame.value = {
|
||||
...selectedGame.value,
|
||||
selectedTemplate.value = {
|
||||
...selectedTemplate.value,
|
||||
items: (data.items || []).map((item) => ({
|
||||
...item,
|
||||
draftLabel: item.label,
|
||||
@@ -360,13 +360,13 @@ export function useAdminGameManager({
|
||||
syncGameItemSortable,
|
||||
mergeRequestItemsIntoDrafts,
|
||||
removeUploadDraft,
|
||||
loadGame,
|
||||
createGame,
|
||||
loadTemplate,
|
||||
createTemplate,
|
||||
handleItemFiles,
|
||||
onFile,
|
||||
openItemFilePicker,
|
||||
clearItemFiles,
|
||||
uploadItem,
|
||||
saveGameItemOrder,
|
||||
saveTemplateItemOrder,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { editorPath } from '../lib/paths'
|
||||
|
||||
export function useAdminTemplateRequests({
|
||||
api,
|
||||
activeTemplateRequest,
|
||||
refreshTemplateRequests,
|
||||
setTab,
|
||||
openGameCreateModal,
|
||||
newGameId,
|
||||
newGameName,
|
||||
selectAdminGame,
|
||||
openTemplateCreateModal,
|
||||
newTemplateId,
|
||||
newTemplateName,
|
||||
selectAdminTemplate,
|
||||
mergeRequestItemsIntoDrafts,
|
||||
resetMessages,
|
||||
success,
|
||||
@@ -37,12 +39,12 @@ export function useAdminTemplateRequests({
|
||||
|
||||
function templateRequestSourceUrl(request) {
|
||||
if (!request?.sourceGameId || !request?.sourceTierListId) return ''
|
||||
return `/editor/${request.sourceGameId}/${request.sourceTierListId}?preview=1`
|
||||
return editorPath(request.sourceGameId, request.sourceTierListId, { preview: true })
|
||||
}
|
||||
|
||||
function templateRequestReviewHint(request) {
|
||||
if (request.type === 'create') return '게임 생성 후 필요한 아이템만 골라 추가하고, 끝나면 여기서 처리 완료하세요.'
|
||||
return '확인하기를 누르면 게임 관리 화면에 요청 아이템이 임시 추가되어 필요한 것만 선별 저장할 수 있어요.'
|
||||
if (request.type === 'create') return '템플릿 생성 후 필요한 아이템만 골라 추가하고, 끝나면 여기서 처리 완료하세요.'
|
||||
return '확인하기를 누르면 템플릿 관리 화면에 요청 아이템이 임시 추가되어 필요한 것만 선별 저장할 수 있어요.'
|
||||
}
|
||||
|
||||
async function startTemplateRequestReview(request) {
|
||||
@@ -65,19 +67,19 @@ export function useAdminTemplateRequests({
|
||||
if (request.type === 'create') {
|
||||
const linkedGameId = syncedRequest.targetGameId || ''
|
||||
if (linkedGameId) {
|
||||
await selectAdminGame(linkedGameId)
|
||||
await selectAdminTemplate(linkedGameId)
|
||||
} else {
|
||||
openGameCreateModal()
|
||||
newGameId.value = (syncedRequest.draftGameId || '').trim()
|
||||
newGameName.value = (syncedRequest.draftGameName || '').trim()
|
||||
openTemplateCreateModal()
|
||||
newTemplateId.value = (syncedRequest.draftGameId || '').trim()
|
||||
newTemplateName.value = (syncedRequest.draftGameName || '').trim()
|
||||
}
|
||||
mergeRequestItemsIntoDrafts(syncedRequest)
|
||||
} else {
|
||||
const nextGameId = syncedRequest.targetGameId || syncedRequest.sourceGameId || ''
|
||||
if (nextGameId) await selectAdminGame(nextGameId)
|
||||
if (nextGameId) await selectAdminTemplate(nextGameId)
|
||||
mergeRequestItemsIntoDrafts(syncedRequest)
|
||||
}
|
||||
success.value = '요청 아이템을 게임 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
|
||||
success.value = '요청 아이템을 템플릿 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
|
||||
} catch (e) {
|
||||
error.value = '요청 확인 단계로 이동하지 못했어요.'
|
||||
} finally {
|
||||
|
||||
@@ -30,23 +30,31 @@ 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 }),
|
||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) =>
|
||||
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)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
|
||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
|
||||
),
|
||||
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
|
||||
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
||||
listAdminTierLists: ({ q = '', topicId = '', gameId = '', page = 1, limit = 50 } = {}) =>
|
||||
request(
|
||||
`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId || gameId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`
|
||||
),
|
||||
getAdminTierListStats: ({ q = '', topicId = '', gameId = '' } = {}) =>
|
||||
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId || gameId)}`),
|
||||
updateAdminTierList: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
|
||||
deleteAdminTierList: (tierListId) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'DELETE' }),
|
||||
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
|
||||
getAdminImageAssetStats: ({ month = '', limit = 12 } = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
@@ -55,15 +63,16 @@ export const api = {
|
||||
return request(`/api/admin/image-assets/stats?${query.toString()}`)
|
||||
},
|
||||
resetAdminImageAssetStats: (payload) => request('/api/admin/image-assets/stats/reset', { method: 'POST', body: payload || {} }),
|
||||
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: {} }),
|
||||
@@ -102,10 +111,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' } = {}) =>
|
||||
@@ -137,4 +146,24 @@ export const api = {
|
||||
deleteAdminCustomItem: (itemId) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}`, { method: 'DELETE' }),
|
||||
deleteAdminUnusedCustomItems: ({ q = '' } = {}) =>
|
||||
request(`/api/admin/custom-items?q=${encodeURIComponent(q)}`, { method: 'DELETE' }),
|
||||
|
||||
listGames: () => request('/api/games'),
|
||||
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
|
||||
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }),
|
||||
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
||||
updateAdminGameItemDisplayOrder: (gameId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { method: 'PATCH', body: payload }),
|
||||
updateAdminGame: (gameId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminGameItem: (gameId, itemId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||
promoteAdminCustomItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||
createAdminGameTemplateFromTierList: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
|
||||
listPublicTierLists: (gameId) =>
|
||||
request(`/api/tierlists/public?topicId=${encodeURIComponent(gameId || '')}`),
|
||||
searchPublicTierLists: (gameId, q = '') =>
|
||||
request(`/api/tierlists/public?topicId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
|
||||
}
|
||||
|
||||
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,15 @@ export function createRouter() {
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: HomeView },
|
||||
{ path: '/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/:gameId/new', redirect: (to) => `/editor/${encodeURIComponent(String(to.params.gameId || ''))}/new` },
|
||||
{ path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView },
|
||||
{
|
||||
path: '/editor/:gameId/:tierListId',
|
||||
redirect: (to) => `/editor/${encodeURIComponent(String(to.params.gameId || ''))}/${encodeURIComponent(String(to.params.tierListId || ''))}`,
|
||||
},
|
||||
{ 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 },
|
||||
|
||||
@@ -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.gameId, tierList.id))
|
||||
}
|
||||
|
||||
onMounted(loadFavorites)
|
||||
@@ -58,11 +59,11 @@ onMounted(loadFavorites)
|
||||
<div class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Collection</div>
|
||||
<h2 class="pageHead__title">내 즐겨찾기</h2>
|
||||
<h2 class="pageHead__title">즐겨찾기</h2>
|
||||
<div class="pageHead__desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 수 있어요.</div>
|
||||
</div>
|
||||
<div class="pageHead__aside toolbar">
|
||||
<input v-model="query" class="input" placeholder="제목, 게임, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
|
||||
<input v-model="query" class="input" placeholder="제목, 주제, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
|
||||
<select v-model="sort" class="select" @change="loadFavorites">
|
||||
<option value="favorited">즐겨찾기한 순</option>
|
||||
<option value="updated">최신 업데이트순</option>
|
||||
@@ -77,7 +78,7 @@ onMounted(loadFavorites)
|
||||
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" draggable="false" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
@@ -87,7 +88,7 @@ onMounted(loadFavorites)
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
|
||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" draggable="false" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
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 gameId = computed(() => route.params.gameId)
|
||||
|
||||
const gameName = ref('')
|
||||
const topicId = computed(() => route.params.topicId || route.params.gameId)
|
||||
const topicName = ref('')
|
||||
const tierLists = ref([])
|
||||
const error = ref('')
|
||||
const query = ref('')
|
||||
const brokenThumbnailIds = ref({})
|
||||
const isTopicLoading = ref(false)
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
const topicTitle = computed(() => topicName.value || (isTopicLoading.value ? '주제 불러오는 중...' : ''))
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
@@ -48,68 +50,72 @@ function handleThumbnailError(tierListId) {
|
||||
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTierLists()
|
||||
})
|
||||
|
||||
async function loadTierLists() {
|
||||
isTopicLoading.value = true
|
||||
try {
|
||||
const [gameRes, listRes] = await Promise.all([
|
||||
api.getGame(gameId.value),
|
||||
api.searchPublicTierLists(gameId.value, query.value),
|
||||
api.getTopic(topicId.value),
|
||||
api.searchPublicTierListsByTopic(topicId.value, query.value),
|
||||
])
|
||||
gameName.value = gameRes.game?.name || gameId.value
|
||||
topicName.value = gameRes.game?.name || ''
|
||||
brokenThumbnailIds.value = {}
|
||||
tierLists.value = listRes.tierLists || []
|
||||
} catch (e) {
|
||||
error.value = '게임 정보를 불러오지 못했어요.'
|
||||
error.value = '주제 정보를 불러오지 못했어요.'
|
||||
} finally {
|
||||
isTopicLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function createNew() {
|
||||
const target = editorNewPath(topicId.value)
|
||||
if (!auth.user) {
|
||||
router.push(`/login?redirect=/editor/${gameId.value}/new`)
|
||||
router.push(loginPath(target))
|
||||
return
|
||||
}
|
||||
router.push(`/editor/${gameId.value}/new`)
|
||||
router.push(target)
|
||||
}
|
||||
|
||||
function openTierList(id) {
|
||||
router.push(`/editor/${gameId.value}/${id}`)
|
||||
router.push(editorPath(topicId.value, id))
|
||||
}
|
||||
|
||||
function submitSearch() {
|
||||
loadTierLists()
|
||||
}
|
||||
|
||||
watch(
|
||||
topicId,
|
||||
() => {
|
||||
topicName.value = ''
|
||||
error.value = ''
|
||||
loadTierLists()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="dashboardHero">
|
||||
<div class="dashboardHero__left">
|
||||
<div class="dashboardHero__eyebrow">Collection</div>
|
||||
<h2 class="dashboardHero__title">{{ gameName || gameId }}</h2>
|
||||
<p class="dashboardHero__desc">이 게임의 공개 티어표를 탐색하고, 바로 새 보드를 만들어 같은 흐름으로 이어갈 수 있어요.</p>
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Collection</div>
|
||||
<h2 class="pageHead__title">{{ topicTitle }}</h2>
|
||||
<div class="pageHead__desc">이 주제의 공개 티어표를 같은 카드 레이아웃으로 살펴보고 이어서 새 티어표를 만들 수 있어요.</div>
|
||||
</div>
|
||||
<div class="pageHead__aside toolbar">
|
||||
<input v-model="query" class="input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
|
||||
<button class="btn" @click="submitSearch">검색</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section class="panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
<div class="panel__title">공개 티어표</div>
|
||||
<div class="panel__sub">제목이나 작성자로 빠르게 좁혀볼 수 있어요.</div>
|
||||
</div>
|
||||
<div class="searchBar">
|
||||
<input v-model="query" class="searchBar__input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
|
||||
<button class="searchBar__button" @click="submitSearch">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
|
||||
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||
<article v-for="t in tierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(t.id)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" alt="" @error="handleThumbnailError(t.id)" />
|
||||
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" alt="" draggable="false" @error="handleThumbnailError(t.id)" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
@@ -121,7 +127,7 @@ function submitSearch() {
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" draggable="false" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
||||
</div>
|
||||
@@ -135,72 +141,17 @@ function submitSearch() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboardHero {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 18px;
|
||||
}
|
||||
.dashboardHero__left {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.dashboardHero__eyebrow {
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-soft);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.dashboardHero__title {
|
||||
margin: 4px 0 6px;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
.dashboardHero__desc {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
max-width: 720px;
|
||||
}
|
||||
.panel {
|
||||
/* border: 1px solid var(--theme-border); */
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.error {
|
||||
margin: 10px 0 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--theme-danger-border);
|
||||
background: var(--theme-danger-bg);
|
||||
}
|
||||
.panel__title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
}
|
||||
.panel__sub {
|
||||
margin-top: 6px;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.panel__head {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.searchBar {
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.searchBar__input {
|
||||
.input {
|
||||
min-width: 240px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
@@ -208,8 +159,8 @@ function submitSearch() {
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.searchBar__button {
|
||||
padding: 11px 14px;
|
||||
.btn {
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft-2);
|
||||
@@ -217,6 +168,13 @@ function submitSearch() {
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.error {
|
||||
margin: 10px 0 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--theme-danger-border);
|
||||
background: var(--theme-danger-bg);
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.75;
|
||||
}
|
||||
@@ -405,7 +363,11 @@ function submitSearch() {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.searchBar__input {
|
||||
.toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -5,18 +5,19 @@ 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()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const items = ref([])
|
||||
const templateRecords = ref([])
|
||||
const error = ref('')
|
||||
const loadingFavoriteId = ref('')
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||
const games = computed(() => {
|
||||
const filtered = items.value
|
||||
const templates = computed(() => {
|
||||
const filtered = templateRecords.value
|
||||
.filter((item) => item.id !== 'freeform')
|
||||
.filter((item) => {
|
||||
if (!query.value) return true
|
||||
@@ -33,34 +34,34 @@ const games = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
async function loadGames() {
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const data = await api.listGames()
|
||||
items.value = data.games || []
|
||||
const data = await api.listTopics()
|
||||
templateRecords.value = data.games || []
|
||||
} catch (e) {
|
||||
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadGames)
|
||||
watch(() => auth.user?.id, loadGames)
|
||||
onMounted(loadTemplates)
|
||||
watch(() => auth.user?.id, loadTemplates)
|
||||
|
||||
function goGame(gameId) {
|
||||
router.push(`/games/${gameId}`)
|
||||
function openTopic(templateId) {
|
||||
router.push(topicPath(templateId))
|
||||
}
|
||||
|
||||
async function toggleFavorite(game, event) {
|
||||
async function toggleFavorite(template, event) {
|
||||
event?.stopPropagation()
|
||||
if (!auth.user) {
|
||||
router.push(`/login?redirect=${encodeURIComponent(route.fullPath || '/')}`)
|
||||
router.push(loginPath(route.fullPath || '/'))
|
||||
return
|
||||
}
|
||||
if (!game?.id || loadingFavoriteId.value === game.id) return
|
||||
if (!template?.id || loadingFavoriteId.value === template.id) return
|
||||
|
||||
try {
|
||||
loadingFavoriteId.value = game.id
|
||||
const res = game.isFavorited ? await api.unfavoriteGame(game.id) : await api.favoriteGame(game.id)
|
||||
items.value = items.value.map((entry) => (entry.id === game.id ? { ...entry, ...res.game } : entry))
|
||||
loadingFavoriteId.value = template.id
|
||||
const res = template.isFavorited ? await api.unfavoriteTopic(template.id) : await api.favoriteTopic(template.id)
|
||||
templateRecords.value = templateRecords.value.map((entry) => (entry.id === template.id ? { ...entry, ...res.game } : entry))
|
||||
} catch (e) {
|
||||
error.value = '즐겨찾기 변경에 실패했어요.'
|
||||
} finally {
|
||||
@@ -68,46 +69,46 @@ async function toggleFavorite(game, event) {
|
||||
}
|
||||
}
|
||||
|
||||
function thumbUrl(g) {
|
||||
return g.thumbnailSrc ? toApiUrl(g.thumbnailSrc) : ''
|
||||
function templateThumbUrl(template) {
|
||||
return template.thumbnailSrc ? toApiUrl(template.thumbnailSrc) : ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Workspace</div>
|
||||
<h1 class="pageHead__title">Game Library</h1>
|
||||
<p class="pageHead__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 게임 템플릿만 보고 있어요.</p>
|
||||
<div class="pageHead__eyebrow">Topic</div>
|
||||
<h1 class="pageHead__title">주제 선택</h1>
|
||||
<p class="pageHead__desc">자주 쓰는 주제 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 주제 템플릿만 보고 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<TransitionGroup v-if="games.length" name="libraryCard" tag="section" class="libraryGrid">
|
||||
<article v-for="g in games" :key="g.id" class="libraryCard">
|
||||
<TransitionGroup v-if="templates.length" name="libraryCard" tag="section" class="libraryGrid">
|
||||
<article v-for="template in templates" :key="template.id" class="libraryCard">
|
||||
<button
|
||||
class="libraryCard__favorite"
|
||||
type="button"
|
||||
:class="{ 'libraryCard__favorite--active': g.isFavorited }"
|
||||
:disabled="loadingFavoriteId === g.id"
|
||||
@click.stop="toggleFavorite(g, $event)"
|
||||
:class="{ 'libraryCard__favorite--active': template.isFavorited }"
|
||||
:disabled="loadingFavoriteId === template.id"
|
||||
@click.stop="toggleFavorite(template, $event)"
|
||||
>
|
||||
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
|
||||
</button>
|
||||
<button class="libraryCard__main" type="button" @click="goGame(g.id)">
|
||||
<button class="libraryCard__main" type="button" @click="openTopic(template.id)">
|
||||
<div class="libraryCard__thumbWrap">
|
||||
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
|
||||
<img v-if="templateThumbUrl(template)" class="libraryCard__thumb" :src="templateThumbUrl(template)" :alt="template.name" draggable="false" />
|
||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="libraryCard__body">
|
||||
<div class="libraryCard__title">{{ g.name }}</div>
|
||||
<div class="libraryCard__meta">{{ g.id }}</div>
|
||||
<div class="libraryCard__title">{{ template.name }}</div>
|
||||
<div class="libraryCard__meta">{{ template.id }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</TransitionGroup>
|
||||
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 게임 템플릿이 없어요.' : '표시할 게임 템플릿이 없어요.' }}</div>
|
||||
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 주제 템플릿이 없어요.' : '표시할 주제 템플릿이 없어요.' }}</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -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 = '로그인/회원가입에 실패했어요.'
|
||||
}
|
||||
@@ -87,7 +88,8 @@ async function submit() {
|
||||
</section>
|
||||
|
||||
<section v-else class="authScreen">
|
||||
<div class="authTabs" role="tablist" aria-label="로그인 또는 회원가입">
|
||||
<div class="authTabs" :class="{ 'authTabs--signup': mode === 'signup' }" role="tablist" aria-label="로그인 또는 회원가입">
|
||||
<span class="authTabs__indicator" aria-hidden="true"></span>
|
||||
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
|
||||
로그인
|
||||
</button>
|
||||
@@ -132,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>
|
||||
@@ -159,16 +161,37 @@ async function submit() {
|
||||
}
|
||||
|
||||
.authTabs {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
width: fit-content;
|
||||
padding: 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-pill-bg);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.authTabs__indicator {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: calc(50% - 6px);
|
||||
height: calc(100% - 12px);
|
||||
border-radius: 999px;
|
||||
background: rgba(76, 133, 245, 0.22);
|
||||
box-shadow: inset 0 0 0 1px rgba(120, 169, 255, 0.1);
|
||||
transform: translateX(0);
|
||||
transition: transform 220ms ease, background-color 220ms ease, box-shadow 220ms ease;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.authTabs--signup .authTabs__indicator {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.authTabs__button {
|
||||
position: relative;
|
||||
min-width: 112px;
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
@@ -177,10 +200,11 @@ async function submit() {
|
||||
color: var(--theme-text-muted);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: color 180ms ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.authTabs__button--active {
|
||||
background: rgba(76, 133, 245, 0.22);
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,22 +55,20 @@ 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.gameId, t.id))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Library</div>
|
||||
<h2 class="pageHead__title">내 티어표</h2>
|
||||
<div class="pageHead__eyebrow">Tier Lists</div>
|
||||
<h2 class="pageHead__title">나의 티어표</h2>
|
||||
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -85,6 +84,7 @@ function openList(t) {
|
||||
class="boardCard__thumb"
|
||||
:src="tierListThumbnailUrl(t)"
|
||||
alt=""
|
||||
draggable="false"
|
||||
@error="handleThumbnailError(t.id)"
|
||||
/>
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
@@ -96,7 +96,7 @@ function openList(t) {
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" draggable="false" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -121,7 +122,7 @@ async function logout() {
|
||||
<header class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Account</div>
|
||||
<h2 class="pageHead__title">Settings</h2>
|
||||
<h2 class="pageHead__title">설정</h2>
|
||||
<div class="pageHead__desc">계정 정보를 간결하게 정리하고, 프로필 이미지를 클릭해서 바로 변경할 수 있어요.</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -134,7 +135,7 @@ async function logout() {
|
||||
<div class="settingsIdentity">
|
||||
<div class="avatarButtonWrap">
|
||||
<button class="avatarButton" type="button" @click="openAvatarPicker">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" />
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" draggable="false" />
|
||||
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
|
||||
<div class="avatarButton__overlay">
|
||||
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
|
||||
|
||||
@@ -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.gameId, tierList.id))
|
||||
}
|
||||
|
||||
async function loadResults() {
|
||||
@@ -65,13 +66,13 @@ watch(
|
||||
|
||||
<template>
|
||||
<section class="wrap">
|
||||
<div class="head">
|
||||
<div>
|
||||
<div class="head__eyebrow">Search</div>
|
||||
<h2 class="title">전체 티어표 검색</h2>
|
||||
<div class="desc">공개된 모든 티어표를 제목과 작성자 기준으로 찾아볼 수 있어요.</div>
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Search</div>
|
||||
<h2 class="pageHead__title">전체 티어표 검색</h2>
|
||||
<div class="pageHead__desc">공개된 티어표를 제목과 작성자 기준으로 다시 찾아볼 수 있어요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-else-if="loading" class="empty">검색 중이에요.</div>
|
||||
@@ -80,7 +81,7 @@ watch(
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" draggable="false" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
@@ -92,7 +93,7 @@ watch(
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
|
||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" draggable="false" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
||||
</div>
|
||||
@@ -110,30 +111,6 @@ watch(
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 8px;
|
||||
}
|
||||
.head__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.title {
|
||||
margin: 4px 0 0;
|
||||
font-size: 32px;
|
||||
color: var(--theme-text-strong);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.desc {
|
||||
margin-top: 6px;
|
||||
color: var(--theme-text-muted);
|
||||
}
|
||||
.error {
|
||||
margin: 0 0 8px;
|
||||
padding: 10px 12px;
|
||||
|
||||
@@ -7,7 +7,9 @@ import SvgIcon from '../components/SvgIcon.vue'
|
||||
import addColumnRightIcon from '../assets/icons/add_column_right.svg'
|
||||
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'
|
||||
@@ -18,10 +20,10 @@ const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const globalRightRailOpen = inject('rightRailOpen', ref(true))
|
||||
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
|
||||
const gameId = computed(() => route.params.gameId)
|
||||
const templateId = computed(() => route.params.topicId || route.params.gameId)
|
||||
const tierListId = computed(() => route.params.tierListId)
|
||||
const previewMode = computed(() => route.query.preview === '1')
|
||||
const gameName = ref('')
|
||||
const templateName = ref('')
|
||||
|
||||
const columns = ref([{ id: 'col-1', name: '' }])
|
||||
const groups = ref([
|
||||
@@ -122,14 +124,20 @@ const copiedFromLabel = computed(() => {
|
||||
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
|
||||
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
||||
const canRequestTemplateCreate = computed(
|
||||
() => canEdit.value && hasSavedTierList.value && gameId.value === 'freeform' && customItems.value.length > 0
|
||||
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
|
||||
)
|
||||
const canRequestTemplateUpdate = computed(
|
||||
() => canEdit.value && hasSavedTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
|
||||
() => canEdit.value && hasSavedTierList.value && templateId.value !== 'freeform' && customItems.value.length > 0
|
||||
)
|
||||
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
|
||||
const templateRequestTargetLabel = computed(() => (templateId.value === 'freeform' ? '새로운 템플릿' : (templateName.value || templateId.value || '선택한 주제')))
|
||||
const shareTierListUrl = computed(() => {
|
||||
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
||||
if (!savedTierListId) return ''
|
||||
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) => {
|
||||
if (!message) return
|
||||
@@ -665,12 +673,13 @@ function buildPayload(existingId) {
|
||||
const finalTitle = effectiveTitle.value
|
||||
return {
|
||||
id: existingId || undefined,
|
||||
gameId: gameId.value,
|
||||
gameId: templateId.value,
|
||||
title: finalTitle,
|
||||
thumbnailSrc: thumbnailSrc.value || '',
|
||||
description: (description.value || '').trim(),
|
||||
isPublic: !!isPublic.value,
|
||||
showCharacterNames: !!showCharacterNames.value,
|
||||
iconSize: Number(iconSize.value || 80),
|
||||
sourceTierListId: sourceTierListId.value || '',
|
||||
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
|
||||
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
|
||||
@@ -689,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/${gameId.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
|
||||
@@ -712,6 +721,32 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyShareUrl() {
|
||||
if (!shareTierListUrl.value) {
|
||||
toast.error('먼저 티어표를 저장한 뒤 공유할 수 있어요.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(shareTierListUrl.value)
|
||||
} else {
|
||||
const helper = document.createElement('textarea')
|
||||
helper.value = shareTierListUrl.value
|
||||
helper.setAttribute('readonly', '')
|
||||
helper.style.position = 'absolute'
|
||||
helper.style.left = '-9999px'
|
||||
document.body.appendChild(helper)
|
||||
helper.select()
|
||||
document.execCommand('copy')
|
||||
helper.remove()
|
||||
}
|
||||
toast.success('공유 링크를 클립보드에 복사했어요.')
|
||||
} catch (e) {
|
||||
toast.error('공유 링크를 복사하지 못했어요.')
|
||||
}
|
||||
}
|
||||
|
||||
function closeSaveModal() {
|
||||
isSaveModalOpen.value = false
|
||||
}
|
||||
@@ -759,7 +794,7 @@ async function confirmDeleteTierList() {
|
||||
await api.deleteTierList(currentTierListId)
|
||||
closeDeleteModal()
|
||||
toast.success('티어표를 삭제했어요.')
|
||||
router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`)
|
||||
router.push(templateId.value === 'freeform' ? mePath() : topicPath(templateId.value))
|
||||
} catch (e) {
|
||||
error.value = '티어표 삭제에 실패했어요.'
|
||||
} finally {
|
||||
@@ -774,7 +809,7 @@ async function duplicateCurrentTierList() {
|
||||
const duplicatedId = data.tierList?.id
|
||||
if (!duplicatedId) throw new Error('duplicate_failed')
|
||||
toast.success('티어표를 복사해 내 작업으로 가져왔어요.')
|
||||
router.push(`/editor/${gameId.value}/${duplicatedId}`)
|
||||
router.push(editorPath(templateId.value, duplicatedId))
|
||||
} catch (e) {
|
||||
error.value = '티어표 복사에 실패했어요.'
|
||||
}
|
||||
@@ -807,7 +842,7 @@ async function requestTemplate(type) {
|
||||
await api.requestTierListTemplate({
|
||||
type,
|
||||
sourceTierListId: sourceId,
|
||||
gameId: gameId.value,
|
||||
gameId: templateId.value,
|
||||
requestTitle: templateRequestDraftTitle.value.trim(),
|
||||
requestDescription: templateRequestDraftDescription.value.trim(),
|
||||
thumbnailSrc: saved.tierList?.thumbnailSrc || thumbnailSrc.value || '',
|
||||
@@ -858,13 +893,13 @@ onMounted(() => {
|
||||
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
|
||||
|
||||
if (isNewTierList.value && !auth.user) {
|
||||
router.replace(`/login?redirect=/editor/${gameId.value}/new`)
|
||||
router.replace(loginPath(editorNewPath(templateId.value)))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const gameRes = await api.getGame(gameId.value)
|
||||
gameName.value = gameRes.game?.name || gameId.value
|
||||
const gameRes = await api.getTopic(templateId.value)
|
||||
templateName.value = gameRes.game?.name || templateId.value
|
||||
const base = (gameRes.items || []).map((img) => ({
|
||||
id: img.id,
|
||||
src: img.src,
|
||||
@@ -876,7 +911,7 @@ onMounted(() => {
|
||||
itemsById.value = map
|
||||
pool.value = base.map((it) => it.id)
|
||||
} catch (e) {
|
||||
error.value = '게임 기본 이미지를 불러오지 못했어요.'
|
||||
error.value = '기본 템플릿 이미지를 불러오지 못했어요.'
|
||||
}
|
||||
|
||||
if (tierListId.value && tierListId.value !== 'new') {
|
||||
@@ -890,6 +925,7 @@ onMounted(() => {
|
||||
description.value = t.description || ''
|
||||
isPublic.value = !!t.isPublic
|
||||
showCharacterNames.value = !!t.showCharacterNames
|
||||
iconSize.value = Number(t.iconSize || 80)
|
||||
authorName.value = t.authorName || ''
|
||||
authorAccountName.value = t.authorAccountName || ''
|
||||
updatedAt.value = Number(t.updatedAt || 0)
|
||||
@@ -925,7 +961,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="previewMode" class="previewOnly">
|
||||
<section v-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
|
||||
<div class="previewOnly__sheet">
|
||||
<div class="previewOnly__title">{{ effectiveTitle }}</div>
|
||||
<div v-if="description" class="previewOnly__description">{{ description }}</div>
|
||||
@@ -941,6 +977,7 @@ onUnmounted(() => {
|
||||
<div class="previewOnly__dropGrid" :style="{ '--column-count': columns.length }">
|
||||
<div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__dropColumn">
|
||||
<div class="previewOnly__drop">
|
||||
<div v-if="columns.length > 1" class="previewOnly__columnBadge">{{ column.name || '열 ' + (columnIndex + 1) }}</div>
|
||||
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="previewOnly__cell">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||||
@@ -958,6 +995,10 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="previewOnly__footer">
|
||||
<span>{{ effectiveAuthorName }}</span>
|
||||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -966,7 +1007,7 @@ onUnmounted(() => {
|
||||
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="saveModalTitle">
|
||||
<div id="saveModalTitle" class="modalCard__title">저장 완료</div>
|
||||
<div class="modalCard__desc">티어표가 저장되었어요. 이어서 더 수정한 뒤 다시 저장할 수도 있어요.</div>
|
||||
<div class="modalCard__desc">티어표가 저장되었어요.<br />이어서 더 수정한 뒤 다시 저장할 수도 있어요.</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--save" @click="closeSaveModal">확인</button>
|
||||
</div>
|
||||
@@ -981,7 +1022,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="requestChecklist__hint">
|
||||
제목과 설명을 함께 적어두면 관리자가 어떤 신규 템플릿인지 훨씬 빠르게 파악할 수 있어요.
|
||||
예시: 제목 `템플릿 등록 요청`, 설명 `여름 이벤트 한정 캐릭터 중심으로 새 게임 템플릿이 필요합니다.`
|
||||
예시: 제목 `템플릿 등록 요청`, 설명 `여름 이벤트 한정 캐릭터 중심으로 새 주제 템플릿이 필요합니다.`
|
||||
</div>
|
||||
<div class="templateRequestDraft">
|
||||
<label class="templateRequestDraft__field">
|
||||
@@ -1039,7 +1080,7 @@ onUnmounted(() => {
|
||||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteTierListTitle">
|
||||
<div id="deleteTierListTitle" class="modalCard__title">티어표 삭제</div>
|
||||
<div class="modalCard__desc">
|
||||
"{{ title || gameName || '이 티어표' }}"를 삭제할까요? 삭제 후에는 복구할 수 없어요.
|
||||
"{{ title || templateName || '이 티어표' }}"를 삭제할까요? 삭제 후에는 복구할 수 없어요.
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeDeleteModal">취소</button>
|
||||
@@ -1080,7 +1121,7 @@ onUnmounted(() => {
|
||||
<div class="editorMain">
|
||||
<section class="head">
|
||||
<div class="editorMain__headCopy">
|
||||
<div class="editorMain__title">{{ gameName || gameId }}</div>
|
||||
<div class="editorMain__title">{{ templateName || templateId }}</div>
|
||||
<div class="editorMain__subtitle">
|
||||
<template v-if="canEdit">
|
||||
행/열 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 수 있어요.
|
||||
@@ -1091,7 +1132,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div v-if="sourceTierListId" class="editorMain__sourceNote">
|
||||
<span>복사본</span>
|
||||
<button class="editorMain__sourceLink" type="button" @click="router.push(`/editor/${gameId}/${sourceTierListId}`)">{{ copiedFromLabel }}</button>
|
||||
<button class="editorMain__sourceLink" type="button" @click="router.push(editorPath(templateId, sourceTierListId))">{{ copiedFromLabel }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1165,13 +1206,14 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="row__content" :style="{ '--column-count': columns.length }">
|
||||
<div v-for="(column, columnIndex) in columns" :key="column.id" class="row__column">
|
||||
<div
|
||||
<div
|
||||
class="row__drop"
|
||||
:data-list-type="'group'"
|
||||
:data-group-id="g.id"
|
||||
:data-column-index="columnIndex"
|
||||
:ref="(el) => setGroupDropEl(g.id, columnIndex, el)"
|
||||
>
|
||||
<div v-if="columns.length > 1" class="row__columnBadge">{{ column.name || '열 ' + (columnIndex + 1) }}</div>
|
||||
<div v-if="!isExporting" class="row__empty" v-show="getGroupCellIds(g, columnIndex).length === 0">여기로 드래그해서 배치</div>
|
||||
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="cell" :data-item-id="id">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
@@ -1324,6 +1366,10 @@ onUnmounted(() => {
|
||||
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
|
||||
</div>
|
||||
<div class="editorSidebar__utilityLinks">
|
||||
<button v-if="hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--share" @click="copyShareUrl">
|
||||
<SvgIcon :src="shareIcon" :size="16" />
|
||||
<span>공유하기</span>
|
||||
</button>
|
||||
<button v-if="canEdit && hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
|
||||
<button v-if="canDuplicate" class="editorSidebar__utilityLink" @click="duplicateCurrentTierList">복사해서 내 티어표로 가져오기</button>
|
||||
<button
|
||||
@@ -1466,6 +1512,7 @@ onUnmounted(() => {
|
||||
border: 1px solid var(--theme-border-strong);
|
||||
}
|
||||
.previewOnly__drop {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
background: var(--theme-pill-bg);
|
||||
border: 1px solid var(--theme-border);
|
||||
@@ -1476,6 +1523,10 @@ onUnmounted(() => {
|
||||
gap: 8px;
|
||||
align-content: flex-start;
|
||||
}
|
||||
.previewOnly__columnBadge,
|
||||
.row__columnBadge {
|
||||
display: none;
|
||||
}
|
||||
.previewOnly__cell {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
@@ -1502,6 +1553,15 @@ onUnmounted(() => {
|
||||
opacity: 0.52;
|
||||
filter: grayscale(0.22) brightness(0.78);
|
||||
}
|
||||
.previewOnly__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 8px;
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
.toggleSwitch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2282,6 +2342,9 @@ onUnmounted(() => {
|
||||
background: transparent;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -2293,6 +2356,10 @@ onUnmounted(() => {
|
||||
.editorSidebar__utilityLink--danger {
|
||||
color: rgba(248, 113, 113, 0.96);
|
||||
}
|
||||
|
||||
.editorSidebar__utilityLink--share {
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.sidebar__title {
|
||||
font-weight: 900;
|
||||
margin-bottom: 8px;
|
||||
@@ -2434,8 +2501,45 @@ onUnmounted(() => {
|
||||
border-radius: 14px;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.previewOnly__row {
|
||||
grid-template-columns: 140px 1fr;
|
||||
.previewOnly__row,
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.previewOnly__columns,
|
||||
.boardColumnsHeader {
|
||||
display: none;
|
||||
}
|
||||
.previewOnly__columnsSpacer,
|
||||
.boardColumnsHeader__spacer {
|
||||
display: none;
|
||||
}
|
||||
.previewOnly__dropGrid,
|
||||
.boardColumnsHeader__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.previewOnly__drop,
|
||||
.row__drop {
|
||||
padding-top: 40px;
|
||||
}
|
||||
.previewOnly__columnBadge,
|
||||
.row__columnBadge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: calc(100% - 20px);
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.heroCard {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -2446,9 +2550,6 @@ onUnmounted(() => {
|
||||
.row__content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.row {
|
||||
grid-template-columns: 150px 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
position: static;
|
||||
}
|
||||
@@ -2477,20 +2578,6 @@ onUnmounted(() => {
|
||||
.previewOnly {
|
||||
padding: 14px;
|
||||
}
|
||||
.previewOnly__columns,
|
||||
.previewOnly__row,
|
||||
.boardColumnsHeader,
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.previewOnly__columnsSpacer,
|
||||
.boardColumnsHeader__spacer {
|
||||
display: none;
|
||||
}
|
||||
.previewOnly__dropGrid,
|
||||
.boardColumnsHeader__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.pool {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||