Compare commits

..

4 Commits

14 changed files with 223 additions and 183 deletions

View File

@@ -31,6 +31,20 @@ function serializeJson(value) {
return JSON.stringify(value || []) return JSON.stringify(value || [])
} }
function normalizeLegacyItemOrigins(items) {
let changed = false
const normalized = (items || []).map((item) => {
if (!item || typeof item !== 'object') return item
if (item.origin !== 'game') return item
changed = true
return {
...item,
origin: 'template',
}
})
return { normalized, changed }
}
function collectUploadSrcsFromItems(items, bucket) { function collectUploadSrcsFromItems(items, bucket) {
for (const item of items || []) { for (const item of items || []) {
if (typeof item?.src === 'string' && item.src.startsWith('/uploads/')) { if (typeof item?.src === 'string' && item.src.startsWith('/uploads/')) {
@@ -599,6 +613,25 @@ async function ensureSchema() {
async function ensureData() { async function ensureData() {
await ensureSchema() await ensureSchema()
const tierListRows = await query('SELECT id, pool_json FROM tierlists')
for (const row of tierListRows) {
const { normalized, changed } = normalizeLegacyItemOrigins(parseJson(row.pool_json, []))
if (!changed) continue
await query('UPDATE tierlists SET pool_json = ? WHERE id = ?', [serializeJson(normalized), row.id])
}
const requestRows = await query('SELECT id, items_json, board_items_json FROM template_requests')
for (const row of requestRows) {
const itemsResult = normalizeLegacyItemOrigins(parseJson(row.items_json, []))
const boardItemsResult = normalizeLegacyItemOrigins(parseJson(row.board_items_json, []))
if (!itemsResult.changed && !boardItemsResult.changed) continue
await query('UPDATE template_requests SET items_json = ?, board_items_json = ? WHERE id = ?', [
serializeJson(itemsResult.normalized),
serializeJson(boardItemsResult.normalized),
row.id,
])
}
} }
async function countUsers() { async function countUsers() {
@@ -788,7 +821,7 @@ async function getTopicDetail(topicId) {
const topic = await findTopicById(topicId) const topic = await findTopicById(topicId)
if (!topic) return null if (!topic) return null
const items = await listTopicItems(topicId) const items = await listTopicItems(topicId)
return { topic, game: topic, items } return { topic, template: topic, items }
} }
async function createTopic({ id, name, isPublic = true }) { async function createTopic({ id, name, isPublic = true }) {
@@ -972,8 +1005,8 @@ async function listReferencedUploadUsage() {
]) ])
for (const row of userRows) addUsage(row.avatar_src, 'avatar') for (const row of userRows) addUsage(row.avatar_src, 'avatar')
for (const row of gameRows) addUsage(row.thumbnail_src, 'game-thumbnail') for (const row of gameRows) addUsage(row.thumbnail_src, 'topic-thumbnail')
for (const row of gameItemRows) addUsage(row.src, 'game-item') for (const row of gameItemRows) addUsage(row.src, 'topic-item')
for (const row of customItemRows) addUsage(row.src, 'custom-item') for (const row of customItemRows) addUsage(row.src, 'custom-item')
for (const row of tierListRows) { for (const row of tierListRows) {
@@ -1193,7 +1226,7 @@ async function cleanupMissingUploadReferences() {
for (const row of gameItemRows) { for (const row of gameItemRows) {
if (await fileExistsForUploadSrc(row.src)) continue if (await fileExistsForUploadSrc(row.src)) continue
await deleteGameItem(row.id) await deleteTopicItem(row.id)
stats.deletedGameItems += 1 stats.deletedGameItems += 1
} }
@@ -1468,22 +1501,6 @@ async function createCustomItem({ id, ownerId, src, label }) {
return { id, ownerId, src, label, origin: 'custom', createdAt } return { id, ownerId, src, label, origin: 'custom', createdAt }
} }
const listGames = listTopics
const findGameById = findTopicById
const listGameItems = listTopicItems
const findGameItemById = findTopicItemById
const getGameDetail = getTopicDetail
const createGame = createTopic
const updateGameThumbnail = updateTopicThumbnail
const updateGameVisibility = updateTopicVisibility
const createGameItem = createTopicItem
const updateGameItemLabel = updateTopicItemLabel
const updateGameItemDisplayOrder = updateTopicItemDisplayOrder
const countTierListsUsingGameItem = countTierListsUsingTopicItem
const deleteGameItem = deleteTopicItem
const deleteGame = deleteTopic
const updateGameDisplayOrder = updateTopicDisplayOrder
async function syncOwnedCustomItemLabels({ ownerId, items }) { async function syncOwnedCustomItemLabels({ ownerId, items }) {
const customItems = Array.from( const customItems = Array.from(
new Map( new Map(
@@ -1533,7 +1550,7 @@ async function getCustomItemUsageMeta() {
` `
) )
const usageMap = new Map() const usageMap = new Map()
const linkedGamesMap = new Map() const linkedTemplatesMap = new Map()
rows.forEach((row) => { rows.forEach((row) => {
const groups = parseJson(row.groups_json, []) const groups = parseJson(row.groups_json, [])
@@ -1557,8 +1574,8 @@ async function getCustomItemUsageMeta() {
if (!row.topic_id) return if (!row.topic_id) return
seenItemIds.forEach((itemId) => { seenItemIds.forEach((itemId) => {
if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map()) if (!linkedTemplatesMap.has(itemId)) linkedTemplatesMap.set(itemId, new Map())
linkedGamesMap.get(itemId).set(row.topic_id, { linkedTemplatesMap.get(itemId).set(row.topic_id, {
id: row.topic_id, id: row.topic_id,
name: row.topic_name || row.topic_id, name: row.topic_name || row.topic_id,
}) })
@@ -1567,7 +1584,7 @@ async function getCustomItemUsageMeta() {
return { return {
usageMap, usageMap,
linkedGamesMap: new Map(Array.from(linkedGamesMap.entries()).map(([itemId, gameMap]) => [itemId, Array.from(gameMap.values())])), linkedTemplatesMap: new Map(Array.from(linkedTemplatesMap.entries()).map(([itemId, templateMap]) => [itemId, Array.from(templateMap.values())])),
} }
} }
@@ -1636,7 +1653,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
}) })
const customItems = customRows.map((row) => { const customItems = customRows.map((row) => {
const linkedGames = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()) const linkedTemplates = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values())
return { return {
id: row.id, id: row.id,
ownerId: row.owner_id, ownerId: row.owner_id,
@@ -1646,7 +1663,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerName: row.nickname || row.email, ownerName: row.nickname || row.email,
ownerEmail: row.email, ownerEmail: row.email,
usageCount: usageMeta.usageMap.get(row.id) || 0, usageCount: usageMeta.usageMap.get(row.id) || 0,
linkedGames, linkedTemplates,
sourceType: 'user', sourceType: 'user',
sourceLabel: '사용자 업로드', sourceLabel: '사용자 업로드',
canDelete: true, canDelete: true,
@@ -1667,7 +1684,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerName: '관리자 보관 자산', ownerName: '관리자 보관 자산',
ownerEmail: '', ownerEmail: '',
usageCount: 0, usageCount: 0,
linkedGames: [], linkedTemplates: [],
sourceType: 'template', sourceType: 'template',
sourceLabel: '관리자 템플릿', sourceLabel: '관리자 템플릿',
canDelete: true, canDelete: true,
@@ -1685,7 +1702,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerName: row.topic_name || row.topic_id, ownerName: row.topic_name || row.topic_id,
ownerEmail: '', ownerEmail: '',
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size, usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
linkedGames: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()), linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
sourceType: 'template', sourceType: 'template',
sourceLabel: '관리자 템플릿', sourceLabel: '관리자 템플릿',
canDelete: true, canDelete: true,
@@ -1704,7 +1721,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
const allItems = baseItems const allItems = baseItems
.map((item) => { .map((item) => {
const siblings = groupedBySrc.get(item.src) || [item] const siblings = groupedBySrc.get(item.src) || [item]
const linkedGames = new Map() const linkedTemplates = new Map()
let userReferenceCount = 0 let userReferenceCount = 0
let templateReferenceCount = 0 let templateReferenceCount = 0
let assetReferenceCount = 0 let assetReferenceCount = 0
@@ -1713,8 +1730,8 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
if (entry.sourceType === 'user') userReferenceCount += 1 if (entry.sourceType === 'user') userReferenceCount += 1
else if (entry.isAssetLibraryItem) assetReferenceCount += 1 else if (entry.isAssetLibraryItem) assetReferenceCount += 1
else templateReferenceCount += 1 else templateReferenceCount += 1
;(entry.linkedGames || []).forEach((game) => { ;(entry.linkedTemplates || []).forEach((template) => {
if (game?.id) linkedGames.set(game.id, game) if (template?.id) linkedTemplates.set(template.id, template)
}) })
}) })
@@ -1724,7 +1741,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
sharedUserReferenceCount: userReferenceCount, sharedUserReferenceCount: userReferenceCount,
sharedTemplateReferenceCount: templateReferenceCount, sharedTemplateReferenceCount: templateReferenceCount,
sharedAssetReferenceCount: assetReferenceCount, sharedAssetReferenceCount: assetReferenceCount,
sharedLinkedGameCount: linkedGames.size, sharedLinkedTemplateCount: linkedTemplates.size,
sharedEntries: siblings sharedEntries: siblings
.slice() .slice()
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0)) .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
@@ -1738,7 +1755,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
sourceTopicId: entry.sourceTopicId || '', sourceTopicId: entry.sourceTopicId || '',
sourceTopicName: entry.sourceTopicName || '', sourceTopicName: entry.sourceTopicName || '',
usageCount: entry.usageCount || 0, usageCount: entry.usageCount || 0,
linkedGames: entry.linkedGames || [], linkedTemplates: entry.linkedTemplates || [],
isAssetLibraryItem: !!entry.isAssetLibraryItem, isAssetLibraryItem: !!entry.isAssetLibraryItem,
})), })),
} }
@@ -1752,7 +1769,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
case 'asset': case 'asset':
return !!item.isAssetLibraryItem return !!item.isAssetLibraryItem
case 'unused-user': case 'unused-user':
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0 return item.sourceType === 'user' && item.usageCount === 0 && item.linkedTemplates.length === 0
case 'unused-admin': case 'unused-admin':
return !!item.isAssetLibraryItem return !!item.isAssetLibraryItem
default: default:
@@ -2027,7 +2044,7 @@ function uniqueTierListItems(poolItems) {
id: item.id, id: item.id,
src: item.src || '', src: item.src || '',
label: item.label || 'item', label: item.label || 'item',
origin: item.origin || 'game', origin: item.origin || 'template',
}) })
}) })
return Array.from(map.values()) return Array.from(map.values())
@@ -2522,9 +2539,6 @@ async function unfavoriteTopic({ userId, topicId }) {
await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, topicId]) await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, topicId])
} }
const favoriteGame = favoriteTopic
const unfavoriteGame = unfavoriteTopic
module.exports = { module.exports = {
DB_NAME, DB_NAME,
ensureData, ensureData,
@@ -2547,14 +2561,6 @@ module.exports = {
createTopic, createTopic,
updateTopicThumbnail, updateTopicThumbnail,
updateTopicVisibility, updateTopicVisibility,
listGames,
findGameById,
listGameItems,
findGameItemById,
getGameDetail,
createGame,
updateGameThumbnail,
updateGameVisibility,
findImageAssetByHash, findImageAssetByHash,
findImageAssetBySrc, findImageAssetBySrc,
findImageAssetById, findImageAssetById,
@@ -2578,15 +2584,8 @@ module.exports = {
deleteTopicItem, deleteTopicItem,
deleteTopic, deleteTopic,
updateTopicDisplayOrder, updateTopicDisplayOrder,
createGameItem,
updateGameItemLabel,
updateGameItemDisplayOrder,
countTierListsUsingGameItem,
updateCustomItemLabel, updateCustomItemLabel,
updateImageAssetLabel, updateImageAssetLabel,
deleteGameItem,
deleteGame,
updateGameDisplayOrder,
createCustomItem, createCustomItem,
findCustomItemById, findCustomItemById,
listCustomItems, listCustomItems,
@@ -2602,8 +2601,6 @@ module.exports = {
unfavoriteTopic, unfavoriteTopic,
favoriteTierList, favoriteTierList,
unfavoriteTierList, unfavoriteTierList,
favoriteGame,
unfavoriteGame,
deleteTierList, deleteTierList,
findCustomItemsByIds, findCustomItemsByIds,
deleteCustomItems, deleteCustomItems,
@@ -2614,5 +2611,4 @@ module.exports = {
listAdminTemplateRequests, listAdminTemplateRequests,
updateTemplateRequestStatus, updateTemplateRequestStatus,
updateTemplateRequestTargetTopic, updateTemplateRequestTargetTopic,
updateTemplateRequestTargetGame: updateTemplateRequestTargetTopic,
} }

View File

@@ -187,7 +187,7 @@ router.post('/templates/:templateId/thumbnail', requireAdmin, upload.single('thu
const optimized = await writeOptimizedImage({ const optimized = await writeOptimizedImage({
file: req.file, file: req.file,
directory: 'games', directory: 'topics',
width: 1280, width: 1280,
height: 1280, height: 1280,
fit: 'inside', fit: 'inside',
@@ -214,7 +214,7 @@ router.post('/templates/:templateId/images', requireAdmin, upload.array('images'
files.map(async (file, index) => { files.map(async (file, index) => {
const optimized = await writeOptimizedImage({ const optimized = await writeOptimizedImage({
file, file,
directory: 'games', directory: 'topics',
width: 512, width: 512,
height: 512, height: 512,
fit: 'inside', fit: 'inside',
@@ -593,7 +593,7 @@ async function createTemplateFromTierList({ tierList, templateId, templateName }
) )
} }
return { game: await findTopicById(templateId), items: createdItems } return { template: await findTopicById(templateId), items: createdItems }
} }
async function createTemplateFromRequest({ templateRequest, templateId, templateName }) { async function createTemplateFromRequest({ templateRequest, templateId, templateName }) {
@@ -609,7 +609,7 @@ async function createTemplateFromRequest({ templateRequest, templateId, template
templateId, templateId,
}) })
return { game: await findTopicById(templateId), items } return { template: await findTopicById(templateId), items }
} }
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => { router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
@@ -631,7 +631,7 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
} }
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' }) if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
if (target.linkedGames.length > 0) return res.status(409).json({ error: 'item_linked' }) if (target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' })
if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' }) if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
const items = await findCustomItemsByIds([target.id]) const items = await findCustomItemsByIds([target.id])

View File

@@ -24,7 +24,7 @@ const FREEFORM_TOPIC_ID = 'freeform'
const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기' const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기'
function normalizePoolItem(item) { function normalizePoolItem(item) {
if (!item || item.origin !== 'game' || typeof item.src !== 'string') return item if (!item || !['game', 'template'].includes(item.origin) || typeof item.src !== 'string') return item
if (item.src.startsWith('/uploads/')) return item if (item.src.startsWith('/uploads/')) return item
try { try {
@@ -83,7 +83,7 @@ const templateRequestSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
src: z.string().min(1), src: z.string().min(1),
label: z.string().min(1).max(60), label: z.string().min(1).max(60),
origin: z.enum(['game', 'custom']).default('game'), origin: z.enum(['template', 'game', 'custom']).default('template'),
}) })
), ),
}) })
@@ -112,7 +112,7 @@ const tierListUpsertSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
src: z.string().min(1), src: z.string().min(1),
label: z.string().min(1).max(60), label: z.string().min(1).max(60),
origin: z.enum(['game', 'custom']).default('game'), origin: z.enum(['template', 'game', 'custom']).default('template'),
}) })
), ),
}).superRefine((value, ctx) => { }).superRefine((value, ctx) => {

View File

@@ -1,5 +1,21 @@
# 의사결정 이력 # 의사결정 이력
## 2026-04-02 v1.4.30
- 로컬 MariaDB는 테스트용으로 새로 밀어도 된다는 전제가 확인되었으므로, 개발 환경에서는 기존 데이터를 끌고 가는 것보다 현재 스키마가 “빈 DB에서 바로 정상 부팅되는지”를 먼저 검증하는 편이 더 가치 있다고 판단했다.
- `origin: 'game'` 호환층은 즉시 제거하기보다, `ensureData()`에서 저장 데이터와 요청 스냅샷을 자동 정규화하게 만들어 두고 새 DB에서도 legacy 값이 생기지 않는 상태를 먼저 확인한 뒤 다음 제거 판단으로 넘기는 편이 가장 안전하다고 정리했다.
## 2026-04-02 v1.4.29
- `origin: 'game'`는 이미 저장된 티어표 데이터와 직접 맞물리므로, 이 단계에서는 새 데이터 기본값만 `template`로 옮기고 예전 값도 계속 받아주는 점진 호환이 가장 안전하다고 판단했다.
- 아이템 라이브러리의 `linkedGames`는 실제 의미가 템플릿 연결 정보이므로, 이 응답 키까지 `linkedTemplates`로 바꿔두는 편이 이후 관리자 유지보수에서 훨씬 덜 헷갈린다고 정리했다.
## 2026-04-02 v1.4.28
- 이 시점 이후 코드 검색에 남는 `game`는 대부분 레거시 데이터 마이그레이션, 옛 주소 redirect, 저장 데이터의 `origin` 호환처럼 의도된 층이므로, 무리하게 전부 0으로 만들기보다 기능을 깨뜨리지 않는 선에서 의미 있는 이름층만 더 줄이는 편이 맞다고 판단했다.
- 관리자 화면 내부 상태명(`selectedTemplate.game`, `isGameLoading`, `gameVisibilitySaving`)은 실제 기능 의미와 어긋나므로, QA 전에 한 번 더 `template` 기준으로 옮겨두는 편이 이후 유지보수에 더 유리하다고 정리했다.
## 2026-04-02 v1.4.27
- 공개/관리자 API 표면까지 `topic/template`로 정리된 뒤에는, 관리자 내부 상태 이름과 DB export alias에 남은 `game` 흔적도 계속 유지할 이유가 작아졌으므로 이 단계에서 함께 걷어내는 편이 맞다고 판단했다.
- 다만 외부에서 직접 참조할 수 있는 공개 북마크와 달리, `adminGames`, `game-admin`, `favoriteGame` 같은 이름은 내부 구현 용어라서 이번 단계에서 정리해도 위험이 낮다고 정리했다.
## 2026-04-02 v1.4.26 ## 2026-04-02 v1.4.26
- `topic/template` 표면을 거의 마감한 시점에서는 관리자 API와 관리자 화면 경로까지 계속 `/games` alias를 유지하는 편보다, 실제 사용하는 `templates` 경로만 남기고 예전 관리자 주소는 redirect로만 정리하는 편이 더 일관되고 안전하다고 판단했다. - `topic/template` 표면을 거의 마감한 시점에서는 관리자 API와 관리자 화면 경로까지 계속 `/games` alias를 유지하는 편보다, 실제 사용하는 `templates` 경로만 남기고 예전 관리자 주소는 redirect로만 정리하는 편이 더 일관되고 안전하다고 판단했다.
- 공개 사용자 북마크는 여전히 `/games -> /topics` redirect가 필요하지만, 백엔드 API의 `/api/games`까지 계속 유지할 이유는 작아졌으므로 이 단계에서 `/api/topics`만 남기는 편이 맞다고 정리했다. - 공개 사용자 북마크는 여전히 `/games -> /topics` redirect가 필요하지만, 백엔드 API의 `/api/games`까지 계속 유지할 이유는 작아졌으므로 이 단계에서 `/api/topics`만 남기는 편이 맞다고 정리했다.

View File

@@ -1,6 +1,15 @@
# 할 일 및 이슈 # 할 일 및 이슈
## 단기 확인 ## 단기 확인
- `v1.4.30`에서 빈 로컬 MariaDB 재초기화 검증까지 통과했으므로, 다음 실제 QA에서는 “기존 데이터가 있는 환경”에서 `ensureData()`가 저장 티어표와 템플릿 요청 스냅샷의 legacy origin을 정상 정규화하는지만 추가 확인하면 된다.
- 개발 환경 기준으로는 새 DB에서 `legacyTierItems=0`, `legacyRequestItems=0`가 확인됐으므로, 이후에는 `origin: 'game'` 호환 코드를 언제 완전히 제거할지 운영 데이터 기준으로만 판단하면 된다.
- `v1.4.29`에서 새 티어표 데이터 기본 origin을 `template`로 바꿨으므로, 저장 후 다시 열기/복사/요청 생성/관리자 가져오기 흐름에서 예전 데이터와 새 데이터가 함께 섞여도 정상 동작하는지 한 번 더 확인한다.
- 관리자 아이템 라이브러리 응답 키가 `linkedTemplates`로 정리됐으므로, 사용자 업로드 이미지 삭제 차단과 템플릿 이동 모달이 그대로 정상 동작하는지 확인한다.
- 현재 남아 있는 `game`는 레거시 redirect, DB 마이그레이션, 호환용 origin만 남겨둔 상태이므로, `v1.4` QA 후에는 이 레거시 층을 언제 제거할지 별도 마감 판단만 하면 된다.
- `v1.4.28`에서 관리자 템플릿 상세 상태와 기본 아이템 정렬 상태 이름을 `template` 기준으로 더 정리했으므로, 관리자 템플릿 선택/공개 전환/기본 아이템 정렬 저장이 그대로 정상인지 한 번 더 확인한다.
- 새 템플릿 썸네일/기본 아이템 업로드는 이제 `topics` 디렉터리로 저장되므로, 실제 업로드 후 최적화 작업 분류와 관리자 최근 작업 표시가 자연스럽게 보이는지 확인한다.
- 현재 코드 검색에 남는 `game`는 레거시 redirect, DB 마이그레이션, `origin: 'game'` 호환이 중심이므로, 이 층까지 실제로 없앨지 여부는 `v1.4` QA 후 안정성 기준으로 다시 판단한다.
- `v1.4.27`에서 관리자 내부 탭/라우트 이름과 DB alias export까지 더 정리했으므로, 관리자 템플릿 탭 이동, 커스텀 아이템에서 템플릿 관리로 점프, 템플릿 요청 확인하기 이동이 모두 정상인지 한 번 더 확인한다.
- `v1.4.26`에서 관리자 기본 경로를 `/admin/templates`로 바꾸고 `/api/admin/templates`만 남겼으므로, 관리자 진입/새로고침/뒤로가기와 템플릿 생성·썸네일 업로드·아이템 추가가 모두 정상인지 확인한다. - `v1.4.26`에서 관리자 기본 경로를 `/admin/templates`로 바꾸고 `/api/admin/templates`만 남겼으므로, 관리자 진입/새로고침/뒤로가기와 템플릿 생성·썸네일 업로드·아이템 추가가 모두 정상인지 확인한다.
- `v1.4.26`에서 공개 API `/api/games`를 제거했으므로, 실제 서버 재시작 후 홈/주제 상세/티어표 편집기에서 `/api/topics`만으로 모두 정상 동작하는지 확인한다. - `v1.4.26`에서 공개 API `/api/games`를 제거했으므로, 실제 서버 재시작 후 홈/주제 상세/티어표 편집기에서 `/api/topics`만으로 모두 정상 동작하는지 확인한다.
- 오래된 관리자 주소 `/admin/games`는 redirect만 남겼으므로, 북마크로 직접 진입해도 `/admin/templates`로 자연스럽게 바뀌는지 본다. - 오래된 관리자 주소 `/admin/games`는 redirect만 남겼으므로, 북마크로 직접 진입해도 `/admin/templates`로 자연스럽게 바뀌는지 본다.
@@ -33,7 +42,6 @@
- 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다. - 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다.
- 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 API 이름층으로 바뀐 뒤에도 저장, 즐겨찾기, 템플릿 생성, 아이템 정렬 흐름이 자연스러운지 한 번 더 QA한다. - 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 API 이름층으로 바뀐 뒤에도 저장, 즐겨찾기, 템플릿 생성, 아이템 정렬 흐름이 자연스러운지 한 번 더 QA한다.
- `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다. - `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다.
- 다음 단계에서는 `api.getGame`, `listGames`, `favoriteGame`처럼 남아 있는 프런트 API 이름을 어느 수준까지 `topic/template` 의미로 감쌀지 정리한다.
- 경로 헬퍼를 사용자 주요 화면에 연결했으므로, 로그인 리다이렉트·공유 프리뷰·복사본 이동·주제 복귀 흐름이 실제 브라우저에서 모두 같은 주소 체계로 자연스럽게 이어지는지 한 번 더 QA한다. - 경로 헬퍼를 사용자 주요 화면에 연결했으므로, 로그인 리다이렉트·공유 프리뷰·복사본 이동·주제 복귀 흐름이 실제 브라우저에서 모두 같은 주소 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
- 다음 단계에서는 `router/index.js``gameHub`, `route.params.gameId`, `api.getGame`처럼 남아 있는 프런트 내부 이름도 어디까지 `topic/template` 의미로 감쌀지 범위를 더 좁힌다. - 다음 단계에서는 `router/index.js``gameHub`, `route.params.gameId`, `api.getGame`처럼 남아 있는 프런트 내부 이름도 어디까지 `topic/template` 의미로 감쌀지 범위를 더 좁힌다.
- 주제 상세 화면 제목은 ID fallback 대신 로딩 문구를 쓰도록 바꿨으므로, 직접 진입·뒤로가기·다른 주제로 연속 이동할 때 헤더가 깜빡이거나 이전 제목을 잠깐 유지하지 않는지 한 번 더 QA한다. - 주제 상세 화면 제목은 ID fallback 대신 로딩 문구를 쓰도록 바꿨으므로, 직접 진입·뒤로가기·다른 주제로 연속 이동할 때 헤더가 깜빡이거나 이전 제목을 잠깐 유지하지 않는지 한 번 더 QA한다.

View File

@@ -1,5 +1,25 @@
# 업데이트 로그 # 업데이트 로그
## 2026-04-02 v1.4.30
- `ensureData()` 단계에서 저장된 티어표 `pool_json`과 템플릿 요청 스냅샷(`items_json`, `board_items_json`) 안에 남아 있을 수 있는 `origin: 'game'` 값을 자동으로 `template`로 정리하도록 보강했다.
- 로컬 MariaDB를 비운 뒤 현재 스키마로 다시 올리는 검증도 함께 진행했고, 새 DB 기준으로 `topics=3`, `tierlists=0`, `legacyTierItems=0`, `legacyRequestItems=0` 상태까지 확인했다.
- 즉 현재 개발 환경에서는 새로 생성되거나 다시 초기화한 데이터에 `game` 기반 origin이 남지 않으며, 남은 `game` 코드는 레거시 redirect·DB 마이그레이션 감지·과거 데이터 호환층만 담당하게 됐다.
## 2026-04-02 v1.4.29
- 티어표 저장/request schema는 이제 새 데이터에서 `origin: 'template'`를 기본으로 쓰고, 예전 `origin: 'game'`도 계속 읽을 수 있게 호환 레이어를 남겼다.
- 관리자 아이템 라이브러리의 템플릿 연결 정보도 `linkedTemplates` 기준으로 정리해, 내부 응답/프런트 상태에 남아 있던 `linkedGames` 흔적을 제거했다.
- 현재 `game` 검색에 남는 것은 레거시 주소 redirect, DB 마이그레이션용 legacy 테이블/컬럼명, 과거 저장 데이터 호환용 `origin: 'game'`처럼 의도적으로 남겨둔 층만 남도록 정리했다.
## 2026-04-02 v1.4.28
- 관리자 템플릿 상세 상태(`selectedTemplate.game`)와 관련 응답 키를 `template` 기준으로 정리해, 내부 코드 검색에서 남던 `game` 흔적을 더 줄였다.
- 관리자 기본 아이템 정렬/로딩 상태 이름도 `templateItem*`, `isTemplateLoading`, `templateVisibilitySaving` 기준으로 바꾸고, 새 템플릿 자산 업로드는 `topics` 디렉터리로 저장되게 맞췄다.
- 현재 코드 검색에서 남는 `game`는 주로 레거시 주소 redirect(`/games/:gameId`), DB 마이그레이션용 legacy 테이블/컬럼명, 기존 저장 데이터와 맞춘 `origin: 'game'` 값처럼 의도적으로 남겨둔 호환층만 남도록 정리했다.
## 2026-04-02 v1.4.27
- 관리자 내부 탭/라우트 이름도 `template-admin`, `adminTemplates`, `/admin/templates` 기준으로 더 정리해, 화면 상태값과 라우트 이름에 남아 있던 `game-admin`, `adminGames` 흔적을 줄였다.
- 더 이상 참조되지 않는 DB alias export(`listGames`, `createGame`, `favoriteGame` 등)와 `updateTemplateRequestTargetGame` 별칭도 제거해, 백엔드 모듈 표면에서 남아 있던 레거시 `game` 이름층을 더 걷어냈다.
- 커스텀 아이템 모달 내부 클래스명도 `createTemplateButton` 기준으로 정리해, 관리자 코드 검색에서 남는 `createGame` 흔적을 줄였다.
## 2026-04-02 v1.4.26 ## 2026-04-02 v1.4.26
- 관리자 API 레거시 `/games` alias를 걷어내고 `POST /api/admin/templates`, `.../templates/:templateId/...`만 남기도록 정리했다. 관리자 템플릿 연결/가져오기 액션도 `link-template`, `create-template` path로 바꿨다. - 관리자 API 레거시 `/games` alias를 걷어내고 `POST /api/admin/templates`, `.../templates/:templateId/...`만 남기도록 정리했다. 관리자 템플릿 연결/가져오기 액션도 `link-template`, `create-template` path로 바꿨다.
- 백엔드 공개 주제 라우트도 이제 `/api/topics`만 마운트하고, 오래된 `/api/games` 경로는 제거했다. 관리자 화면 URL 역시 `/admin/games` 대신 `/admin/templates`를 기본 경로로 쓰고, 예전 주소는 redirect만 남겼다. - 백엔드 공개 주제 라우트도 이제 `/api/topics`만 마운트하고, 오래된 `/api/games` 경로는 제거했다. 관리자 화면 URL 역시 `/admin/games` 대신 `/admin/templates`를 기본 경로로 쓰고, 예전 주소는 redirect만 남겼다.

View File

@@ -76,7 +76,7 @@ const showRightRailAction = computed(() => false)
const showSettingsGuideButton = computed(() => route.name === 'profile') const showSettingsGuideButton = computed(() => route.name === 'profile')
const guideSteps = [ const guideSteps = [
{ {
id: 'select-game', id: 'select-topic',
title: '주제 또는 양식 선택', title: '주제 또는 양식 선택',
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.', summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
description: description:

View File

@@ -9,12 +9,12 @@ const props = defineProps({
stagedRequestDraftCount: { type: Number, required: true }, stagedRequestDraftCount: { type: Number, required: true },
appliedRequestItemCount: { type: Number, required: true }, appliedRequestItemCount: { type: Number, required: true },
openTemplateCreateModal: { type: Function, required: true }, openTemplateCreateModal: { type: Function, required: true },
isGameLoading: { type: Boolean, required: true }, isTemplateLoading: { type: Boolean, required: true },
hasSelectedTemplate: { type: Boolean, required: true }, hasSelectedTemplate: { type: Boolean, required: true },
selectedTemplate: { type: Object, default: null }, selectedTemplate: { type: Object, default: null },
displayThumbnailUrl: { type: String, default: '' }, displayThumbnailUrl: { type: String, default: '' },
canApplyThumbnail: { type: Boolean, required: true }, canApplyThumbnail: { type: Boolean, required: true },
gameVisibilitySaving: { type: Boolean, required: true }, templateVisibilitySaving: { type: Boolean, required: true },
thumbFileInputRef: { type: Function, required: true }, thumbFileInputRef: { type: Function, required: true },
openThumbFilePicker: { type: Function, required: true }, openThumbFilePicker: { type: Function, required: true },
onThumb: { type: Function, required: true }, onThumb: { type: Function, required: true },
@@ -41,14 +41,14 @@ const props = defineProps({
removeUploadDraft: { type: Function, required: true }, removeUploadDraft: { type: Function, required: true },
hasTemplateItemOrderChanges: { type: Boolean, required: true }, hasTemplateItemOrderChanges: { type: Boolean, required: true },
saveTemplateItemOrder: { type: Function, required: true }, saveTemplateItemOrder: { type: Function, required: true },
gameItemListRef: { type: Function, required: true }, templateItemListRef: { type: Function, required: true },
saveTemplateItemLabel: { type: Function, required: true }, saveTemplateItemLabel: { type: Function, required: true },
removeTemplateItem: { type: Function, required: true }, removeTemplateItem: { type: Function, required: true },
selectedTemplateId: { type: String, default: '' }, selectedTemplateId: { type: String, default: '' },
}) })
function setGameItemListElement(el) { function setGameItemListElement(el) {
props.gameItemListRef(el) props.templateItemListRef(el)
} }
function setThumbFileElement(el) { function setThumbFileElement(el) {
@@ -102,7 +102,7 @@ function setThumbFileElement(el) {
</div> </div>
</div> </div>
<div v-if="props.isGameLoading" class="panel panel--empty"> <div v-if="props.isTemplateLoading" class="panel panel--empty">
<div class="emptyState"> <div class="emptyState">
<div class="emptyState__title">템플릿 정보를 불러오는 중이에요.</div> <div class="emptyState__title">템플릿 정보를 불러오는 중이에요.</div>
<div class="emptyState__desc">선택한 템플릿의 썸네일과 기본 아이템을 표시합니다.</div> <div class="emptyState__desc">선택한 템플릿의 썸네일과 기본 아이템을 표시합니다.</div>
@@ -122,7 +122,7 @@ function setThumbFileElement(el) {
@dragleave="props.onThumbDragLeave" @dragleave="props.onThumbDragLeave"
@drop="props.onThumbDrop" @drop="props.onThumbDrop"
> >
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.game.name" /> <img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.template.name" />
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div> <div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
<div class="thumbDropZone__copy"> <div class="thumbDropZone__copy">
<div v-if="!props.displayThumbnailUrl" class="thumbDropZone__iconWrap"> <div v-if="!props.displayThumbnailUrl" class="thumbDropZone__iconWrap">
@@ -134,10 +134,10 @@ function setThumbFileElement(el) {
</div> </div>
<div class="gameSettingsCard__body"> <div class="gameSettingsCard__body">
<div class="panel__title">템플릿 설정</div> <div class="panel__title">템플릿 설정</div>
<div class="gameSettingsCard__meta">{{ props.selectedTemplate.game.name }} · {{ props.selectedTemplate.game.id }}</div> <div class="gameSettingsCard__meta">{{ props.selectedTemplate.template.name }} · {{ props.selectedTemplate.template.id }}</div>
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.gameVisibilitySaving }"> <label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.templateVisibilitySaving }">
<input :checked="!!props.selectedTemplate.game.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" /> <input :checked="!!props.selectedTemplate.template.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" />
<span class="toggleSwitch__label">{{ props.selectedTemplate.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span> <span class="toggleSwitch__label">{{ props.selectedTemplate.template.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span> <span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label> </label>
<div class="gameSettingsCard__actions"> <div class="gameSettingsCard__actions">
@@ -216,8 +216,8 @@ function setThumbFileElement(el) {
</div> </div>
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div> <div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
<div v-else :ref="setGameItemListElement" class="thumbGrid"> <div v-else :ref="setGameItemListElement" class="thumbGrid">
<div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-game-item-id="item.id"> <div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-template-item-id="item.id">
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" /> <img class="thumb thumb--template" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag /> <input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
<div class="thumbCard__actions"> <div class="thumbCard__actions">
<button <button

View File

@@ -85,7 +85,7 @@ export function useAdminCustomItems({
function openCustomItemDeleteModal(item) { function openCustomItemDeleteModal(item) {
if (!item) return if (!item) return
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) { if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.' error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return return
} }
@@ -100,7 +100,7 @@ export function useAdminCustomItems({
function jumpToTemplateAdmin(templateId) { function jumpToTemplateAdmin(templateId) {
if (!templateId) return if (!templateId) return
closeCustomItemModal() closeCustomItemModal()
setTab('game-admin') setTab('template-admin')
nextTick(() => { nextTick(() => {
selectAdminTemplate(templateId) selectAdminTemplate(templateId)
}) })
@@ -109,7 +109,7 @@ export function useAdminCustomItems({
async function removeCustomItem(item = modalTargetCustomItem.value) { async function removeCustomItem(item = modalTargetCustomItem.value) {
resetMessages() resetMessages()
if (!item) return if (!item) return
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) { if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.' error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return return
} }

View File

@@ -11,10 +11,10 @@ export function useAdminGameManager({
thumbFile, thumbFile,
itemPreviewUrls, itemPreviewUrls,
itemFileInput, itemFileInput,
gameItemListEl, templateItemListEl,
gameItemSortable, templateItemSortable,
savedGameItemOrderIds, savedTemplateItemOrderIds,
isGameLoading, isTemplateLoading,
activeTemplateRequest, activeTemplateRequest,
templateRequests, templateRequests,
customItemModalOpen, customItemModalOpen,
@@ -49,21 +49,21 @@ export function useAdminGameManager({
return src.split('/').pop() || item.file?.name || 'item' return src.split('/').pop() || item.file?.name || 'item'
} }
function destroyGameItemSortable() { function destroyTemplateItemSortable() {
if (gameItemSortable.value) { if (templateItemSortable.value) {
gameItemSortable.value.destroy() templateItemSortable.value.destroy()
gameItemSortable.value = null templateItemSortable.value = null
} }
} }
async function syncGameItemSortable() { async function syncTemplateItemSortable() {
await nextTick() await nextTick()
destroyGameItemSortable() destroyTemplateItemSortable()
if (!gameItemListEl.value || !selectedTemplate.value?.items?.length) return if (!templateItemListEl.value || !selectedTemplate.value?.items?.length) return
gameItemSortable.value = Sortable.create(gameItemListEl.value, { templateItemSortable.value = Sortable.create(templateItemListEl.value, {
animation: 160, animation: 160,
draggable: '[data-game-item-id]', draggable: '[data-template-item-id]',
forceFallback: true, forceFallback: true,
fallbackOnBody: false, fallbackOnBody: false,
filter: '[data-no-drag]', filter: '[data-no-drag]',
@@ -124,31 +124,30 @@ export function useAdminGameManager({
if (!selectedTemplateId.value) { if (!selectedTemplateId.value) {
selectedTemplate.value = null selectedTemplate.value = null
savedGameItemOrderIds.value = [] savedTemplateItemOrderIds.value = []
destroyGameItemSortable() destroyTemplateItemSortable()
return return
} }
try { try {
isGameLoading.value = true isTemplateLoading.value = true
const data = await api.getTopic(selectedTemplateId.value) const data = await api.getTopic(selectedTemplateId.value)
const loadedTemplate = data.template || data.topic || null const loadedTemplate = data.template || data.topic || null
selectedTemplate.value = { selectedTemplate.value = {
...data, ...data,
game: loadedTemplate,
template: loadedTemplate, template: loadedTemplate,
items: (data.items || []).map((item) => ({ items: (data.items || []).map((item) => ({
...item, ...item,
draftLabel: item.label, draftLabel: item.label,
})), })),
} }
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id) savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncGameItemSortable() await syncTemplateItemSortable()
} catch (e) { } catch (e) {
selectedTemplate.value = null selectedTemplate.value = null
error.value = '템플릿 정보를 불러오지 못했어요.' error.value = '템플릿 정보를 불러오지 못했어요.'
} finally { } finally {
isGameLoading.value = false isTemplateLoading.value = false
} }
} }
@@ -350,8 +349,8 @@ export function useAdminGameManager({
draftLabel: item.label, draftLabel: item.label,
})), })),
} }
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id) savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncGameItemSortable() await syncTemplateItemSortable()
success.value = '기본 아이템 순서를 저장했어요.' success.value = '기본 아이템 순서를 저장했어요.'
} catch (e) { } catch (e) {
error.value = '기본 아이템 순서 저장에 실패했어요.' error.value = '기본 아이템 순서 저장에 실패했어요.'
@@ -360,8 +359,8 @@ export function useAdminGameManager({
return { return {
requestItemFilename, requestItemFilename,
destroyGameItemSortable, destroyTemplateItemSortable,
syncGameItemSortable, syncTemplateItemSortable,
mergeRequestItemsIntoDrafts, mergeRequestItemsIntoDrafts,
removeUploadDraft, removeUploadDraft,
loadTemplate, loadTemplate,

View File

@@ -62,7 +62,7 @@ export function useAdminTemplateRequests({
Object.assign(request, syncedRequest) Object.assign(request, syncedRequest)
request.status = syncedRequest.status || 'reviewing' request.status = syncedRequest.status || 'reviewing'
updateActiveTemplateRequest(syncedRequest) updateActiveTemplateRequest(syncedRequest)
setTab('game-admin') setTab('template-admin')
if (request.type === 'create') { if (request.type === 'create') {
const linkedTopicId = syncedRequest.targetTopicId || '' const linkedTopicId = syncedRequest.targetTopicId || ''

View File

@@ -27,7 +27,7 @@ export function createRouter() {
{ path: '/admin', redirect: '/admin/featured' }, { path: '/admin', redirect: '/admin/featured' },
{ path: '/admin/featured', name: 'adminFeatured', component: AdminView }, { path: '/admin/featured', name: 'adminFeatured', component: AdminView },
{ path: '/admin/games', redirect: '/admin/templates' }, { path: '/admin/games', redirect: '/admin/templates' },
{ path: '/admin/templates', name: 'adminGames', component: AdminView }, { path: '/admin/templates', name: 'adminTemplates', component: AdminView },
{ path: '/admin/items', name: 'adminItems', component: AdminView }, { path: '/admin/items', name: 'adminItems', component: AdminView },
{ path: '/admin/tierlists', name: 'adminTierlists', component: AdminView }, { path: '/admin/tierlists', name: 'adminTierlists', component: AdminView },
{ path: '/admin/users', name: 'adminUsers', component: AdminView }, { path: '/admin/users', name: 'adminUsers', component: AdminView },

View File

@@ -36,7 +36,7 @@ const selectedTemplateId = ref('')
const selectedTemplate = ref(null) const selectedTemplate = ref(null)
const featuredTemplateIds = ref([]) const featuredTemplateIds = ref([])
const templatePickerModalOpen = ref(false) const templatePickerModalOpen = ref(false)
const templatePickerMode = ref('game-admin') const templatePickerMode = ref('template-admin')
const templatePickerQuery = ref('') const templatePickerQuery = ref('')
const templatePickerSort = ref('recent') const templatePickerSort = ref('recent')
@@ -109,7 +109,7 @@ const success = ref('')
const newTemplateId = ref('') const newTemplateId = ref('')
const newTemplateName = ref('') const newTemplateName = ref('')
const newTemplateIsPublic = ref(false) const newTemplateIsPublic = ref(false)
const gameVisibilitySaving = ref(false) const templateVisibilitySaving = ref(false)
const uploadFiles = ref([]) const uploadFiles = ref([])
const uploadItemDrafts = ref([]) const uploadItemDrafts = ref([])
@@ -122,12 +122,12 @@ const itemFileInput = ref(null)
const thumbFileInput = ref(null) const thumbFileInput = ref(null)
const featuredListEl = ref(null) const featuredListEl = ref(null)
const featuredSortable = ref(null) const featuredSortable = ref(null)
const gameItemListEl = ref(null) const templateItemListEl = ref(null)
const gameItemSortable = ref(null) const templateItemSortable = ref(null)
let gameItemSortableSyncTimer = null let templateItemSortableSyncTimer = null
const savedGameItemOrderIds = ref([]) const savedTemplateItemOrderIds = ref([])
const userAvatarInputs = ref({}) const userAvatarInputs = ref({})
const isGameLoading = ref(false) const isTemplateLoading = ref(false)
const templateCreateModalOpen = ref(false) const templateCreateModalOpen = ref(false)
const previousBodyOverflow = ref('') const previousBodyOverflow = ref('')
@@ -144,20 +144,20 @@ function setThumbFileInputRef(el) {
} }
function scheduleGameItemSortableSync() { function scheduleGameItemSortableSync() {
if (gameItemSortableSyncTimer) { if (templateItemSortableSyncTimer) {
clearTimeout(gameItemSortableSyncTimer) clearTimeout(templateItemSortableSyncTimer)
gameItemSortableSyncTimer = null templateItemSortableSyncTimer = null
} }
if (!gameItemListEl.value || !selectedTemplate.value?.items?.length) return if (!templateItemListEl.value || !selectedTemplate.value?.items?.length) return
gameItemSortableSyncTimer = setTimeout(() => { templateItemSortableSyncTimer = setTimeout(() => {
gameItemSortableSyncTimer = null templateItemSortableSyncTimer = null
syncGameItemSortable() syncTemplateItemSortable()
}, 0) }, 0)
} }
function setGameItemListRef(el) { function setGameItemListRef(el) {
gameItemListEl.value = el templateItemListEl.value = el
if (!el) return if (!el) return
scheduleGameItemSortableSync() scheduleGameItemSortableSync()
} }
@@ -175,7 +175,7 @@ function normalizeAdminSrc(src) {
} }
} }
const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.game?.id) const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.template?.id)
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value) const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value)
const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedTemplateId.value) const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedTemplateId.value)
const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length) const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length)
@@ -188,7 +188,7 @@ const appliedRequestItemCount = computed(() => {
}) })
const hasTemplateItemOrderChanges = computed(() => { const hasTemplateItemOrderChanges = computed(() => {
const currentIds = (selectedTemplate.value?.items || []).map((item) => item.id) const currentIds = (selectedTemplate.value?.items || []).map((item) => item.id)
return currentIds.join('|') !== savedGameItemOrderIds.value.join('|') return currentIds.join('|') !== savedTemplateItemOrderIds.value.join('|')
}) })
const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value))) const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value)))
const adminTierListPageCount = computed(() => Math.max(1, Math.ceil(adminTierListTotal.value / adminTierListLimit.value))) const adminTierListPageCount = computed(() => Math.max(1, Math.ceil(adminTierListTotal.value / adminTierListLimit.value)))
@@ -215,7 +215,7 @@ const customItemTargetTemplate = computed(() => templates.value.find((template)
const importModalItemCount = computed(() => importModalItems.value.length) const importModalItemCount = computed(() => importModalItems.value.length)
const activeTabTitle = computed(() => { const activeTabTitle = computed(() => {
if (activeTab.value === 'featured') return '목록 관리' if (activeTab.value === 'featured') return '목록 관리'
if (activeTab.value === 'game-admin') return '템플릿 관리' if (activeTab.value === 'template-admin') return '템플릿 관리'
if (activeTab.value === 'items') return '아이템 관리' if (activeTab.value === 'items') return '아이템 관리'
if (activeTab.value === 'tierlists') { if (activeTab.value === 'tierlists') {
return tierlistsMode.value === 'requests' ? '템플릿 요청 관리' : '전체 티어표 관리' return tierlistsMode.value === 'requests' ? '템플릿 요청 관리' : '전체 티어표 관리'
@@ -226,7 +226,7 @@ const activeTabDescription = computed(() => {
if (activeTab.value === 'featured') { if (activeTab.value === 'featured') {
return '홈 화면 상단에 고정 노출되는 템플릿 순서를 따로 관리합니다.' return '홈 화면 상단에 고정 노출되는 템플릿 순서를 따로 관리합니다.'
} }
if (activeTab.value === 'game-admin') { if (activeTab.value === 'template-admin') {
return '템플릿 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.' return '템플릿 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
} }
if (activeTab.value === 'items') { if (activeTab.value === 'items') {
@@ -251,7 +251,7 @@ const adminOverviewStats = computed(() => {
{ label: '추가 가능', value: `${Math.max(0, 50 - featuredTemplateIds.value.length)}` }, { label: '추가 가능', value: `${Math.max(0, 50 - featuredTemplateIds.value.length)}` },
] ]
} }
if (activeTab.value === 'game-admin') { if (activeTab.value === 'template-admin') {
return [ return [
{ label: '전체 템플릿', value: `${templates.value.length}` }, { label: '전체 템플릿', value: `${templates.value.length}` },
{ label: '티어표 전체', value: `${selectedTemplateTierListStats.value.total || 0}` }, { label: '티어표 전체', value: `${selectedTemplateTierListStats.value.total || 0}` },
@@ -305,14 +305,14 @@ const isAnyModalOpen = computed(
) )
const adminRouteNameByTab = { const adminRouteNameByTab = {
featured: 'adminFeatured', featured: 'adminFeatured',
'game-admin': 'adminGames', 'template-admin': 'adminTemplates',
items: 'adminItems', items: 'adminItems',
tierlists: 'adminTierlists', tierlists: 'adminTierlists',
users: 'adminUsers', users: 'adminUsers',
} }
function tabFromAdminRoute(name) { function tabFromAdminRoute(name) {
if (name === 'adminGames') return 'game-admin' if (name === 'adminTemplates') return 'template-admin'
if (name === 'adminItems') return 'items' if (name === 'adminItems') return 'items'
if (name === 'adminTierlists') return 'tierlists' if (name === 'adminTierlists') return 'tierlists'
if (name === 'adminUsers') return 'users' if (name === 'adminUsers') return 'users'
@@ -375,12 +375,12 @@ onUnmounted(() => {
if (typeof document !== 'undefined') document.body.style.overflow = previousBodyOverflow.value || '' if (typeof document !== 'undefined') document.body.style.overflow = previousBodyOverflow.value || ''
clearPreviewUrl('item') clearPreviewUrl('item')
clearPreviewUrl('thumb') clearPreviewUrl('thumb')
if (gameItemSortableSyncTimer) { if (templateItemSortableSyncTimer) {
clearTimeout(gameItemSortableSyncTimer) clearTimeout(templateItemSortableSyncTimer)
gameItemSortableSyncTimer = null templateItemSortableSyncTimer = null
} }
destroyFeaturedSortable() destroyFeaturedSortable()
destroyGameItemSortable() destroyTemplateItemSortable()
}) })
function clearPreviewUrl(kind) { function clearPreviewUrl(kind) {
@@ -423,7 +423,7 @@ watch(
() => route.name, () => route.name,
(name) => { (name) => {
activeTab.value = tabFromAdminRoute(name) activeTab.value = tabFromAdminRoute(name)
if (name === 'adminGames') { if (name === 'adminTemplates') {
const nextTopicId = typeof route.query.topicId === 'string' ? route.query.topicId : '' const nextTopicId = typeof route.query.topicId === 'string' ? route.query.topicId : ''
if (nextTopicId && nextTopicId !== selectedTemplateId.value) { if (nextTopicId && nextTopicId !== selectedTemplateId.value) {
selectedTemplateId.value = nextTopicId selectedTemplateId.value = nextTopicId
@@ -446,13 +446,13 @@ watch(
watch( watch(
() => selectedTemplateId.value, () => selectedTemplateId.value,
(templateId) => { (templateId) => {
if (route.name !== 'adminGames') return if (route.name !== 'adminTemplates') return
syncAdminRouteQuery({ topicId: templateId || undefined }) syncAdminRouteQuery({ topicId: templateId || undefined })
} }
) )
watch( watch(
() => selectedTemplate.value?.game?.id || '', () => selectedTemplate.value?.template?.id || '',
async (templateId) => { async (templateId) => {
await refreshSelectedTemplateTierListStats(templateId) await refreshSelectedTemplateTierListStats(templateId)
}, },
@@ -481,7 +481,7 @@ watch(
watch( watch(
() => activeTab.value, () => activeTab.value,
async (tab) => { async (tab) => {
if (tab === 'game-admin' && selectedTemplateId.value && !selectedTemplate.value?.game?.id) { if (tab === 'template-admin' && selectedTemplateId.value && !selectedTemplate.value?.template?.id) {
await loadTemplate() await loadTemplate()
return return
} }
@@ -524,7 +524,7 @@ watch(
) )
watch( watch(
() => [selectedTemplate.value?.game?.id || '', selectedTemplate.value?.items?.length || 0, !!gameItemListEl.value], () => [selectedTemplate.value?.template?.id || '', selectedTemplate.value?.items?.length || 0, !!templateItemListEl.value],
([templateId, itemCount, hasListEl]) => { ([templateId, itemCount, hasListEl]) => {
if (!templateId || !itemCount || !hasListEl) return if (!templateId || !itemCount || !hasListEl) return
scheduleGameItemSortableSync() scheduleGameItemSortableSync()
@@ -571,6 +571,7 @@ function formatImageJobSourceCategory(category) {
return '커스텀 아이템' return '커스텀 아이템'
case 'tierlists': case 'tierlists':
return '티어표 썸네일' return '티어표 썸네일'
case 'topics':
case 'games': case 'games':
return '주제/템플릿 이미지' return '주제/템플릿 이미지'
case 'avatars': case 'avatars':
@@ -619,7 +620,7 @@ const imageDiagnosticsCards = computed(() => {
] ]
}) })
const visibleLinkedTemplates = computed(() => const visibleLinkedTemplates = computed(() =>
(modalTargetCustomItem.value?.linkedGames || []).filter((template) => template?.id && template.id !== 'freeform') (modalTargetCustomItem.value?.linkedTemplates || []).filter((template) => template?.id && template.id !== 'freeform')
) )
const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean))) const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean)))
@@ -732,7 +733,7 @@ function setTab(tab) {
const nextRouteName = adminRouteNameByTab[tab] const nextRouteName = adminRouteNameByTab[tab]
if (nextRouteName && route.name !== nextRouteName) { if (nextRouteName && route.name !== nextRouteName) {
const nextQuery = const nextQuery =
tab === 'game-admin' tab === 'template-admin'
? { topicId: selectedTemplateId.value || undefined } ? { topicId: selectedTemplateId.value || undefined }
: tab === 'tierlists' && tierlistsMode.value === 'all' : tab === 'tierlists' && tierlistsMode.value === 'all'
? { mode: 'all' } ? { mode: 'all' }
@@ -931,8 +932,8 @@ const {
}) })
const { const {
destroyGameItemSortable, destroyTemplateItemSortable,
syncGameItemSortable, syncTemplateItemSortable,
mergeRequestItemsIntoDrafts, mergeRequestItemsIntoDrafts,
removeUploadDraft, removeUploadDraft,
loadTemplate, loadTemplate,
@@ -953,10 +954,10 @@ const {
thumbFile, thumbFile,
itemPreviewUrls, itemPreviewUrls,
itemFileInput, itemFileInput,
gameItemListEl, templateItemListEl,
gameItemSortable, templateItemSortable,
savedGameItemOrderIds, savedTemplateItemOrderIds,
isGameLoading, isTemplateLoading,
activeTemplateRequest, activeTemplateRequest,
templateRequests, templateRequests,
customItemModalOpen, customItemModalOpen,
@@ -1167,17 +1168,17 @@ async function uploadThumbnail() {
} }
async function saveTemplateVisibility() { async function saveTemplateVisibility() {
if (!selectedTemplate.value?.game?.id) return if (!selectedTemplate.value?.template?.id) return
try { try {
gameVisibilitySaving.value = true templateVisibilitySaving.value = true
const data = await api.updateAdminTemplate(selectedTemplate.value.game.id, { const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, {
isPublic: !!selectedTemplate.value.game.isPublic, isPublic: !!selectedTemplate.value.template.isPublic,
}) })
const nextTemplate = data.template || {} const nextTemplate = data.template || {}
selectedTemplate.value = { selectedTemplate.value = {
...selectedTemplate.value, ...selectedTemplate.value,
game: { template: {
...selectedTemplate.value.game, ...selectedTemplate.value.template,
...nextTemplate, ...nextTemplate,
}, },
} }
@@ -1188,17 +1189,17 @@ async function saveTemplateVisibility() {
error.value = '템플릿 공개 상태를 저장하지 못했어요.' error.value = '템플릿 공개 상태를 저장하지 못했어요.'
return false return false
} finally { } finally {
gameVisibilitySaving.value = false templateVisibilitySaving.value = false
} }
} }
async function toggleSelectedTemplateVisibility(nextValue) { async function toggleSelectedTemplateVisibility(nextValue) {
if (!selectedTemplate.value?.game?.id || gameVisibilitySaving.value) return if (!selectedTemplate.value?.template?.id || templateVisibilitySaving.value) return
const previous = !!selectedTemplate.value.game.isPublic const previous = !!selectedTemplate.value.template.isPublic
selectedTemplate.value = { selectedTemplate.value = {
...selectedTemplate.value, ...selectedTemplate.value,
game: { template: {
...selectedTemplate.value.game, ...selectedTemplate.value.template,
isPublic: !!nextValue, isPublic: !!nextValue,
}, },
} }
@@ -1206,8 +1207,8 @@ async function toggleSelectedTemplateVisibility(nextValue) {
if (!saved) { if (!saved) {
selectedTemplate.value = { selectedTemplate.value = {
...selectedTemplate.value, ...selectedTemplate.value,
game: { template: {
...selectedTemplate.value.game, ...selectedTemplate.value.template,
isPublic: previous, isPublic: previous,
}, },
} }
@@ -1278,9 +1279,9 @@ async function saveTemplateItemLabel(item) {
async function removeTemplate() { async function removeTemplate() {
resetMessages() resetMessages()
if (!selectedTemplateId.value || !selectedTemplate.value?.game) return if (!selectedTemplateId.value || !selectedTemplate.value?.template) return
const ok = window.confirm(`"${selectedTemplate.value.game.name}" 템플릿을 삭제할까요? 관련 기본 아이템과 티어표도 함께 삭제됩니다.`) const ok = window.confirm(`"${selectedTemplate.value.template.name}" 템플릿을 삭제할까요? 관련 기본 아이템과 티어표도 함께 삭제됩니다.`)
if (!ok) return if (!ok) return
try { try {
@@ -1290,7 +1291,7 @@ async function removeTemplate() {
}) })
if (!res.ok) throw new Error('failed') if (!res.ok) throw new Error('failed')
const deletedName = selectedTemplate.value.game.name const deletedName = selectedTemplate.value.template.name
selectedTemplateId.value = '' selectedTemplateId.value = ''
selectedTemplate.value = null selectedTemplate.value = null
resetUploadState() resetUploadState()
@@ -1312,7 +1313,7 @@ function setAdminTierListGameId(topicId) {
refreshAdminTierLists() refreshAdminTierLists()
} }
function openTemplatePickerModal(mode = 'game-admin') { function openTemplatePickerModal(mode = 'template-admin') {
templatePickerMode.value = mode templatePickerMode.value = mode
templatePickerQuery.value = '' templatePickerQuery.value = ''
templatePickerSort.value = 'recent' templatePickerSort.value = 'recent'
@@ -1368,7 +1369,7 @@ function buildModalItemFromTierListItem(item, tierList) {
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'template' : 'user'), sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'template' : 'user'),
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템', sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList), ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
linkedGames: Array.isArray(matchedItem?.linkedGames) ? matchedItem.linkedGames : [], linkedTemplates: Array.isArray(matchedItem?.linkedTemplates) ? matchedItem.linkedTemplates : [],
usageCount: matchedItem?.usageCount || 0, usageCount: matchedItem?.usageCount || 0,
canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false, canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false,
isPromoting: false, isPromoting: false,
@@ -1432,7 +1433,7 @@ async function saveAdminTierListMeta() {
adminTierLists.value = adminTierLists.value.map((tierList) => (tierList.id === updated.id ? { ...tierList, ...updated } : tierList)) adminTierLists.value = adminTierLists.value.map((tierList) => (tierList.id === updated.id ? { ...tierList, ...updated } : tierList))
if (previewTierList.value?.id === updated.id) previewTierList.value = { ...previewTierList.value, ...updated } if (previewTierList.value?.id === updated.id) previewTierList.value = { ...previewTierList.value, ...updated }
modalTargetAdminTierList.value = updated modalTargetAdminTierList.value = updated
await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.game?.id || '')]) await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.template?.id || '')])
success.value = '티어표 정보를 수정했어요.' success.value = '티어표 정보를 수정했어요.'
closeAdminTierListManageModal() closeAdminTierListManageModal()
} catch (e) { } catch (e) {
@@ -1454,7 +1455,7 @@ async function deleteAdminTierListEntry() {
adminTierLists.value = adminTierLists.value.filter((tierList) => tierList.id !== modalTargetAdminTierList.value.id) adminTierLists.value = adminTierLists.value.filter((tierList) => tierList.id !== modalTargetAdminTierList.value.id)
adminTierListTotal.value = Math.max(0, adminTierListTotal.value - 1) adminTierListTotal.value = Math.max(0, adminTierListTotal.value - 1)
if (previewTierList.value?.id === modalTargetAdminTierList.value.id) previewTierList.value = null if (previewTierList.value?.id === modalTargetAdminTierList.value.id) previewTierList.value = null
await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.game?.id || '')]) await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.template?.id || '')])
success.value = '티어표를 삭제했어요.' success.value = '티어표를 삭제했어요.'
closeAdminTierListManageModal() closeAdminTierListManageModal()
if (!adminTierLists.value.length && adminTierListPage.value > 1) { if (!adminTierLists.value.length && adminTierListPage.value > 1) {
@@ -1641,7 +1642,7 @@ function templateRequestTargetLabel(request) {
const displayThumbnailUrl = computed(() => { const displayThumbnailUrl = computed(() => {
if (thumbPreviewUrl.value) return thumbPreviewUrl.value if (thumbPreviewUrl.value) return thumbPreviewUrl.value
if (selectedTemplate.value?.game?.thumbnailSrc) return toApiUrl(selectedTemplate.value.game.thumbnailSrc) if (selectedTemplate.value?.template?.thumbnailSrc) return toApiUrl(selectedTemplate.value.template.thumbnailSrc)
return '' return ''
}) })
@@ -1701,18 +1702,18 @@ function userAvatarFallback(user) {
/> />
<AdminGamesSection <AdminGamesSection
v-else-if="activeTab === 'game-admin'" v-else-if="activeTab === 'template-admin'"
:active-template-request="activeTemplateRequest" :active-template-request="activeTemplateRequest"
:template-request-source-url="templateRequestSourceUrl" :template-request-source-url="templateRequestSourceUrl"
:staged-request-draft-count="stagedRequestDraftCount" :staged-request-draft-count="stagedRequestDraftCount"
:applied-request-item-count="appliedRequestItemCount" :applied-request-item-count="appliedRequestItemCount"
:open-template-create-modal="openTemplateCreateModal" :open-template-create-modal="openTemplateCreateModal"
:is-game-loading="isGameLoading" :is-template-loading="isTemplateLoading"
:has-selected-template="hasSelectedTemplate" :has-selected-template="hasSelectedTemplate"
:selected-template="selectedTemplate" :selected-template="selectedTemplate"
:display-thumbnail-url="displayThumbnailUrl" :display-thumbnail-url="displayThumbnailUrl"
:can-apply-thumbnail="canApplyThumbnail" :can-apply-thumbnail="canApplyThumbnail"
:game-visibility-saving="gameVisibilitySaving" :template-visibility-saving="templateVisibilitySaving"
:thumb-file-input-ref="setThumbFileInputRef" :thumb-file-input-ref="setThumbFileInputRef"
:open-thumb-file-picker="openThumbFilePicker" :open-thumb-file-picker="openThumbFilePicker"
:on-thumb="onThumb" :on-thumb="onThumb"
@@ -1739,7 +1740,7 @@ function userAvatarFallback(user) {
:remove-upload-draft="removeUploadDraft" :remove-upload-draft="removeUploadDraft"
:has-template-item-order-changes="hasTemplateItemOrderChanges" :has-template-item-order-changes="hasTemplateItemOrderChanges"
:save-template-item-order="saveTemplateItemOrder" :save-template-item-order="saveTemplateItemOrder"
:game-item-list-ref="setGameItemListRef" :template-item-list-ref="setGameItemListRef"
:save-template-item-label="saveTemplateItemLabel" :save-template-item-label="saveTemplateItemLabel"
:remove-template-item="removeTemplateItem" :remove-template-item="removeTemplateItem"
:selected-template-id="selectedTemplateId" :selected-template-id="selectedTemplateId"
@@ -1823,7 +1824,7 @@ function userAvatarFallback(user) {
v-model="newTemplateId" v-model="newTemplateId"
class="field__input" class="field__input"
maxlength="120" maxlength="120"
placeholder="game id (영문/숫자)" placeholder="topic id (영문/숫자)"
@keydown.enter.prevent="createTemplate" @keydown.enter.prevent="createTemplate"
/> />
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newTemplateId.length }}/120</span> <span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newTemplateId.length }}/120</span>
@@ -1981,7 +1982,7 @@ function userAvatarFallback(user) {
</div> </div>
<div class="customItemModal__pickerActions"> <div class="customItemModal__pickerActions">
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button> <button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
<button class="btn btn--ghost btn--small customItemModal__createGameButton" type="button" @click="openTemplateCreateModal"> 템플릿 만들기</button> <button class="btn btn--ghost btn--small customItemModal__createTemplateButton" type="button" @click="openTemplateCreateModal"> 템플릿 만들기</button>
</div> </div>
</aside> </aside>
<div class="customItemModal__body"> <div class="customItemModal__body">
@@ -2223,24 +2224,24 @@ function userAvatarFallback(user) {
<div class="adminSidebar__label">Mode</div> <div class="adminSidebar__label">Mode</div>
<div class="adminSidebar__tabs"> <div class="adminSidebar__tabs">
<button class="tab" :class="{ 'tab--active': activeTab === 'featured' }" @click="setTab('featured')">목록 관리</button> <button class="tab" :class="{ 'tab--active': activeTab === 'featured' }" @click="setTab('featured')">목록 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'game-admin' }" @click="setTab('game-admin')">템플릿 관리</button> <button class="tab" :class="{ 'tab--active': activeTab === 'template-admin' }" @click="setTab('template-admin')">템플릿 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button> <button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button> <button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button> <button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
</div> </div>
</section> </section>
<section v-if="activeTab === 'game-admin'" class="adminSidebar__panel"> <section v-if="activeTab === 'template-admin'" class="adminSidebar__panel">
<div class="adminSidebar__label">Template</div> <div class="adminSidebar__label">Template</div>
<div class="adminSidebar__group"> <div class="adminSidebar__group">
<button class="btn btn--primary" @click="openTemplateCreateModal"> 템플릿 생성</button> <button class="btn btn--primary" @click="openTemplateCreateModal"> 템플릿 생성</button>
<button class="btn btn--ghost" @click="openTemplatePickerModal('game-admin')">템플릿 선택</button> <button class="btn btn--ghost" @click="openTemplatePickerModal('template-admin')">템플릿 선택</button>
<div v-if="selectedTemplate?.game" class="adminSelectionCard"> <div v-if="selectedTemplate?.template" class="adminSelectionCard">
<div class="adminSelectionCard__label">선택한 템플릿</div> <div class="adminSelectionCard__label">선택한 템플릿</div>
<div class="adminSelectionCard__title">{{ selectedTemplate.game.name }}</div> <div class="adminSelectionCard__title">{{ selectedTemplate.template.name }}</div>
<div class="adminSelectionCard__meta">{{ selectedTemplate.game.id }}</div> <div class="adminSelectionCard__meta">{{ selectedTemplate.template.id }}</div>
</div> </div>
<div v-if="selectedTemplateId && !hasSelectedTemplate && !isGameLoading" class="hint hint--tight">선택된 템플릿 ID: {{ selectedTemplateId }}</div> <div v-if="selectedTemplateId && !hasSelectedTemplate && !isTemplateLoading" class="hint hint--tight">선택된 템플릿 ID: {{ selectedTemplateId }}</div>
</div> </div>
</section> </section>
@@ -3330,7 +3331,7 @@ function userAvatarFallback(user) {
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
} }
.adminUiScope .thumb--game { .adminUiScope .thumb--template {
max-width: 150px; max-width: 150px;
margin: 0 auto; margin: 0 auto;
display: block; display: block;
@@ -3493,7 +3494,7 @@ function userAvatarFallback(user) {
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
.adminUiScope .customItemModal__createGameButton { .adminUiScope .customItemModal__createTemplateButton {
justify-self: start; justify-self: start;
} }
.adminUiScope .customItemModal__body { .adminUiScope .customItemModal__body {

View File

@@ -904,7 +904,7 @@ onMounted(() => {
id: img.id, id: img.id,
src: img.src, src: img.src,
label: img.label, label: img.label,
origin: 'game', origin: 'template',
})) }))
const map = {} const map = {}
base.forEach((it) => (map[it.id] = it)) base.forEach((it) => (map[it.id] = it))