Compare commits

...

2 Commits

3 changed files with 234 additions and 86 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,13 @@
# 업데이트 로그
## 2026-03-31 v1.2.59
- 관리자 아이템 상세 모달의 게임 선택을 전용 상태로 분리해 기본 선택값이 비어 있도록 바꾸고, 썸네일 아래에 배치해 정보/액션과 시각적으로 분리함.
- 커스텀 아이템이 실제로 사용 중인 게임 목록을 백엔드에서 함께 내려주고, 템플릿 요청 생성 폼에는 게임 ID와 게임 이름 라벨을 추가해 구분을 명확히 함.
## 2026-03-31 v1.2.58
- 관리자 아이템 관리 카드를 썸네일과 제목만 보이는 compact 카드로 줄여, 대량 업로드된 이미지도 훨씬 높은 밀도로 탐색할 수 있게 정리함.
- 카드 클릭 시 상세 정보를 모달로 열고 이미지 다운로드, 기본 템플릿 추가, 삭제를 모달 안에서 결정하는 흐름으로 바꿈.
## 2026-03-31 v1.2.57
- 관리자 오른쪽 사이드에서 Featured, Game Summary, Users 패널을 완전히 제거하고, 티어표 요청 모드에는 모드 전환 탭만 남기도록 정리함.

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('')
@@ -48,9 +49,12 @@ const previewTierList = ref(null)
const userPasswordModalOpen = ref(false)
const userDeleteModalOpen = ref(false)
const userRoleModalOpen = ref(false)
const customItemModalOpen = ref(false)
const customItemDeleteModalOpen = ref(false)
const modalTargetUser = ref(null)
const modalPasswordDraft = ref('')
const modalRoleNextAdmin = ref(false)
const modalTargetCustomItem = ref(null)
const users = ref([])
@@ -227,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) {
@@ -257,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)
@@ -855,18 +853,44 @@ function moveCustomItemPage(direction) {
refreshCustomItems()
}
async function removeCustomItem(item) {
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) {
if (!item) return
if (item.usageCount > 0) {
error.value = '사용 중인 커스텀 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
modalTargetCustomItem.value = item
customItemDeleteModalOpen.value = true
}
function closeCustomItemDeleteModal() {
customItemDeleteModalOpen.value = false
}
async function removeCustomItem(item = modalTargetCustomItem.value) {
resetMessages()
if (!item) return
if (item.usageCount > 0) {
error.value = '사용 중인 커스텀 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
const ok = window.confirm(`"${item.label}" 미사용 커스텀 이미지를 삭제할까요?`)
if (!ok) return
try {
await api.deleteAdminCustomItem(item.id)
closeCustomItemDeleteModal()
closeCustomItemModal()
await refreshCustomItems()
success.value = '미사용 커스텀 이미지를 삭제했어요.'
} catch (e) {
@@ -890,16 +914,17 @@ 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) {
error.value = '커스텀 아이템을 기본 템플릿으로 가져오지 못했어요.'
@@ -1349,37 +1374,10 @@ async function saveFeaturedOrder() {
<div class="panel">
<div v-if="!customItems.length" class="hint">조건에 맞는 커스텀 아이템이 없어요.</div>
<div v-else class="customItemGrid">
<article v-for="item in customItems" :key="item.id" class="customItemCard">
<button v-for="item in customItems" :key="item.id" type="button" class="customItemCard" @click="openCustomItemModal(item)">
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="customItemCard__body">
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
<div class="customItemCard__metaList">
<div class="customItemCard__metaRow">
<span>파일</span>
<strong :title="item.src.split('/').pop()">{{ item.src.split('/').pop() }}</strong>
</div>
<div class="customItemCard__metaRow">
<span>업로더</span>
<strong :title="item.ownerName">{{ item.ownerName }}</strong>
</div>
<div class="customItemCard__metaRow">
<span>사용 </span>
<strong>{{ item.usageCount }} 티어표</strong>
</div>
<div class="customItemCard__metaRow">
<span>등록일</span>
<strong>{{ fmt(item.createdAt) }}</strong>
</div>
</div>
<div class="customItemCard__actions">
<a class="btn btn--small btn--ghost" :href="toApiUrl(item.src)" :download="item.label">이미지 다운로드</a>
<button class="btn btn--small btn--ghost" :disabled="!customItemTargetGameId || item.isPromoting" @click="promoteCustomItem(item)">
{{ item.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
</button>
<button class="btn btn--small btn--danger" :disabled="item.usageCount > 0" @click="removeCustomItem(item)">개별 삭제</button>
</div>
</div>
</article>
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
</button>
</div>
<div class="pager">
@@ -1423,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">
@@ -1671,6 +1675,61 @@ async function saveFeaturedOrder() {
</div>
</div>
<div v-if="customItemModalOpen" class="modalOverlay" @click.self="closeCustomItemModal">
<div class="modalCard modalCard--customItem" role="dialog" aria-modal="true">
<div class="modalCard__titleRow">
<div class="modalCard__title">커스텀 아이템 상세</div>
<button class="btn btn--ghost btn--small" @click="closeCustomItemModal">닫기</button>
</div>
<div v-if="modalTargetCustomItem" class="customItemModal">
<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">
<div class="customItemModal__metaRow"><span>파일</span><strong :title="modalTargetCustomItem.src.split('/').pop()">{{ modalTargetCustomItem.src.split('/').pop() }}</strong></div>
<div class="customItemModal__metaRow"><span>업로더</span><strong :title="modalTargetCustomItem.ownerName">{{ modalTargetCustomItem.ownerName }}</strong></div>
<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="customItemModal__actions">
<a class="btn btn--ghost" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a>
<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>
</div>
</div>
</div>
</div>
</div>
<div v-if="customItemDeleteModalOpen" class="modalOverlay" @click.self="closeCustomItemDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">커스텀 아이템 삭제</div>
<div class="modalCard__desc">{{ modalTargetCustomItem ? '"' + modalTargetCustomItem.label + '" 이미지를 삭제할까요? 미사용 상태의 이미지에만 삭제를 허용합니다.' : '' }}</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeCustomItemDeleteModal">취소</button>
<button class="btn btn--danger" @click="removeCustomItem()">삭제</button>
</div>
</div>
</div>
<div v-if="previewModalOpen" class="modalOverlay" @click.self="closePreviewModal">
<div class="modalCard modalCard--preview" role="dialog" aria-modal="true">
<div class="modalCard__titleRow">
@@ -2435,22 +2494,28 @@ async function saveFeaturedOrder() {
text-align: center;
}
.customItemGrid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 280px));
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: 12px;
justify-content: start;
}
.customItemCard {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 18px;
background: rgba(255, 255, 255, 0.04);
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
gap: 14px;
padding: 14px;
gap: 10px;
padding: 10px;
min-width: 0;
text-align: left;
cursor: pointer;
transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease;
}
.customItemCard:hover {
border-color: rgba(126, 162, 255, 0.42);
background: rgba(255, 255, 255, 0.06);
transform: translateY(-1px);
}
.customItemCard__image {
width: 100%;
@@ -2460,51 +2525,88 @@ async function saveFeaturedOrder() {
border-radius: 14px;
background: rgba(0, 0, 0, 0.18);
}
.customItemCard__body {
display: grid;
gap: 12px;
min-width: 0;
align-content: start;
}
.customItemCard__actions {
display: grid;
gap: 8px;
margin-top: auto;
}
.customItemCard__actions > * {
width: 100%;
}
.customItemCard__title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-word;
font-weight: 900;
line-height: 1.35;
white-space: nowrap;
font-weight: 800;
font-size: 13px;
line-height: 1.3;
color: #ffffff;
}
.customItemCard__metaList {
.customItemModal {
display: grid;
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;
object-fit: cover;
border-radius: 18px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.customItemModal__body {
display: grid;
gap: 14px;
min-width: 0;
}
.customItemModal__selector,
.customItemModal__linked {
display: grid;
gap: 8px;
}
.customItemCard__metaRow {
.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;
line-height: 1.35;
word-break: break-word;
}
.customItemModal__metaList {
display: grid;
gap: 3px;
gap: 10px;
}
.customItemModal__metaRow {
display: grid;
gap: 4px;
min-width: 0;
}
.customItemCard__metaRow span {
.customItemModal__metaRow span {
font-size: 11px;
color: rgba(255, 255, 255, 0.46);
}
.customItemCard__metaRow strong {
.customItemModal__metaRow strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: rgba(255, 255, 255, 0.82);
color: rgba(255, 255, 255, 0.84);
}
.customItemModal__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.modalCard--customItem {
width: min(760px, 100%);
}
.pager {
margin-top: 16px;
@@ -2791,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;
@@ -2991,6 +3101,9 @@ async function saveFeaturedOrder() {
.adminHero__stats {
grid-template-columns: 1fr;
}
.customItemModal {
grid-template-columns: 1fr;
}
.adminSidebar {
display: none;
}
@@ -3034,19 +3147,22 @@ async function saveFeaturedOrder() {
font-size: 24px;
}
.thumbGrid,
.customItemGrid,
.userList {
grid-template-columns: 1fr;
}
.customItemGrid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.tierAdminCard__head {
display: grid;
}
.customItemCard {
align-items: stretch;
padding: 10px;
}
.customItemCard__image {
width: clamp(72px, 28vw, 120px);
flex-basis: 120px;
width: 100%;
aspect-ratio: 1 / 1;
}
}
</style>