Compare commits

...

26 Commits

Author SHA1 Message Date
4ed7f275ba 릴리스: v1.3.84 모바일 열 배지 위치 및 헤더 정리 2026-04-02 16:45:43 +09:00
88ce413c31 릴리스: v1.3.83 모바일 열 배지 추가 2026-04-02 16:42:17 +09:00
7f7475fb20 릴리스: v1.3.82 프리뷰 메타와 요청 카드 동작 정리 2026-04-02 16:38:57 +09:00
8a44b51cce 릴리스: v1.3.81 티어표 공유 링크 복사 추가 2026-04-02 16:34:09 +09:00
9d63ed2e76 릴리스: v1.3.80 공통 오른쪽 레일 카피라이트 적용 2026-04-02 16:29:40 +09:00
99eb79f2c3 릴리스: v1.3.79 관리자 카피라이트 위치 고정 2026-04-02 16:26:04 +09:00
6b8abea203 릴리스: v1.3.78 축소 액션 아이콘과 카피라이트 정리 2026-04-02 16:22:12 +09:00
d692798358 릴리스: v1.3.77 축소 레일 티어표 만들기 버튼 추가 2026-04-02 16:13:50 +09:00
49d4946735 릴리스: v1.3.76 왼쪽 레일 검색/축소 정리 2026-04-02 16:10:36 +09:00
bd53cf96dc 릴리스: v1.3.75 관리자 모달/사이드바 정리 2026-04-02 16:04:07 +09:00
66c3b1e7b7 릴리스: v1.3.74 관리자 선택 제약과 카피라이트 추가 2026-04-02 15:55:10 +09:00
494f04d9a7 릴리스: v1.3.73 관리자 게임 선택 UX 정리 2026-04-02 15:50:20 +09:00
5aae278fd3 릴리스: v1.3.72 관리자 게임 라우트 초기화 오류 수정 2026-04-02 15:37:18 +09:00
14dfe0ad75 릴리스: v1.3.71 관리자 게임 선택 모달 통합 2026-04-02 15:34:07 +09:00
a7cfb97131 릴리스: v1.3.70 관리자 티어표 필터와 관리 액션 보강 2026-04-02 15:28:52 +09:00
badf250967 릴리스: v1.3.69 관리자 티어표 집계와 아이템 UI 정리 2026-04-02 15:21:03 +09:00
a16b1e1025 릴리스: v1.3.68 관리자 아이템 모달 레이아웃 안정화 2026-04-02 15:12:36 +09:00
c1dfea41a5 릴리스: v1.3.67 아이템 참조 요약과 영향 표시 2026-04-02 15:04:20 +09:00
188576f8ac 릴리스: v1.3.66 누락 썸네일 정리 조건 보정 2026-04-02 14:49:17 +09:00
5db1e57f13 릴리스: v1.3.65 누락 이미지 참조 정리 도구 추가 2026-04-02 14:37:59 +09:00
2918a0423c 릴리스: v1.3.64 관리자 필터와 게임 설정 패널 정리 2026-04-02 14:23:02 +09:00
b542b963d2 릴리스: v1.3.63 이미지 최적화 작업 라벨 정리 2026-04-02 14:03:41 +09:00
b98a3d5a6d 릴리스: v1.3.62 에디터 저장 순서와 삭제 조건 정리 2026-04-02 13:57:23 +09:00
d3c5eeae6a 릴리스: v1.3.61 드롭존 아이콘과 업로드 UI 통일 2026-04-02 13:48:14 +09:00
e3559f4a84 릴리스: v1.3.60 관리자 공개 토글과 인증 새로고침 안정화 2026-04-02 13:41:14 +09:00
aa114a170e 릴리스: v1.3.59 관리자 템플릿 요청 중복 방지 및 신규 템플릿 연결 흐름 정리 2026-04-02 12:46:59 +09:00
20 changed files with 2129 additions and 378 deletions

View File

@@ -73,6 +73,7 @@ function mapGameRow(row) {
id: row.id,
name: row.name,
thumbnailSrc: row.thumbnail_src || '',
isPublic: row.is_public == null ? true : !!row.is_public,
displayRank: row.display_rank == null ? null : Number(row.display_rank),
createdAt: Number(row.created_at),
}
@@ -256,11 +257,18 @@ async function ensureSchema() {
id VARCHAR(120) PRIMARY KEY,
name VARCHAR(120) NOT NULL,
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
is_public TINYINT(1) NOT NULL DEFAULT 1,
display_rank INT NULL DEFAULT NULL,
created_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const gameIsPublicColumns = await query("SHOW COLUMNS FROM games LIKE 'is_public'")
if (!gameIsPublicColumns.length) {
await query('ALTER TABLE games ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src')
await query('UPDATE games SET is_public = 1 WHERE is_public IS NULL')
}
const displayRankColumns = await query("SHOW COLUMNS FROM games LIKE 'display_rank'")
if (!displayRankColumns.length) {
await query('ALTER TABLE games ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
@@ -643,12 +651,14 @@ async function adminDeleteUser(id) {
await query('DELETE FROM users WHERE id = ?', [id])
}
async function listGames(currentUserId = '') {
async function listGames(currentUserId = '', options = {}) {
const includePrivate = !!options.includePrivate
const rows = await query(
`
SELECT id, name, thumbnail_src, display_rank, created_at
SELECT id, name, thumbnail_src, is_public, display_rank, created_at
FROM games
WHERE id <> ?
${includePrivate ? '' : 'AND is_public = 1'}
ORDER BY
CASE WHEN display_rank IS NULL THEN 1 ELSE 0 END ASC,
display_rank ASC,
@@ -669,7 +679,7 @@ async function listGames(currentUserId = '') {
}
async function findGameById(id) {
const rows = await query('SELECT id, name, thumbnail_src, display_rank, created_at FROM games WHERE id = ? LIMIT 1', [id])
const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM games WHERE id = ? LIMIT 1', [id])
return mapGameRow(rows[0])
}
@@ -702,11 +712,12 @@ async function getGameDetail(gameId) {
return { game, items }
}
async function createGame({ id, name }) {
await query('INSERT INTO games (id, name, thumbnail_src, display_rank, created_at) VALUES (?, ?, ?, ?, ?)', [
async function createGame({ id, name, isPublic = true }) {
await query('INSERT INTO games (id, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
id,
name,
'',
isPublic ? 1 : 0,
null,
now(),
])
@@ -718,6 +729,11 @@ async function updateGameThumbnail(gameId, thumbnailSrc) {
return findGameById(gameId)
}
async function updateGameVisibility(gameId, isPublic) {
await query('UPDATE games SET is_public = ? WHERE id = ?', [isPublic ? 1 : 0, gameId])
return findGameById(gameId)
}
async function findImageAssetByHash(contentHash) {
const rows = await query(
'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE content_hash = ? LIMIT 1',
@@ -1026,6 +1042,160 @@ async function getReferencedUploadFootprint() {
}
}
async function fileExistsForUploadSrc(src) {
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return true
const absolutePath = path.join(__dirname, '..', src.replace(/^\//, ''))
try {
await fs.stat(absolutePath)
return true
} catch (error) {
if (error?.code === 'ENOENT') return false
throw error
}
}
function stripItemIdsFromGroups(groups, missingItemIds) {
let changed = false
const nextGroups = (groups || []).map((group) => {
const nextItemIds = (group?.itemIds || []).filter((itemId) => !missingItemIds.has(itemId))
if (nextItemIds.length !== (group?.itemIds || []).length) changed = true
return {
...group,
itemIds: nextItemIds,
}
})
return { changed, groups: nextGroups }
}
function stripMissingItems(items, missingItemIds, missingSrcs) {
let changed = false
const nextItems = (items || []).filter((item) => {
const shouldRemove =
(item?.id && missingItemIds.has(item.id)) ||
(typeof item?.src === 'string' && missingSrcs.has(item.src))
if (shouldRemove) changed = true
return !shouldRemove
})
return { changed, items: nextItems }
}
async function cleanupMissingUploadReferences() {
const stats = {
clearedAvatars: 0,
clearedGameThumbnails: 0,
clearedTierListThumbnails: 0,
clearedTemplateRequestThumbnails: 0,
deletedGameItems: 0,
updatedTierLists: 0,
updatedTemplateRequests: 0,
deletedCustomItems: 0,
}
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
query("SELECT id, avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT id, thumbnail_src FROM games WHERE thumbnail_src <> ''"),
query("SELECT id, src FROM game_items WHERE src <> ''"),
query("SELECT id, src FROM custom_items WHERE src <> ''"),
query("SELECT id, thumbnail_src, groups_json, pool_json FROM tierlists"),
query("SELECT id, thumbnail_src_snapshot, groups_json, items_json, board_items_json FROM template_requests"),
])
for (const row of userRows) {
if (await fileExistsForUploadSrc(row.avatar_src)) continue
await query('UPDATE users SET avatar_src = ? WHERE id = ?', ['', row.id])
stats.clearedAvatars += 1
}
for (const row of gameRows) {
if (await fileExistsForUploadSrc(row.thumbnail_src)) continue
await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', ['', row.id])
stats.clearedGameThumbnails += 1
}
for (const row of gameItemRows) {
if (await fileExistsForUploadSrc(row.src)) continue
await deleteGameItem(row.id)
stats.deletedGameItems += 1
}
const missingCustomItemIds = new Set()
const missingCustomSrcs = new Set()
for (const row of customItemRows) {
if (await fileExistsForUploadSrc(row.src)) continue
missingCustomItemIds.add(row.id)
missingCustomSrcs.add(row.src)
}
for (const row of tierListRows) {
const groups = parseJson(row.groups_json, [])
const pool = parseJson(row.pool_json, [])
let changed = false
let nextThumbnail = row.thumbnail_src || ''
if (row.thumbnail_src && !(await fileExistsForUploadSrc(row.thumbnail_src))) {
nextThumbnail = ''
changed = true
stats.clearedTierListThumbnails += 1
}
const strippedPool = stripMissingItems(pool, missingCustomItemIds, missingCustomSrcs)
const strippedGroups = stripItemIdsFromGroups(groups, missingCustomItemIds)
if (strippedPool.changed || strippedGroups.changed) changed = true
if (changed) {
await query('UPDATE tierlists SET thumbnail_src = ?, groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ?', [
nextThumbnail,
serializeJson(strippedGroups.groups),
serializeJson(strippedPool.items),
now(),
row.id,
])
stats.updatedTierLists += 1
}
}
for (const row of templateRequestRows) {
const groups = parseJson(row.groups_json, [])
const items = parseJson(row.items_json, [])
const boardItems = parseJson(row.board_items_json, [])
let changed = false
let nextThumbnail = row.thumbnail_src_snapshot || ''
if (row.thumbnail_src_snapshot && !(await fileExistsForUploadSrc(row.thumbnail_src_snapshot))) {
nextThumbnail = ''
changed = true
stats.clearedTemplateRequestThumbnails += 1
}
const strippedItems = stripMissingItems(items, missingCustomItemIds, missingCustomSrcs)
const strippedBoardItems = stripMissingItems(boardItems, missingCustomItemIds, missingCustomSrcs)
const strippedGroups = stripItemIdsFromGroups(groups, missingCustomItemIds)
if (strippedItems.changed || strippedBoardItems.changed || strippedGroups.changed) changed = true
if (changed) {
await query(
'UPDATE template_requests SET thumbnail_src_snapshot = ?, groups_json = ?, items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?',
[
nextThumbnail,
serializeJson(strippedGroups.groups),
serializeJson(strippedItems.items),
serializeJson(strippedBoardItems.items),
now(),
row.id,
]
)
stats.updatedTemplateRequests += 1
}
}
if (missingCustomItemIds.size) {
await deleteCustomItems(Array.from(missingCustomItemIds))
stats.deletedCustomItems = missingCustomItemIds.size
}
return stats
}
async function getImageAssetStats({ month } = {}) {
const range = resolveMonthRange(month)
const jobWhere = []
@@ -1306,7 +1476,7 @@ async function getCustomItemUsageMeta() {
}
}
async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) {
async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMode = 'all' } = {}) {
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
const normalizedPage = Math.max(Number(page) || 1, 1)
const searchText = (queryText || '').trim()
@@ -1428,10 +1598,71 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
sourceGameName: row.game_name || row.game_id,
}))
const allItems = [...customItems, ...templateItems, ...assetLibraryItems]
const baseItems = [...customItems, ...templateItems, ...assetLibraryItems]
const groupedBySrc = new Map()
for (const item of baseItems) {
if (!item?.src) continue
if (!groupedBySrc.has(item.src)) groupedBySrc.set(item.src, [])
groupedBySrc.get(item.src).push(item)
}
const allItems = baseItems
.map((item) => {
const siblings = groupedBySrc.get(item.src) || [item]
const linkedGames = new Map()
let userReferenceCount = 0
let templateReferenceCount = 0
let assetReferenceCount = 0
siblings.forEach((entry) => {
if (entry.sourceType === 'user') userReferenceCount += 1
else if (entry.isAssetLibraryItem) assetReferenceCount += 1
else templateReferenceCount += 1
;(entry.linkedGames || []).forEach((game) => {
if (game?.id) linkedGames.set(game.id, game)
})
})
return {
...item,
sharedReferenceCount: siblings.length,
sharedUserReferenceCount: userReferenceCount,
sharedTemplateReferenceCount: templateReferenceCount,
sharedAssetReferenceCount: assetReferenceCount,
sharedLinkedGameCount: linkedGames.size,
sharedEntries: siblings
.slice()
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
.map((entry) => ({
id: entry.id,
label: entry.label,
sourceLabel: entry.sourceLabel,
sourceType: entry.sourceType,
ownerName: entry.ownerName,
createdAt: entry.createdAt,
sourceGameId: entry.sourceGameId || '',
sourceGameName: entry.sourceGameName || '',
usageCount: entry.usageCount || 0,
linkedGames: entry.linkedGames || [],
isAssetLibraryItem: !!entry.isAssetLibraryItem,
})),
}
})
.filter((item) => {
if (!orphanOnly) return true
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0
switch (filterMode) {
case 'user':
return item.sourceType === 'user'
case 'template':
return item.sourceType === 'template' && !item.isAssetLibraryItem
case 'asset':
return !!item.isAssetLibraryItem
case 'unused-user':
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0
case 'unused-admin':
return !!item.isAssetLibraryItem
default:
return true
}
})
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
@@ -1720,22 +1951,32 @@ function getAutoThumbnailSrc(groups = [], pool = []) {
return fallbackItem?.src || ''
}
async function listAdminTierLists({ queryText = '', page = 1, limit = 50, currentUserId = '' } = {}) {
async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit = 50, currentUserId = '' } = {}) {
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
const normalizedPage = Math.max(Number(page) || 1, 1)
const hasQuery = !!(queryText || '').trim()
const hasGameId = !!(gameId || '').trim()
const search = `%${(queryText || '').trim()}%`
const whereClause = hasQuery
? `
WHERE
t.title LIKE ?
OR g.name LIKE ?
OR g.id LIKE ?
OR u.email LIKE ?
OR u.nickname LIKE ?
`
: ''
const params = hasQuery ? [search, search, search, search, search] : []
const whereParts = []
const params = []
if (hasGameId) {
whereParts.push('t.game_id = ?')
params.push((gameId || '').trim())
}
if (hasQuery) {
whereParts.push(`(
t.title LIKE ?
OR g.name LIKE ?
OR g.id LIKE ?
OR u.email LIKE ?
OR u.nickname LIKE ?
)`)
params.push(search, search, search, search, search)
}
const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
const rows = await query(
`
@@ -1795,6 +2036,50 @@ async function listAdminTierLists({ queryText = '', page = 1, limit = 50, curren
}
}
async function summarizeAdminTierLists({ queryText = '', gameId = '' } = {}) {
const hasQuery = !!(queryText || '').trim()
const hasGameId = !!(gameId || '').trim()
const search = `%${(queryText || '').trim()}%`
const whereParts = []
const params = []
if (hasGameId) {
whereParts.push('t.game_id = ?')
params.push((gameId || '').trim())
}
if (hasQuery) {
whereParts.push(`(
t.title LIKE ?
OR g.name LIKE ?
OR g.id LIKE ?
OR u.email LIKE ?
OR u.nickname LIKE ?
)`)
params.push(search, search, search, search, search)
}
const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
const rows = await query(
`
SELECT t.is_public
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
INNER JOIN games g ON g.id = t.game_id
${whereClause}
`,
params
)
const total = rows.length
const publicCount = rows.filter((row) => Number(row.is_public) === 1).length
return {
total,
publicCount,
privateCount: Math.max(0, total - publicCount),
}
}
async function findTierListById(id, currentUserId = '') {
const rows = await query(
`
@@ -2004,10 +2289,27 @@ async function updateTemplateRequestStatus({ id, status }) {
return findTemplateRequestById(id)
}
async function updateTemplateRequestTargetGame({ id, targetGameId }) {
await query('UPDATE template_requests SET target_game_id = ?, updated_at = ? WHERE id = ?', [targetGameId || '', now(), id])
return findTemplateRequestById(id)
}
async function deleteTierList(id) {
await query('DELETE FROM tierlists WHERE id = ?', [id])
}
async function updateAdminTierListMeta({ id, title, description = '', isPublic }) {
await query(
`
UPDATE tierlists
SET title = ?, description = ?, is_public = ?, updated_at = ?
WHERE id = ?
`,
[title, description || '', isPublic ? 1 : 0, now(), id]
)
return findTierListById(id)
}
async function findCustomItemsByIds(ids) {
if (!ids.length) return []
const placeholders = ids.map(() => '?').join(', ')
@@ -2139,6 +2441,7 @@ module.exports = {
getGameDetail,
createGame,
updateGameThumbnail,
updateGameVisibility,
findImageAssetByHash,
findImageAssetBySrc,
findImageAssetById,
@@ -2154,6 +2457,7 @@ module.exports = {
replaceUploadSourceReferences,
clearImageOptimizationJobs,
getImageAssetStats,
cleanupMissingUploadReferences,
createGameItem,
updateGameItemLabel,
updateGameItemDisplayOrder,
@@ -2170,7 +2474,9 @@ module.exports = {
listFavoriteTierLists,
listUserTierLists,
listAdminTierLists,
summarizeAdminTierLists,
findTierListById,
updateAdminTierListMeta,
favoriteTierList,
unfavoriteTierList,
favoriteGame,
@@ -2184,4 +2490,5 @@ module.exports = {
findTemplateRequestById,
listAdminTemplateRequests,
updateTemplateRequestStatus,
updateTemplateRequestTargetGame,
}

View File

@@ -9,10 +9,12 @@ const {
findUserById,
findGameById,
findGameItemById,
listGameItems,
findImageAssetById,
createGame,
listGames,
updateGameThumbnail,
updateGameVisibility,
createGameItem,
updateGameItemLabel,
updateGameItemDisplayOrder,
@@ -20,6 +22,7 @@ const {
updateImageAssetLabel,
deleteGameItem,
deleteGame,
deleteTierList,
updateGameDisplayOrder,
listCustomItems,
findCustomItemById,
@@ -29,10 +32,13 @@ const {
listUsers,
findPrimaryAdminUser,
listAdminTierLists,
summarizeAdminTierLists,
findTierListById,
updateAdminTierListMeta,
listAdminTemplateRequests,
findTemplateRequestById,
updateTemplateRequestStatus,
updateTemplateRequestTargetGame,
adminUpdateUser,
adminUpdateUserPassword,
adminDeleteUser,
@@ -41,6 +47,7 @@ const {
getImageAssetStats,
listRecentImageOptimizationJobs,
clearImageOptimizationJobs,
cleanupMissingUploadReferences,
} = require('../db')
const { requireAdmin } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
@@ -65,6 +72,17 @@ function buildItemLabelFromFilename(file) {
return normalized || 'item'
}
function buildItemLabelFromSrc(src) {
const raw = typeof src === 'string' ? src : ''
const base = path.basename(raw.split('?')[0] || '', path.extname(raw.split('?')[0] || ''))
const normalized = base
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 60)
return normalized || 'item'
}
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
@@ -96,13 +114,14 @@ router.post('/games', requireAdmin, async (req, res) => {
const schema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(60),
isPublic: z.boolean().optional().default(false),
thumbnailSrc: z.string().max(255).optional().default(''),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findGameById(parsed.data.id)
if (exists) return res.status(409).json({ error: 'game_id_taken' })
const game = await createGame({ id: parsed.data.id, name: parsed.data.name })
const game = await createGame({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
if (parsed.data.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
await updateGameThumbnail(game.id, copiedThumb)
@@ -110,6 +129,20 @@ router.post('/games', requireAdmin, async (req, res) => {
res.json({ game: await findGameById(game.id) })
})
router.patch('/games/:gameId', requireAdmin, async (req, res) => {
const schema = z.object({
isPublic: z.boolean(),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
const updated = await updateGameVisibility(game.id, parsed.data.isPublic)
res.json({ game: updated })
})
router.patch('/games/display-order', requireAdmin, async (req, res) => {
const schema = z.object({
gameIds: z.array(z.string().min(1)).max(50),
@@ -117,7 +150,7 @@ router.patch('/games/display-order', requireAdmin, async (req, res) => {
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const games = await listGames()
const games = await listGames('', { includePrivate: true })
const validGameIds = new Set(games.map((game) => game.id))
const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId))
const updatedGames = await updateGameDisplayOrder(filteredIds)
@@ -248,11 +281,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' })
@@ -261,7 +290,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)
})
@@ -269,6 +298,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
router.get('/tierlists', requireAdmin, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
gameId: z.string().trim().max(120).optional().default(''),
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
})
@@ -277,6 +307,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
const result = await listAdminTierLists({
queryText: parsed.data.q,
gameId: parsed.data.gameId,
page: parsed.data.page,
limit: parsed.data.limit,
currentUserId: req.session?.userId || '',
@@ -284,6 +315,21 @@ 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(''),
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,
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 })
@@ -361,6 +407,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) => {
@@ -399,9 +450,25 @@ async function promoteLibraryItemToGameItem({ item, gameId }) {
}
async function copyUploadIntoGameAsset(src) {
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || ''
if (src.startsWith('/uploads/assets/')) return src
return src
if (typeof src !== 'string') return ''
const raw = src.trim()
if (!raw) return ''
if (raw.startsWith('/uploads/')) {
if (raw.startsWith('/uploads/assets/')) return raw
return raw
}
try {
const url = new URL(raw)
if (url.pathname.startsWith('/uploads/')) {
return url.pathname
}
} catch (error) {
return raw
}
return raw
}
function uniqueTierListPoolItems(tierList) {
@@ -435,10 +502,17 @@ async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
}
async function promoteSnapshotItemsToGame({ items, gameId }) {
const existingItems = await listGameItems(gameId)
const existingSrcs = new Set(
existingItems
.map((item) => (typeof item?.src === 'string' ? item.src.trim() : ''))
.filter(Boolean)
)
const createdItems = []
for (const item of items || []) {
const copiedSrc = await copyUploadIntoGameAsset(item.src)
if (!copiedSrc || existingSrcs.has(copiedSrc)) continue
createdItems.push(
await createGameItem({
id: nanoid(),
@@ -447,23 +521,40 @@ async function promoteSnapshotItemsToGame({ items, gameId }) {
label: item.label,
})
)
existingSrcs.add(copiedSrc)
}
return createdItems
}
function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}) {
function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}, itemSrcs = []) {
const requestedIds = new Set((itemIds || []).filter(Boolean))
const requestedSrcs = new Set((itemSrcs || []).filter((src) => typeof src === 'string' && src.trim()).map((src) => src.trim()))
const items = Array.isArray(templateRequest?.items) ? templateRequest.items : []
const filtered = requestedIds.size ? items.filter((item) => item?.id && requestedIds.has(item.id)) : items
return filtered.map((item) => ({
...item,
label: typeof itemLabels?.[item.id] === 'string' && itemLabels[item.id].trim() ? itemLabels[item.id].trim().slice(0, 60) : item.label,
}))
const filtered =
requestedIds.size || requestedSrcs.size
? items.filter((item) => (item?.id && requestedIds.has(item.id)) || (typeof item?.src === 'string' && requestedSrcs.has(item.src.trim())))
: items
return filtered
.filter((item) => typeof item?.src === 'string' && item.src.trim())
.map((item) => {
const draftLabel =
typeof itemLabels?.[item.id] === 'string' && itemLabels[item.id].trim()
? itemLabels[item.id].trim().slice(0, 60)
: typeof item?.label === 'string' && item.label.trim()
? item.label.trim().slice(0, 60)
: buildItemLabelFromSrc(item.src)
return {
...item,
src: item.src.trim(),
label: draftLabel,
}
})
}
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
await createGame({ id: gameId, name: gameName })
await createGame({ id: gameId, name: gameName, isPublic: false })
if (tierList.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc)
await updateGameThumbnail(gameId, copiedThumb)
@@ -486,7 +577,7 @@ async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
}
async function createGameTemplateFromRequest({ templateRequest, gameId, gameName }) {
await createGame({ id: gameId, name: gameName })
await createGame({ id: gameId, name: gameName, isPublic: false })
if (templateRequest.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc)
@@ -502,7 +593,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') {
@@ -606,6 +697,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' })
@@ -644,12 +763,19 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r
})
router.post('/template-requests/:requestId/review', requireAdmin, async (req, res) => {
const templateRequest = await findTemplateRequestById(req.params.requestId)
let templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
if (templateRequest.status === 'completed' || templateRequest.status === 'rejected' || templateRequest.status === 'approved') {
return res.status(409).json({ error: 'request_already_handled' })
}
if (templateRequest.type === 'create' && templateRequest.targetGameId && !templateRequest.targetGameName) {
templateRequest = await updateTemplateRequestTargetGame({
id: templateRequest.id,
targetGameId: '',
})
}
if (templateRequest.status === 'reviewing') {
return res.json({ request: templateRequest })
}
@@ -658,11 +784,36 @@ router.post('/template-requests/:requestId/review', requireAdmin, async (req, re
res.json({ request })
})
router.post('/template-requests/:requestId/link-game', requireAdmin, async (req, res) => {
const schema = z.object({
gameId: z.string().trim().min(1).max(120),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
if (templateRequest.type !== 'create') return res.status(409).json({ error: 'create_request_required' })
if (!['pending', 'reviewing'].includes(templateRequest.status)) {
return res.status(409).json({ error: 'request_already_handled' })
}
const game = await findGameById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' })
const request = await updateTemplateRequestTargetGame({
id: templateRequest.id,
targetGameId: game.id,
})
res.json({ request })
})
router.post('/template-requests/:requestId/promote-items', requireAdmin, async (req, res) => {
const schema = z.object({
gameId: z.string().trim().min(1).max(120),
itemIds: z.array(z.string().min(1)).optional().default([]),
itemLabels: z.record(z.string().min(1).max(60)).optional().default({}),
itemSrcs: z.array(z.string().min(1)).optional().default([]),
itemLabels: z.record(z.string(), z.string().min(1).max(60)).optional().default({}),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -676,10 +827,32 @@ router.post('/template-requests/:requestId/promote-items', requireAdmin, async (
const game = await findGameById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' })
const items = await promoteSnapshotItemsToGame({
items: pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels),
gameId: game.id,
})
const promotableItems = pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels, parsed.data.itemSrcs)
if (!promotableItems.length) {
return res.status(400).json({ error: 'no_items_selected' })
}
let items = []
try {
items = await promoteSnapshotItemsToGame({
items: promotableItems,
gameId: game.id,
})
} catch (error) {
console.error('[admin] template request promote-items failed', {
requestId: templateRequest.id,
gameId: game.id,
itemCount: promotableItems.length,
message: error?.message || 'unknown_error',
code: error?.code || '',
stack: error?.stack || '',
})
return res.status(500).json({
error: 'promote_items_failed',
detail: error?.message || 'unknown_error',
code: error?.code || '',
})
}
const request =
templateRequest.status === 'reviewing'

View File

@@ -5,7 +5,7 @@ const { requireAuth } = require('../middleware/auth')
const router = express.Router()
router.get('/', async (req, res) => {
const games = await listGames(req.session?.userId || '')
const games = await listGames(req.session?.userId || '', { includePrivate: !!req.session?.isAdmin })
res.json({ games })
})
@@ -30,6 +30,7 @@ router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
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 })
})

View File

@@ -1,5 +1,99 @@
# 의사결정 이력
## 2026-04-02 v1.3.83
- 모바일에서 열 헤더가 칸과 시각적으로 분리되는 문제는 전체 레이아웃을 다시 갈아엎기보다, 각 칸 안에 열 이름 배지를 같이 보여주는 편이 가장 적은 변경으로 효과를 낸다고 정리했다.
- 배지를 쓰는 반응형 구간에서는 기존 상단 열 헤더까지 남겨두면 중복 정보가 되므로, 같은 브레이크포인트에서 헤더는 숨기고 칸 배지 하나만 남기는 편이 맞다고 정리했다.
## 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 문제이므로, 내부 객체 키 순서가 아니라 현재 화면 배치 순서를 저장 기준으로 삼는 편이 맞다고 정리했다.
- 템플릿 요청이 저장본에서만 가능하다면 삭제도 같은 기준을 따르는 편이 흐름상 자연스러우므로, 저장되지 않은 초안에는 삭제 액션을 노출하지 않는 쪽으로 판단했다.
## 2026-04-02 v1.3.61
- 업로드 드롭존은 기능만 같고 생김새가 다르면 운영자와 사용자 모두 맥락 전환 비용이 생기므로, 관리자와 에디터에서 같은 아이콘·점선 보더·버튼 문법으로 읽히게 맞추는 편이 낫다고 정리했다.
- 썸네일 교체 영역은 일반 입력 필드처럼 보이면 클릭 가능성이 떨어지므로, 이미지 미리보기 위에서도 업로드 박스라는 인상이 유지되게 밝은 배경과 아이콘을 함께 쓰는 쪽으로 판단했다.
## 2026-04-02 v1.3.60
- 관리자 접근 차단은 유지하되, 이미 로그인된 관리자가 새로고침할 때 홈으로 튕기는 체감은 권한 제어보다 더 큰 문제이므로 인증 결과가 나올 때까지 같은 세션 확인 요청을 기다리는 편이 맞다고 정리했다.
- 관리자 썸네일 드롭존과 에디터 보드 드롭존은 기능은 같아도 현재 상태가 문구와 형태로 바로 드러나야 하므로, 빈 상태와 교체 상태를 텍스트로 구분하고 점선 박스 형태를 더 적극적으로 드러내는 쪽으로 판단했다.
## 2026-04-02 v1.3.59
- 템플릿 요청 아이템 반영은 관리자 주의에만 맡기기보다, 서버에서 같은 게임 안의 동일 `src` 중복 생성을 막고 프런트에서도 이미 반영된 요청 아이템을 다시 드래프트에 올리지 않는 편이 운영 안정성에 더 낫다고 정리했다.
- 신규 템플릿 요청은 “완전한 임시 게임” 구조로 바로 바꾸기보다, 우선 한 번 만든 게임을 요청과 연결해 다시 `확인하기`를 눌러도 같은 게임을 재사용하게 만드는 편이 리스크 대비 효과가 크다고 판단했다.
- 신규 템플릿 요청 카드는 생성 여부가 관리자의 머릿속 상태가 아니라 UI 메타로 드러나야 하므로, `연결된 게임 있음/없음``이미 반영 n개`를 카드와 작업 패널 양쪽에서 함께 보여주는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.55
- 관리자 요청/업로드 배지는 문구만 다르면 빠르게 구분하기 어려우므로, 같은 `pill` 구조를 유지하되 색으로도 역할을 나누는 편이 운영 판단에 더 적합하다고 정리했다.
- 신규 템플릿 요청으로 새 게임을 만들 때는 아이템만 가져오고 썸네일이 비어 있으면 식별성이 떨어지므로, 요청 썸네일도 기본값으로 함께 승계하는 편이 맞다고 판단했다.

View File

@@ -1,6 +1,39 @@
# 할 일 및 이슈
## 단기 확인
- 티어표 편집/프리뷰 모바일 열 배지는 새로 붙였으므로, 실제 좁은 화면에서 칸 상단 배지와 아이템 썸네일이 겹치지 않고 열 구분이 자연스러운지 한 번 더 QA한다.
- 모바일 열 배지는 같은 구간에서 상단 열 제목을 숨기도록 다시 맞췄으므로, 720px 안팎뿐 아니라 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에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
- 관리자 템플릿 요청은 동일 `src` 중복 생성 방지와 `연결된 게임/이미 반영 n개` 표시까지 붙였으므로, 이후에는 요청별 반영 이력(예: 어떤 아이템을 언제 반영했는지)까지 별도로 남길지 검토한다.
- 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다.
- 관리자 본문 컴포넌트 분리와 `게임/템플릿 요청/회원 관리/아이템 관리/목록 관리` composable 분리는 시작했으므로, 다음 단계에서는 공통 모달 상태를 어느 계층에서 소유할지 정리하고 남은 관리자 유틸 함수를 더 줄인다.
- 관리자 화면은 섹션 경로 분리까지 끝났으므로, 다음 단계에서는 `AdminView.vue`를 실제 레이아웃 뷰와 섹션별 라우트 컴포넌트로 더 쪼갤지 결정한다.
@@ -18,16 +51,10 @@
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
- 관리자 템플릿 요청 카드와 전체 티어표 카드가 같은 문법으로 맞춰졌으므로, 이후에는 라이트모드까지 포함해 두 카드의 썸네일 높이·입력창 밀도·아이템 그리드를 한 번 더 비교 QA한다.
- 템플릿 요청 저장 흐름은 저장된 티어표 기준으로 바뀌었으므로, 이후 실제 데이터로 빈 제목 저장 시 자동 생성 제목·요청 버튼 노출 시점·관리자 요청 미리보기 밀도를 한 번 더 비교 QA한다.

View File

@@ -1,5 +1,116 @@
# 업데이트 로그
## 2026-04-02 v1.3.83
- 티어표 편집/프리뷰 화면에서 열을 여러 개 쓰는 경우, 모바일처럼 좁은 화면에서는 기존 상단 열 헤더만으로 각 칸의 의미를 읽기 어려웠으므로 각 칸 상단에 작은 열 이름 배지를 추가함.
- 이 배지는 모바일 구간에서만 보이고 데스크톱 레이아웃은 그대로 유지되므로, 작은 화면에서는 `메인 / 밸런스 / 서포트` 같은 열 맥락을 스크롤 중에도 잃지 않게 정리함.
- 이후 배지가 칸 기준이 아니라 화면 한쪽에 겹치던 문제를 바로잡기 위해 각 칸을 기준점으로 다시 잡았고, 배지가 보이는 구간에서는 기존 상단 열 제목을 함께 숨겨 중복 표기를 제거함.
## 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 모두 같은 순서 기준을 공유하게 되어, 이미지를 여러 장 올린 뒤 저장해도 사용자가 보고 있던 흐름이 덜 흔들리도록 정리함.
- 티어표 삭제 버튼은 이제 템플릿 요청과 같은 기준으로 `저장된 티어표`에서만 노출되며, 실제 삭제도 저장본 ID가 있을 때만 동작하도록 맞춰 저장 전 초안 상태의 어색한 삭제 액션을 제거함.
## 2026-04-02 v1.3.61
- 관리자 게임 관리의 썸네일 드롭존, 관리자 기본 아이템 추가 드롭존, 티어표 에디터의 커스텀 이미지 드롭존에 `add_photo_alternate` 아이콘을 넣어 업로드 영역임을 더 빠르게 인식할 수 있게 정리함.
- 관리자와 에디터 드롭존은 점선 보더 굵기, 라운드, 밝은 배경 톤, 활성화 상태 색 변화, 파일 선택 버튼 크기를 같은 계열로 맞춰 서로 다른 화면에서도 같은 업로드 컴포넌트처럼 읽히도록 통일함.
- 썸네일 드롭존 역시 배경을 일반 입력 필드보다 더 밝고 넓은 업로드 박스처럼 보이게 조정해, 일반 폼 필드와 대표 이미지 교체 영역을 시각적으로 더 분명하게 구분함.
## 2026-04-02 v1.3.60
- 관리자 게임 관리의 대표 썸네일 드롭존은 이제 썸네일이 없을 때는 `클릭 & 드래그`, 이미 등록된 썸네일이 있을 때는 `썸네일 변경`으로 문구가 바뀌어 현재 동작을 더 바로 읽을 수 있게 함.
- 관리자 인증 상태는 라우터 가드와 앱 셸이 동시에 `/api/auth/me`를 호출할 때, 가드가 아직 끝나지 않은 요청을 기다리지 못해 새로고침 직후 홈으로 튕기던 흐름이 있었으므로 인증 스토어에서 진행 중인 `refresh` Promise를 재사용하도록 정리함.
- 따라서 관리자 계정으로 로그인된 상태에서는 `/admin/...` 경로를 새로고침해도 세션 확인이 끝날 때까지 같은 요청을 기다린 뒤 관리자 화면에 남도록 안정성을 보강함.
- 티어표 만들기 화면의 보드 드롭존은 점선 테두리, 더 높은 박스, 중앙 정렬된 안내 문구와 버튼을 적용해 커스텀 이미지 추가 영역임을 더 즉시 인식할 수 있게 조정함.
## 2026-04-02 v1.3.59
- 관리자 템플릿 요청의 `promote-items` 처리에서는 잘못된 `z.record` 스키마 때문에 500이 나던 서버 파싱 버그를 수정하고, 요청 아이템 `src`까지 함께 받아 실제 요청 데이터와 더 안정적으로 매칭하도록 보강함.
- 요청 아이템을 게임에 반영할 때는 이제 같은 게임 안에 동일한 `src`가 이미 있으면 새 기본 아이템을 다시 만들지 않도록 막고, 관리자 화면에서도 이미 반영된 요청 아이템은 드래프트에 다시 올리지 않게 정리함.
- 신규 템플릿 요청으로 새 게임을 한 번 만들면 해당 요청과 새 게임을 연결해 저장하고, 이후 같은 요청에서 다시 `확인하기`를 눌렀을 때는 새 게임을 또 만들지 않고 기존에 연결된 게임으로 바로 복귀하도록 흐름을 정리함.
- 따라서 요청 카드와 게임 관리 작업 패널에서는 `연결된 게임`, `이미 반영 n개` 같은 상태를 함께 보여, 처리 완료 전에도 현재 진행 정도와 재작업 위험을 더 쉽게 구분할 수 있게 함.
## 2026-04-02 v1.3.55
- 관리자 요청 카드 오른쪽 상단의 `신규 템플릿 / 보유 템플릿` 배지는 서로 다른 색상으로 분리해, 카드 타입을 텍스트보다 더 빠르게 구분할 수 있게 조정함.
- 게임 관리의 기본 아이템 추가 미리보기에서도 `요청 아이템 / 직접 추가 파일` 배지를 서로 다른 색상으로 구분해, 요청 반영분과 직접 업로드분이 한눈에 섞이지 않도록 정리함.

View File

@@ -9,6 +9,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 +21,12 @@ const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const { toasts, dismissToast } = useToast()
const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
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')
@@ -135,11 +138,11 @@ const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : '
const leftBottomPrimaryAction = computed(() => {
if (!authReady.value) return null
if (route.name === 'home' && auth.user) {
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new' }
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new', iconSrc: iconDashboardCustomize }
}
if (route.name === 'gameHub') {
const target = `/editor/${route.params.gameId}/new`
return { label: '새 티어표 만들기', to: auth.user ? target : `/login?redirect=${target}` }
return { label: '새 티어표 만들기', to: auth.user ? target : `/login?redirect=${target}`, iconSrc: iconAddNotes }
}
return null
})
@@ -391,11 +394,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(query ? `/?q=${encodeURIComponent(query)}` : '/')
}
@@ -444,7 +443,7 @@ 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">
@@ -468,6 +467,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>
@@ -513,12 +521,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 +629,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 +754,11 @@ function submitGlobalSearch() {
}
.rightRail__content {
flex: 0 0 auto;
flex: 1 1 auto;
min-height: 0;
overflow: visible;
display: flex;
flex-direction: column;
}
.ghostIcon {
@@ -953,21 +969,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,11 +995,16 @@ 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 {
@@ -988,14 +1012,24 @@ function submitGlobalSearch() {
}
.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 +1041,10 @@ function submitGlobalSearch() {
padding-top: 12px;
}
.leftRail__collapsedAction {
display: none;
}
.adminButton {
width: 100%;
display: inline-flex;
@@ -1031,6 +1069,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;
@@ -1145,13 +1206,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 +1654,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;
}

View 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

View 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="M480-480ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h320v80H200v560h560v-320h80v320q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Zm440-320v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 347 B

View 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

View 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

View File

@@ -1,14 +1,31 @@
<script setup>
import { toApiUrl } from '../../lib/runtime'
import SvgIcon from '../SvgIcon.vue'
import addPhotoAlternateIcon from '../../assets/icons/add_photo_alternate.svg'
const props = defineProps({
activeTemplateRequest: { type: Object, default: null },
templateRequestSourceUrl: { type: Function, required: true },
stagedRequestDraftCount: { type: Number, required: true },
appliedRequestItemCount: { type: Number, required: true },
openGameCreateModal: { type: Function, required: true },
isGameLoading: { type: Boolean, required: true },
hasSelectedGame: { type: Boolean, required: true },
selectedGame: { 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 },
removeGame: { type: Function, required: true },
toggleSelectedGameVisibility: { type: Function, required: true },
itemFileInputRef: { type: Function, required: true },
onFile: { type: Function, required: true },
isItemDragOver: { type: Boolean, required: true },
@@ -29,6 +46,14 @@ const props = defineProps({
removeGameItem: { type: Function, required: true },
selectedGameId: { type: String, default: '' },
})
function setGameItemListElement(el) {
props.gameItemListRef(el)
}
function setThumbFileElement(el) {
props.thumbFileInputRef(el)
}
</script>
<template>
@@ -38,12 +63,22 @@ const props = defineProps({
<div class="panel__title">진행 중인 요청 작업</div>
<div class="requestWorkspace__title">{{ props.activeTemplateRequest.sourceTierListTitle || '템플릿 요청' }}</div>
<div class="hint hint--tight">
{{ props.activeTemplateRequest.type === 'create' ? '새 게임을 만든 뒤 필요한 아이템만 골라 저장하세요.' : '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.' }}
{{
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">요청 아이템 {{ props.stagedRequestDraftCount }}</span>
<span v-if="props.appliedRequestItemCount" class="pill pill--soft">이미 반영 {{ props.appliedRequestItemCount }}</span>
<span v-if="props.activeTemplateRequest.type === 'create' && (props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId)" class="pill pill--soft">
연결된 게임 · {{ props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId }}
</span>
</div>
</div>
<div class="requestWorkspace__actions">
@@ -56,7 +91,12 @@ const props = defineProps({
>
요청 티어표 보기
</a>
<button v-if="props.activeTemplateRequest.type === 'create'" class="btn btn--ghost btn--small" type="button" @click="props.openGameCreateModal">
<button
v-if="props.activeTemplateRequest.type === 'create' && !props.activeTemplateRequest.targetGameId"
class="btn btn--ghost btn--small"
type="button"
@click="props.openGameCreateModal"
>
게임 만들기
</button>
</div>
@@ -69,13 +109,43 @@ const props = defineProps({
</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>
<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.selectedGame.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.selectedGame.game.name }} · {{ props.selectedGame.game.id }}</div>
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.gameVisibilitySaving }">
<input :checked="!!props.selectedGame.game.isPublic" type="checkbox" @change="props.toggleSelectedGameVisibility($event.target.checked)" />
<span class="toggleSwitch__label">{{ props.selectedGame.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.removeGame">게임 삭제</button>
</div>
</div>
</section>
<div class="section">
<section class="adminCard">
@@ -92,18 +162,18 @@ const props = defineProps({
@dragleave="props.onItemDragLeave"
@drop="props.onItemDrop"
>
<div class="dropZone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="dropZone__icon" />
</div>
<div class="dropZone__title">이미지를 드래그해서 기본 아이템으로 추가</div>
<div class="dropZone__desc">
여러 파일을 번에 올릴 있고, 저장 라벨은 파일명으로 자동 생성됩니다.
<span v-if="props.stagedRequestDraftCount"> 현재 요청에서 가져온 아이템 {{ props.stagedRequestDraftCount }}개도 함께 검토 중이에요.</span>
</div>
<div class="dropZone__actions">
<button class="btn btn--ghost btn--small" type="button" @click.stop="props.openItemFilePicker">파일 선택</button>
<button class="btn btn--ghost btn--small dropZone__button" type="button" @click.stop="props.openItemFilePicker">파일 선택</button>
</div>
</div>
<button class="btn" :disabled="!props.canAddItem" @click="props.uploadItem">
아이템 {{ props.uploadItemDrafts.length || 0 }} 추가
</button>
</div>
<div class="itemPreviewCard">
<div v-if="props.uploadItemDrafts.length" class="itemDraftList">
@@ -128,9 +198,9 @@ const props = defineProps({
</div>
</div>
<div v-else class="itemPreviewEmpty">등록한 기본 아이템 미리보기가 여기에 표시됩니다.</div>
<div class="thumbLabel thumbLabel--preview">
{{ props.uploadItemDrafts.length ? `추가 예정 아이템 ${props.uploadItemDrafts.length}` : '아직 선택된 파일이 없어요.' }}
</div>
<button class="btn itemPreviewCard__submit" :disabled="!props.canAddItem" @click="props.uploadItem">
{{ props.uploadItemDrafts.length ? `아이템 ${props.uploadItemDrafts.length} 추가` : '아이템 추가' }}
</button>
</div>
</div>
</section>
@@ -145,7 +215,7 @@ const props = defineProps({
<button class="btn btn--primary btn--small" :disabled="!props.hasGameItemOrderChanges" @click="props.saveGameItemOrder">순서 저장</button>
</div>
<div v-if="!props.selectedGame?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
<div v-else :ref="props.gameItemListRef" class="thumbGrid">
<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">
<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 />

View File

@@ -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,10 +39,17 @@ 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">
@@ -81,6 +90,9 @@ const props = defineProps({
<div class="tierAdminCard__stats">
<span class="pill">추가 아이템 {{ request.items?.length || 0 }}</span>
<span v-if="request.type === 'create' && (request.targetGameName || request.targetGameId)" class="pill pill--soft">
연결됨 · {{ request.targetGameName || request.targetGameId }}
</span>
<span class="pill" :class="{ 'pill--accent': request.status === 'reviewing' }">{{ props.templateRequestStatusLabel(request) }}</span>
</div>
@@ -92,20 +104,16 @@ 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.isHandling
? '이동중...'
: request.type === 'create' && (request.targetGameName || request.targetGameId)
? '연결된 게임 열기'
: '확인하기'
}}
</button>
<button class="btn btn--ghost" :disabled="request.isHandling || request.status !== 'reviewing'" @click="props.completeTemplateRequest(request)">처리 완료</button>
</div>
@@ -119,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>
@@ -126,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>
@@ -136,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>
@@ -162,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>

View File

@@ -8,7 +8,7 @@ export function useAdminCustomItems({
customItemLimit,
customItemPageCount,
customItemQuery,
customItemOrphanOnly,
customItemFilter,
customItemModalOpen,
customItemDeleteModalOpen,
customItemModalHistoryActive,
@@ -16,8 +16,6 @@ export function useAdminCustomItems({
customItemModalDraftLabel,
customItemModalLabelSaving,
customItemModalTargetGameId,
customItemModalGameQuery,
customItemModalGameSort,
games,
selectedGameId,
refreshCustomItems,
@@ -33,7 +31,8 @@ export function useAdminCustomItems({
refreshCustomItems()
}
function toggleCustomItemOrphanOnly() {
function changeCustomItemFilter(filter) {
customItemFilter.value = filter
customItemPage.value = 1
refreshCustomItems()
}
@@ -61,8 +60,6 @@ export function useAdminCustomItems({
modalTargetCustomItem.value = item || null
customItemModalDraftLabel.value = item?.label || ''
customItemModalTargetGameId.value = ''
customItemModalGameQuery.value = ''
customItemModalGameSort.value = 'recent'
customItemModalOpen.value = true
pushCustomItemModalHistoryState()
}
@@ -74,8 +71,6 @@ export function useAdminCustomItems({
customItemModalDraftLabel.value = ''
customItemModalLabelSaving.value = false
customItemModalTargetGameId.value = ''
customItemModalGameQuery.value = ''
customItemModalGameSort.value = 'recent'
if (fromPopState) {
customItemModalHistoryActive.value = false
@@ -186,7 +181,7 @@ export function useAdminCustomItems({
return {
submitCustomItemSearch,
toggleCustomItemOrphanOnly,
changeCustomItemFilter,
changeCustomItemLimit,
moveCustomItemPage,
pushCustomItemModalHistoryState,

View File

@@ -21,6 +21,7 @@ export function useAdminGameManager({
customItemModalTargetGameId,
newGameId,
newGameName,
newGameIsPublic,
clearPreviewUrl,
resetFileInput,
resetUploadState,
@@ -30,6 +31,19 @@ export function useAdminGameManager({
success,
error,
}) {
function normalizeDraftSrc(src) {
if (typeof src !== 'string') return ''
const raw = src.trim()
if (!raw) return ''
if (raw.startsWith('/uploads/')) return raw
try {
const url = new URL(raw)
return url.pathname || raw
} catch (e) {
return raw
}
}
function requestItemFilename(item = {}) {
const src = typeof item.src === 'string' ? item.src : ''
return src.split('/').pop() || item.file?.name || 'item'
@@ -51,9 +65,10 @@ export function useAdminGameManager({
animation: 160,
draggable: '[data-game-item-id]',
forceFallback: true,
fallbackOnBody: true,
fallbackOnBody: false,
filter: '[data-no-drag]',
preventOnFilter: false,
fallbackClass: 'thumbCard--dragging',
ghostClass: 'ghost',
chosenClass: 'chosen',
onEnd: (evt) => {
@@ -72,6 +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 existingKeys = new Set(uploadItemDrafts.value.map((draft) => `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}`))
const nextRequestDrafts = (request.items || [])
.filter((item) => item?.src)
@@ -84,6 +100,7 @@ export function useAdminGameManager({
sourceName: requestItemFilename(item),
src: item.src,
}))
.filter((draft) => !existingGameSrcs.has(normalizeDraftSrc(draft.src)))
.filter((draft) => !existingKeys.has(`${draft.kind}:${draft.requestId}:${draft.itemId}`))
if (nextRequestDrafts.length) {
@@ -100,9 +117,10 @@ export function useAdminGameManager({
uploadFiles.value = uploadItemDrafts.value.filter((draft) => draft.kind === 'file').map((draft) => draft.file).filter(Boolean)
}
async function loadGame() {
async function loadGame(options = {}) {
const preserveUploadState = !!options.preserveUploadState
resetMessages()
resetUploadState()
if (!preserveUploadState) resetUploadState()
if (!selectedGameId.value) {
selectedGame.value = null
@@ -124,7 +142,6 @@ export function useAdminGameManager({
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncGameItemSortable()
} catch (e) {
console.error('[AdminView] loadGame failed', selectedGameId.value, e)
selectedGame.value = null
error.value = '게임 정보를 불러오지 못했어요.'
} finally {
@@ -132,7 +149,10 @@ export function useAdminGameManager({
}
}
async function createGame() {
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()
const preserveUploadState = !!options.preserveUploadState
resetMessages()
try {
const res = await fetch(toApiUrl('/api/admin/games'), {
@@ -140,20 +160,39 @@ export function useAdminGameManager({
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: newGameId.value.trim(),
name: newGameName.value.trim(),
id: nextGameId,
name: nextGameName,
isPublic: !!newGameIsPublic.value,
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
}),
})
if (!res.ok) throw new Error('failed')
const data = await res.json()
if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) {
const linkData = await api.linkAdminTemplateRequestGame(activeTemplateRequest.value.id, {
gameId: data.game.id,
})
activeTemplateRequest.value = {
...activeTemplateRequest.value,
targetGameId: linkData.request?.targetGameId || data.game.id,
targetGameName: linkData.request?.targetGameName || data.game.name || nextGameName,
}
const requestIndex = templateRequests.value.findIndex((entry) => entry.id === activeTemplateRequest.value.id)
if (requestIndex >= 0) {
templateRequests.value.splice(requestIndex, 1, {
...templateRequests.value[requestIndex],
targetGameId: linkData.request?.targetGameId || data.game.id,
targetGameName: linkData.request?.targetGameName || data.game.name || nextGameName,
})
}
}
await refreshGames()
selectedGameId.value = data.game.id
if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id
closeGameCreateModal()
await loadGame()
if (activeTemplateRequest.value?.id) {
await loadGame({ preserveUploadState })
if (!preserveUploadState && activeTemplateRequest.value?.id) {
const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value
mergeRequestItemsIntoDrafts(sourceRequest)
}
@@ -209,12 +248,31 @@ export function useAdminGameManager({
async function uploadItem() {
resetMessages()
if (!uploadItemDrafts.value.length || !selectedGameId.value) {
if (!uploadItemDrafts.value.length) {
error.value = '아이템 파일을 선택해주세요.'
return
}
try {
if (!selectedGameId.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를 저장해주세요.'
return
}
await createGame({
gameId: draftGameId,
gameName: draftGameName,
preserveUploadState: true,
})
}
if (!selectedGameId.value) {
error.value = '게임을 먼저 선택해주세요.'
return
}
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
let uploadCount = 0
@@ -238,15 +296,16 @@ export function useAdminGameManager({
const requestIds = [...new Set(requestDrafts.map((entry) => entry.requestId).filter(Boolean))]
for (const requestId of requestIds) {
const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId)
await api.promoteAdminTemplateRequestItems(requestId, {
const result = await api.promoteAdminTemplateRequestItems(requestId, {
gameId: selectedGameId.value,
itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean),
itemSrcs: draftsForRequest.map((entry) => entry.src).filter(Boolean),
itemLabels: draftsForRequest.reduce((acc, entry) => {
if (entry.itemId) acc[entry.itemId] = entry.label.trim()
return acc
}, {}),
})
uploadCount += draftsForRequest.length
uploadCount += Array.isArray(result?.items) ? result.items.length : 0
}
}
@@ -254,7 +313,21 @@ export function useAdminGameManager({
await loadGame()
success.value = `게임 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
} catch (e) {
error.value = '아이템 추가 실패(관리자 권한/파일 크기 확인)'
const apiError = e?.data?.error || ''
if (apiError === 'no_items_selected') {
error.value = '추가할 요청 아이템이 없어요.'
return
}
if (apiError === 'promote_items_failed') {
const detail = e?.data?.detail ? ` (${e.data.detail})` : ''
error.value = `요청 아이템을 게임 기본 아이템으로 옮기지 못했어요.${detail}`
return
}
if (apiError === 'game_not_found') {
error.value = '선택한 게임을 찾지 못했어요.'
return
}
error.value = '아이템 추가에 실패했어요.'
}
}

View File

@@ -21,10 +21,12 @@ export function useAdminTemplateRequests({
thumbnailSrc: request.thumbnailSrc || '',
draftGameId: request.draftGameId || '',
draftGameName: request.draftGameName || '',
draftGameIsPublic: !!request.draftGameIsPublic,
sourceTierListId: request.sourceTierListId || '',
sourceGameId: request.sourceGameId || '',
sourceTierListTitle: request.sourceTierListTitle || '',
targetGameId: request.targetGameId || '',
targetGameName: request.targetGameName || '',
requesterName: request.requesterName || '',
}
}
@@ -48,19 +50,32 @@ export function useAdminTemplateRequests({
try {
request.isHandling = true
const data = await api.startAdminTemplateRequestReview(request.id)
request.status = data.request?.status || 'reviewing'
updateActiveTemplateRequest(request)
const syncedRequest = {
...request,
...(data.request || {}),
draftGameId: request.draftGameId || '',
draftGameName: request.draftGameName || '',
draftGameIsPublic: !!request.draftGameIsPublic,
}
Object.assign(request, syncedRequest)
request.status = syncedRequest.status || 'reviewing'
updateActiveTemplateRequest(syncedRequest)
setTab('game-admin')
if (request.type === 'create') {
openGameCreateModal()
newGameId.value = (request.draftGameId || '').trim()
newGameName.value = (request.draftGameName || '').trim()
mergeRequestItemsIntoDrafts(request)
const linkedGameId = syncedRequest.targetGameId || ''
if (linkedGameId) {
await selectAdminGame(linkedGameId)
} else {
openGameCreateModal()
newGameId.value = (syncedRequest.draftGameId || '').trim()
newGameName.value = (syncedRequest.draftGameName || '').trim()
}
mergeRequestItemsIntoDrafts(syncedRequest)
} else {
const nextGameId = request.targetGameId || request.sourceGameId || ''
const nextGameId = syncedRequest.targetGameId || syncedRequest.sourceGameId || ''
if (nextGameId) await selectAdminGame(nextGameId)
mergeRequestItemsIntoDrafts(request)
mergeRequestItemsIntoDrafts(syncedRequest)
}
success.value = '요청 아이템을 게임 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
} catch (e) {

View File

@@ -37,14 +37,24 @@ export const api = {
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 } = {}) =>
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 = '', gameId = '', page = 1, limit = 50 } = {}) =>
request(
`/api/admin/tierlists?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`
),
getAdminTierListStats: ({ q = '', gameId = '' } = {}) =>
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}`),
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()
@@ -53,6 +63,7 @@ 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) =>
@@ -65,6 +76,8 @@ export const api = {
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: {} }),
linkAdminTemplateRequestGame: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/link-game`, { method: 'POST', body: payload }),
promoteAdminTemplateRequestItems: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/promote-items`, { method: 'POST', body: payload }),
completeAdminTemplateRequest: (requestId) =>

View File

@@ -1,6 +1,8 @@
import { defineStore } from 'pinia'
import { api } from '../lib/api'
let refreshPromise = null
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
@@ -9,19 +11,23 @@ export const useAuthStore = defineStore('auth', {
}),
actions: {
async refresh() {
if (this.status === 'loading') return this.user
if (refreshPromise) return refreshPromise
this.status = 'loading'
try {
const data = await api.me()
this.user = data.user
return this.user
} catch (error) {
this.user = null
return null
} finally {
this.status = 'idle'
this.hydrated = true
}
refreshPromise = (async () => {
try {
const data = await api.me()
this.user = data.user
return this.user
} catch (error) {
this.user = null
return null
} finally {
this.status = 'idle'
this.hydrated = true
refreshPromise = null
}
})()
return refreshPromise
},
async signup(email, password) {
const user = await api.signup({ email, password })
@@ -42,4 +48,3 @@ export const useAuthStore = defineStore('auth', {
},
},
})

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,8 @@ import * as htmlToImage from 'html-to-image'
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 { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
@@ -118,10 +120,7 @@ const copiedFromLabel = computed(() => {
if (sourceSnapshotAuthor.value) parts.push(sourceSnapshotAuthor.value)
return parts.join(' · ') || '복사해 온 티어표'
})
const customItems = computed(() =>
Object.values(itemsById.value)
.filter((item) => item?.origin === 'custom')
)
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
@@ -132,6 +131,12 @@ const canRequestTemplateUpdate = computed(
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 shareTierListUrl = computed(() => {
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
if (!savedTierListId) return ''
if (typeof window === 'undefined') return `/editor/${gameId.value}/${savedTierListId}?preview=1`
return new URL(`/editor/${gameId.value}/${savedTierListId}?preview=1`, window.location.origin).toString()
})
watch(error, (message) => {
if (!message) return
@@ -165,6 +170,29 @@ function formatExportDate(ts) {
})
}
function getOrderedItemIds() {
const orderedIds = []
const seen = new Set()
const pushId = (itemId) => {
if (!itemId || seen.has(itemId) || !itemsById.value[itemId]) return
seen.add(itemId)
orderedIds.push(itemId)
}
pool.value.forEach(pushId)
groups.value.forEach((group) => {
;(group.cells || []).forEach((cell) => {
;(cell || []).forEach(pushId)
})
})
Object.keys(itemsById.value).forEach(pushId)
return orderedIds
}
function getOrderedItems() {
return getOrderedItemIds().map((itemId) => itemsById.value[itemId]).filter(Boolean)
}
function setIconSize(nextSize) {
iconSize.value = nextSize
}
@@ -654,7 +682,7 @@ function buildPayload(existingId) {
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
groups: buildGroupPayload(),
pool: Object.values(itemsById.value),
pool: getOrderedItems(),
}
}
@@ -691,6 +719,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
}
@@ -721,6 +775,7 @@ function closeTemplateUpdateModal() {
}
function openDeleteModal() {
if (!hasSavedTierList.value) return
isDeleteModalOpen.value = true
}
@@ -729,11 +784,12 @@ function closeDeleteModal() {
}
async function confirmDeleteTierList() {
if (!canEdit.value || isNewTierList.value || isDeleting.value) return
const currentTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
if (!canEdit.value || !currentTierListId || isDeleting.value) return
error.value = ''
try {
isDeleting.value = true
await api.deleteTierList(tierListId.value)
await api.deleteTierList(currentTierListId)
closeDeleteModal()
toast.success('티어표를 삭제했어요.')
router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`)
@@ -791,7 +847,7 @@ async function requestTemplate(type) {
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
groups: buildGroupPayload(),
boardItems: Object.values(itemsById.value),
boardItems: getOrderedItems(),
})
if (type === 'create') closeTemplateRequestModal()
@@ -918,6 +974,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>
@@ -935,6 +992,10 @@ onUnmounted(() => {
</div>
</div>
</div>
<div class="previewOnly__footer">
<span>{{ effectiveAuthorName }}</span>
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
</div>
</div>
</section>
@@ -1142,13 +1203,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" />
@@ -1184,13 +1246,16 @@ onUnmounted(() => {
@dragleave="onDragLeave"
@drop.prevent="onDropFiles"
>
<div>
<div>
<div class="dropzone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="dropzone__icon" />
</div>
<div class="dropzone__title">커스텀 이미지 추가</div>
<div class="dropzone__desc">이곳으로 이미지를 드래그하거나 파일 선택으로 번에 추가할 있어요.</div>
</div>
<div class="dropzone__actions">
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
<button class="btn btn--ghost dropzone__button" @click="openFile">파일 선택</button>
<button class="btn btn--ghost btn--small dropzone__button" @click="openFile">파일 선택</button>
</div>
</div>
</div>
@@ -1298,7 +1363,11 @@ 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="canEdit && !isNewTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
<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
v-if="canRequestTemplateCreate"
@@ -1440,6 +1509,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);
@@ -1450,6 +1520,10 @@ onUnmounted(() => {
gap: 8px;
align-content: flex-start;
}
.previewOnly__columnBadge,
.row__columnBadge {
display: none;
}
.previewOnly__cell {
display: inline-flex;
position: relative;
@@ -1476,6 +1550,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;
@@ -2073,9 +2156,26 @@ onUnmounted(() => {
.dropzone--board {
margin-top: 18px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
justify-content: center;
gap: 18px;
min-height: 180px;
padding: 28px 22px;
border-radius: 22px;
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
text-align: center;
}
.dropzone--board.dropzone--active {
border-color: rgba(96, 165, 250, 0.56);
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg-hover) 90%, white 6%), color-mix(in srgb, var(--theme-card-bg) 84%, white 4%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 18%, transparent), transparent 60%);
transform: translateY(-1px);
}
.dropzone__actions {
@@ -2083,10 +2183,39 @@ onUnmounted(() => {
align-items: center;
gap: 12px;
flex: 0 0 auto;
justify-content: center;
}
.dropzone__button {
min-width: 148px;
min-width: 124px;
min-height: 34px;
font-size: 11px;
}
/* .dropzone__iconWrap {
width: 52px;
height: 52px;
display: grid;
place-items: center;
border-radius: 16px;
background: color-mix(in srgb, var(--theme-text) 10%, transparent);
} */
.dropzone__icon {
width: 48px;
height: 48px;
opacity: 0.86;
}
.dropzone--board .dropzone__title {
font-size: 18px;
font-weight: 800;
}
.dropzone--board .dropzone__desc {
max-width: 520px;
color: var(--theme-text-soft);
line-height: 1.6;
}
.editorSidebar__section {
display: grid;
@@ -2210,6 +2339,9 @@ onUnmounted(() => {
background: transparent;
color: var(--theme-text-muted);
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
@@ -2221,6 +2353,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;
@@ -2285,10 +2421,11 @@ onUnmounted(() => {
}
.dropzone {
margin-top: 12px;
padding: 14px;
padding: 28px 22px;
border-radius: 16px;
border: 1px dashed rgba(255, 255, 255, 0.18);
background: var(--theme-surface-soft);
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
}
.dropzone--active {
border-color: rgba(110, 231, 183, 0.6);
@@ -2361,6 +2498,44 @@ onUnmounted(() => {
border-radius: 14px;
}
@media (max-width: 980px) {
.previewOnly__columns,
.previewOnly__row,
.boardColumnsHeader,
.row {
grid-template-columns: 1fr;
}
.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;
}
.previewOnly__row {
grid-template-columns: 140px 1fr;
}
@@ -2404,20 +2579,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));
}