Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34ddd1083d |
@@ -605,28 +605,51 @@ async function findCustomItemById(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCustomItemUsageMap() {
|
async function getCustomItemUsageMeta() {
|
||||||
const rows = await query('SELECT groups_json, pool_json FROM tierlists')
|
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 usageMap = new Map()
|
||||||
|
const linkedGamesMap = new Map()
|
||||||
|
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
const groups = parseJson(row.groups_json, [])
|
const groups = parseJson(row.groups_json, [])
|
||||||
const pool = parseJson(row.pool_json, [])
|
const pool = parseJson(row.pool_json, [])
|
||||||
|
const seenItemIds = new Set()
|
||||||
|
|
||||||
groups.forEach((group) => {
|
groups.forEach((group) => {
|
||||||
;(group?.itemIds || []).forEach((itemId) => {
|
;(group?.itemIds || []).forEach((itemId) => {
|
||||||
usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1)
|
usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1)
|
||||||
|
if (itemId) seenItemIds.add(itemId)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
pool.forEach((item) => {
|
pool.forEach((item) => {
|
||||||
if (item?.id) {
|
if (item?.id) {
|
||||||
usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1)
|
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 } = {}) {
|
async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) {
|
||||||
@@ -655,7 +678,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
|||||||
params
|
params
|
||||||
)
|
)
|
||||||
|
|
||||||
const usageMap = await getCustomItemUsageMap()
|
const { usageMap, linkedGamesMap } = await getCustomItemUsageMeta()
|
||||||
const allItems = rows
|
const allItems = rows
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -666,6 +689,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
|||||||
ownerName: row.nickname || row.email,
|
ownerName: row.nickname || row.email,
|
||||||
ownerEmail: row.email,
|
ownerEmail: row.email,
|
||||||
usageCount: usageMap.get(row.id) || 0,
|
usageCount: usageMap.get(row.id) || 0,
|
||||||
|
linkedGames: linkedGamesMap.get(row.id) || [],
|
||||||
}))
|
}))
|
||||||
.filter((item) => (orphanOnly ? item.usageCount === 0 : true))
|
.filter((item) => (orphanOnly ? item.usageCount === 0 : true))
|
||||||
|
|
||||||
@@ -705,7 +729,7 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
|
|||||||
params
|
params
|
||||||
)
|
)
|
||||||
|
|
||||||
const usageMap = await getCustomItemUsageMap()
|
const { usageMap } = await getCustomItemUsageMeta()
|
||||||
return rows
|
return rows
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-31 v1.2.59
|
||||||
|
- 관리자 아이템 상세 모달의 게임 선택을 전용 상태로 분리해 기본 선택값이 비어 있도록 바꾸고, 썸네일 아래에 배치해 정보/액션과 시각적으로 분리함.
|
||||||
|
- 커스텀 아이템이 실제로 사용 중인 게임 목록을 백엔드에서 함께 내려주고, 템플릿 요청 생성 폼에는 게임 ID와 게임 이름 라벨을 추가해 구분을 명확히 함.
|
||||||
|
|
||||||
## 2026-03-31 v1.2.58
|
## 2026-03-31 v1.2.58
|
||||||
- 관리자 아이템 관리 카드를 썸네일과 제목만 보이는 compact 카드로 줄여, 대량 업로드된 이미지도 훨씬 높은 밀도로 탐색할 수 있게 정리함.
|
- 관리자 아이템 관리 카드를 썸네일과 제목만 보이는 compact 카드로 줄여, 대량 업로드된 이미지도 훨씬 높은 밀도로 탐색할 수 있게 정리함.
|
||||||
- 카드 클릭 시 상세 정보를 모달로 열고 이미지 다운로드, 기본 템플릿 추가, 삭제를 모달 안에서 결정하는 흐름으로 바꿈.
|
- 카드 클릭 시 상세 정보를 모달로 열고 이미지 다운로드, 기본 템플릿 추가, 삭제를 모달 안에서 결정하는 흐름으로 바꿈.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const customItemLimit = ref(50)
|
|||||||
const customItemTotal = ref(0)
|
const customItemTotal = ref(0)
|
||||||
const customItemOrphanOnly = ref(false)
|
const customItemOrphanOnly = ref(false)
|
||||||
const customItemTargetGameId = ref('')
|
const customItemTargetGameId = ref('')
|
||||||
|
const customItemModalTargetGameId = ref('')
|
||||||
|
|
||||||
const adminTierLists = ref([])
|
const adminTierLists = ref([])
|
||||||
const adminTierListQuery = ref('')
|
const adminTierListQuery = ref('')
|
||||||
@@ -230,9 +231,6 @@ function setTab(tab) {
|
|||||||
if (tab === 'tierlists') {
|
if (tab === 'tierlists') {
|
||||||
tierlistsMode.value = 'requests'
|
tierlistsMode.value = 'requests'
|
||||||
}
|
}
|
||||||
if (tab === 'items' && !customItemTargetGameId.value && games.value.length) {
|
|
||||||
customItemTargetGameId.value = games.value[0].id
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTierlistsMode(mode) {
|
function setTierlistsMode(mode) {
|
||||||
@@ -260,9 +258,6 @@ async function refreshGames() {
|
|||||||
try {
|
try {
|
||||||
const data = await api.listGames()
|
const data = await api.listGames()
|
||||||
games.value = data.games || []
|
games.value = data.games || []
|
||||||
if (!customItemTargetGameId.value && games.value.length) {
|
|
||||||
customItemTargetGameId.value = games.value[0].id
|
|
||||||
}
|
|
||||||
featuredGameIds.value = games.value
|
featuredGameIds.value = games.value
|
||||||
.filter((game) => game.displayRank != null)
|
.filter((game) => game.displayRank != null)
|
||||||
.sort((a, b) => a.displayRank - b.displayRank)
|
.sort((a, b) => a.displayRank - b.displayRank)
|
||||||
@@ -860,12 +855,14 @@ function moveCustomItemPage(direction) {
|
|||||||
|
|
||||||
function openCustomItemModal(item) {
|
function openCustomItemModal(item) {
|
||||||
modalTargetCustomItem.value = item || null
|
modalTargetCustomItem.value = item || null
|
||||||
|
customItemModalTargetGameId.value = ''
|
||||||
customItemModalOpen.value = true
|
customItemModalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCustomItemModal() {
|
function closeCustomItemModal() {
|
||||||
customItemModalOpen.value = false
|
customItemModalOpen.value = false
|
||||||
modalTargetCustomItem.value = null
|
modalTargetCustomItem.value = null
|
||||||
|
customItemModalTargetGameId.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCustomItemDeleteModal(item) {
|
function openCustomItemDeleteModal(item) {
|
||||||
@@ -917,16 +914,16 @@ async function removeUnusedCustomItems() {
|
|||||||
|
|
||||||
async function promoteCustomItem(item) {
|
async function promoteCustomItem(item) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
if (!customItemTargetGameId.value) {
|
if (!customItemModalTargetGameId.value) {
|
||||||
error.value = '가져올 게임을 먼저 선택해주세요.'
|
error.value = '가져올 게임을 먼저 선택해주세요.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
item.isPromoting = true
|
item.isPromoting = true
|
||||||
await api.promoteAdminCustomItem(item.id, { gameId: customItemTargetGameId.value })
|
await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetGameId.value })
|
||||||
const targetGameName = games.value.find((game) => game.id === customItemTargetGameId.value)?.name || customItemTargetGameId.value
|
const targetGameName = games.value.find((game) => game.id === customItemModalTargetGameId.value)?.name || customItemModalTargetGameId.value
|
||||||
if (selectedGameId.value === customItemTargetGameId.value) await loadGame()
|
if (selectedGameId.value === customItemModalTargetGameId.value) await loadGame()
|
||||||
closeCustomItemModal()
|
closeCustomItemModal()
|
||||||
success.value = `"${item.label}" 아이템을 ${targetGameName} 기본 템플릿으로 추가했어요.`
|
success.value = `"${item.label}" 아이템을 ${targetGameName} 기본 템플릿으로 추가했어요.`
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1424,8 +1421,14 @@ async function saveFeaturedOrder() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="request.type === 'create'" class="templateRequestCard__form">
|
<div v-if="request.type === 'create'" class="templateRequestCard__form">
|
||||||
<input v-model="request.draftGameId" class="input" placeholder="새 게임 ID" />
|
<label class="templateRequestField">
|
||||||
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
|
<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>
|
||||||
|
|
||||||
<div class="templateRequestCard__actions">
|
<div class="templateRequestCard__actions">
|
||||||
@@ -1679,7 +1682,23 @@ async function saveFeaturedOrder() {
|
|||||||
<button class="btn btn--ghost btn--small" @click="closeCustomItemModal">닫기</button>
|
<button class="btn btn--ghost btn--small" @click="closeCustomItemModal">닫기</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="modalTargetCustomItem" class="customItemModal">
|
<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__body">
|
||||||
<div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div>
|
<div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div>
|
||||||
<div class="customItemModal__metaList">
|
<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>{{ modalTargetCustomItem.usageCount }}개 티어표</strong></div>
|
||||||
<div class="customItemModal__metaRow"><span>등록일</span><strong>{{ fmt(modalTargetCustomItem.createdAt) }}</strong></div>
|
<div class="customItemModal__metaRow"><span>등록일</span><strong>{{ fmt(modalTargetCustomItem.createdAt) }}</strong></div>
|
||||||
</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">
|
<div class="customItemModal__actions">
|
||||||
<a class="btn btn--ghost" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a>
|
<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 ? '추가중...' : '기본 템플릿에 추가' }}
|
{{ modalTargetCustomItem.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--danger" :disabled="modalTargetCustomItem.usageCount > 0" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
|
<button class="btn btn--danger" :disabled="modalTargetCustomItem.usageCount > 0" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
|
||||||
@@ -2524,10 +2537,15 @@ async function saveFeaturedOrder() {
|
|||||||
}
|
}
|
||||||
.customItemModal {
|
.customItemModal {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
|
grid-template-columns: minmax(200px, 240px) minmax(0, 1fr);
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
.customItemModal__side {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.customItemModal__image {
|
.customItemModal__image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
@@ -2541,6 +2559,20 @@ async function saveFeaturedOrder() {
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
min-width: 0;
|
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 {
|
.customItemModal__title {
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
@@ -2861,6 +2893,14 @@ async function saveFeaturedOrder() {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
.templateRequestField {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.templateRequestField__label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.52);
|
||||||
|
}
|
||||||
.templateRequestCard__actions {
|
.templateRequestCard__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user