릴리스: v1.2.59 관리자 아이템 모달 게임 표시 보강

This commit is contained in:
2026-03-31 15:07:57 +09:00
parent b5ec579e5d
commit a53b883162
3 changed files with 94 additions and 26 deletions

View File

@@ -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,

View File

@@ -1,5 +1,9 @@
# 업데이트 로그
## 2026-03-31 v1.2.59
- 관리자 아이템 상세 모달의 게임 선택을 전용 상태로 분리해 기본 선택값이 비어 있도록 바꾸고, 썸네일 아래에 배치해 정보/액션과 시각적으로 분리함.
- 커스텀 아이템이 실제로 사용 중인 게임 목록을 백엔드에서 함께 내려주고, 템플릿 요청 생성 폼에는 게임 ID와 게임 이름 라벨을 추가해 구분을 명확히 함.
## 2026-03-31 v1.2.58
- 관리자 아이템 관리 카드를 썸네일과 제목만 보이는 compact 카드로 줄여, 대량 업로드된 이미지도 훨씬 높은 밀도로 탐색할 수 있게 정리함.
- 카드 클릭 시 상세 정보를 모달로 열고 이미지 다운로드, 기본 템플릿 추가, 삭제를 모달 안에서 결정하는 흐름으로 바꿈.

View File

@@ -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() {
</div>
<div v-if="request.type === 'create'" class="templateRequestCard__form">
<input v-model="request.draftGameId" class="input" placeholder="새 게임 ID" />
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
<label class="templateRequestField">
<span class="templateRequestField__label">게임 ID</span>
<input v-model="request.draftGameId" class="input" placeholder="새 게임 ID" />
</label>
<label class="templateRequestField">
<span class="templateRequestField__label">게임 이름</span>
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
</label>
</div>
<div class="templateRequestCard__actions">
@@ -1679,7 +1682,23 @@ async function saveFeaturedOrder() {
<button class="btn btn--ghost btn--small" @click="closeCustomItemModal">닫기</button>
</div>
<div v-if="modalTargetCustomItem" class="customItemModal">
<img class="customItemModal__image" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
<div class="customItemModal__side">
<img class="customItemModal__image" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
<div class="customItemModal__selector">
<span class="customItemModal__label">기본 템플릿에 추가</span>
<select v-model="customItemModalTargetGameId" class="select">
<option value="">게임 선택</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
</select>
</div>
<div class="customItemModal__linked">
<span class="customItemModal__label">이미 사용 중인 게임</span>
<div v-if="modalTargetCustomItem.linkedGames?.length" class="customItemModal__chips">
<span v-for="game in modalTargetCustomItem.linkedGames" :key="game.id" class="pill">{{ game.name }}</span>
</div>
<div v-else class="hint hint--tight">아직 연결된 게임이 없어요.</div>
</div>
</div>
<div class="customItemModal__body">
<div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div>
<div class="customItemModal__metaList">
@@ -1688,15 +1707,9 @@ async function saveFeaturedOrder() {
<div class="customItemModal__metaRow"><span>사용 </span><strong>{{ modalTargetCustomItem.usageCount }} 티어표</strong></div>
<div class="customItemModal__metaRow"><span>등록일</span><strong>{{ fmt(modalTargetCustomItem.createdAt) }}</strong></div>
</div>
<div class="modalCard__form">
<select v-model="customItemTargetGameId" class="select">
<option value="">기본 템플릿에 추가할 게임 선택</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
</select>
</div>
<div class="customItemModal__actions">
<a class="btn btn--ghost" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a>
<button class="btn btn--ghost" :disabled="!customItemTargetGameId || modalTargetCustomItem.isPromoting" @click="promoteCustomItem(modalTargetCustomItem)">
<button class="btn btn--ghost" :disabled="!customItemModalTargetGameId || modalTargetCustomItem.isPromoting" @click="promoteCustomItem(modalTargetCustomItem)">
{{ modalTargetCustomItem.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
</button>
<button class="btn btn--danger" :disabled="modalTargetCustomItem.usageCount > 0" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
@@ -2524,10 +2537,15 @@ async function saveFeaturedOrder() {
}
.customItemModal {
display: grid;
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
grid-template-columns: minmax(200px, 240px) minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.customItemModal__side {
display: grid;
gap: 12px;
min-width: 0;
}
.customItemModal__image {
width: 100%;
aspect-ratio: 1 / 1;
@@ -2541,6 +2559,20 @@ async function saveFeaturedOrder() {
gap: 14px;
min-width: 0;
}
.customItemModal__selector,
.customItemModal__linked {
display: grid;
gap: 8px;
}
.customItemModal__label {
font-size: 11px;
color: rgba(255, 255, 255, 0.52);
}
.customItemModal__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.customItemModal__title {
font-size: 19px;
font-weight: 900;
@@ -2861,6 +2893,14 @@ async function saveFeaturedOrder() {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.templateRequestField {
display: grid;
gap: 6px;
}
.templateRequestField__label {
font-size: 11px;
color: rgba(255, 255, 255, 0.52);
}
.templateRequestCard__actions {
display: flex;
gap: 10px;