diff --git a/backend/src/db.js b/backend/src/db.js index 3314ba3..fe4d442 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -605,28 +605,51 @@ async function findCustomItemById(id) { } } -async function getCustomItemUsageMap() { - const rows = await query('SELECT groups_json, pool_json FROM tierlists') +async function getCustomItemUsageMeta() { + const rows = await query( + ` + SELECT t.game_id, g.name AS game_name, t.groups_json, t.pool_json + FROM tierlists t + LEFT JOIN games g ON g.id = t.game_id + ` + ) const usageMap = new Map() + const linkedGamesMap = new Map() rows.forEach((row) => { const groups = parseJson(row.groups_json, []) const pool = parseJson(row.pool_json, []) + const seenItemIds = new Set() groups.forEach((group) => { ;(group?.itemIds || []).forEach((itemId) => { usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1) + if (itemId) seenItemIds.add(itemId) }) }) pool.forEach((item) => { if (item?.id) { usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1) + seenItemIds.add(item.id) } }) + + if (!row.game_id) return + + seenItemIds.forEach((itemId) => { + if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map()) + linkedGamesMap.get(itemId).set(row.game_id, { + id: row.game_id, + name: row.game_name || row.game_id, + }) + }) }) - return usageMap + return { + usageMap, + linkedGamesMap: new Map(Array.from(linkedGamesMap.entries()).map(([itemId, gameMap]) => [itemId, Array.from(gameMap.values())])), + } } async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) { @@ -655,7 +678,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl params ) - const usageMap = await getCustomItemUsageMap() + const { usageMap, linkedGamesMap } = await getCustomItemUsageMeta() const allItems = rows .map((row) => ({ id: row.id, @@ -666,6 +689,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl ownerName: row.nickname || row.email, ownerEmail: row.email, usageCount: usageMap.get(row.id) || 0, + linkedGames: linkedGamesMap.get(row.id) || [], })) .filter((item) => (orphanOnly ? item.usageCount === 0 : true)) @@ -705,7 +729,7 @@ async function findUnusedCustomItems({ queryText = '' } = {}) { params ) - const usageMap = await getCustomItemUsageMap() + const { usageMap } = await getCustomItemUsageMeta() return rows .map((row) => ({ id: row.id, diff --git a/docs/update.md b/docs/update.md index 642c224..2a476c4 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,9 @@ # 업데이트 로그 +## 2026-03-31 v1.2.59 +- 관리자 아이템 상세 모달의 게임 선택을 전용 상태로 분리해 기본 선택값이 비어 있도록 바꾸고, 썸네일 아래에 배치해 정보/액션과 시각적으로 분리함. +- 커스텀 아이템이 실제로 사용 중인 게임 목록을 백엔드에서 함께 내려주고, 템플릿 요청 생성 폼에는 게임 ID와 게임 이름 라벨을 추가해 구분을 명확히 함. + ## 2026-03-31 v1.2.58 - 관리자 아이템 관리 카드를 썸네일과 제목만 보이는 compact 카드로 줄여, 대량 업로드된 이미지도 훨씬 높은 밀도로 탐색할 수 있게 정리함. - 카드 클릭 시 상세 정보를 모달로 열고 이미지 다운로드, 기본 템플릿 추가, 삭제를 모달 안에서 결정하는 흐름으로 바꿈. diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index e06bb02..46cc143 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -29,6 +29,7 @@ const customItemLimit = ref(50) const customItemTotal = ref(0) const customItemOrphanOnly = ref(false) const customItemTargetGameId = ref('') +const customItemModalTargetGameId = ref('') const adminTierLists = ref([]) const adminTierListQuery = ref('') @@ -230,9 +231,6 @@ function setTab(tab) { if (tab === 'tierlists') { tierlistsMode.value = 'requests' } - if (tab === 'items' && !customItemTargetGameId.value && games.value.length) { - customItemTargetGameId.value = games.value[0].id - } } function setTierlistsMode(mode) { @@ -260,9 +258,6 @@ async function refreshGames() { try { const data = await api.listGames() games.value = data.games || [] - if (!customItemTargetGameId.value && games.value.length) { - customItemTargetGameId.value = games.value[0].id - } featuredGameIds.value = games.value .filter((game) => game.displayRank != null) .sort((a, b) => a.displayRank - b.displayRank) @@ -860,12 +855,14 @@ function moveCustomItemPage(direction) { function openCustomItemModal(item) { modalTargetCustomItem.value = item || null + customItemModalTargetGameId.value = '' customItemModalOpen.value = true } function closeCustomItemModal() { customItemModalOpen.value = false modalTargetCustomItem.value = null + customItemModalTargetGameId.value = '' } function openCustomItemDeleteModal(item) { @@ -917,16 +914,16 @@ async function removeUnusedCustomItems() { async function promoteCustomItem(item) { resetMessages() - if (!customItemTargetGameId.value) { + if (!customItemModalTargetGameId.value) { error.value = '가져올 게임을 먼저 선택해주세요.' return } try { item.isPromoting = true - await api.promoteAdminCustomItem(item.id, { gameId: customItemTargetGameId.value }) - const targetGameName = games.value.find((game) => game.id === customItemTargetGameId.value)?.name || customItemTargetGameId.value - if (selectedGameId.value === customItemTargetGameId.value) await loadGame() + await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetGameId.value }) + const targetGameName = games.value.find((game) => game.id === customItemModalTargetGameId.value)?.name || customItemModalTargetGameId.value + if (selectedGameId.value === customItemModalTargetGameId.value) await loadGame() closeCustomItemModal() success.value = `"${item.label}" 아이템을 ${targetGameName} 기본 템플릿으로 추가했어요.` } catch (e) { @@ -1424,8 +1421,14 @@ async function saveFeaturedOrder() {