릴리스: v1.3.34 관리자 아이템 라이브러리 정리
This commit is contained in:
@@ -31,8 +31,9 @@ const customItemPage = ref(1)
|
||||
const customItemLimit = ref(50)
|
||||
const customItemTotal = ref(0)
|
||||
const customItemOrphanOnly = ref(false)
|
||||
const customItemTargetGameId = ref('')
|
||||
const customItemModalTargetGameId = ref('')
|
||||
const customItemModalGameQuery = ref('')
|
||||
const customItemModalGameSort = ref('recent')
|
||||
|
||||
const adminTierLists = ref([])
|
||||
const adminTierListQuery = ref('')
|
||||
@@ -137,7 +138,7 @@ const activeTabDescription = computed(() => {
|
||||
return '게임 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
|
||||
}
|
||||
if (activeTab.value === 'items') {
|
||||
return '사용자 커스텀 이미지를 검색하고, 미사용 이미지를 정리하거나 템플릿으로 승격할 수 있어요.'
|
||||
return '사용자 업로드와 관리자 템플릿 이미지를 함께 검수하고, 필요한 게임에 직접 연결할 수 있어요.'
|
||||
}
|
||||
if (activeTab.value === 'tierlists') {
|
||||
return tierlistsMode.value === 'requests'
|
||||
@@ -170,7 +171,7 @@ const adminOverviewStats = computed(() => {
|
||||
return [
|
||||
{ label: '검색 결과', value: `${customItemTotal.value}` },
|
||||
{ label: '미사용', value: `${orphanItems}` },
|
||||
{ label: '대상 게임', value: customItemTargetGameId.value ? '선택됨' : '미선택' },
|
||||
{ label: '템플릿 아이템', value: `${customItems.value.filter((item) => item.sourceType === 'template').length}` },
|
||||
]
|
||||
}
|
||||
if (activeTab.value === 'tierlists') {
|
||||
@@ -283,6 +284,21 @@ const imageDiagnosticsCards = computed(() => {
|
||||
const visibleLinkedGames = computed(() =>
|
||||
(modalTargetCustomItem.value?.linkedGames || []).filter((game) => game?.id && game.id !== 'freeform')
|
||||
)
|
||||
const filteredCustomItemModalGames = computed(() => {
|
||||
const query = customItemModalGameQuery.value.trim().toLowerCase()
|
||||
const linkedIds = new Set(visibleLinkedGames.value.map((game) => game.id))
|
||||
const list = games.value.filter((game) => {
|
||||
if (!query) return true
|
||||
return `${game.name || ''} ${game.id || ''}`.toLowerCase().includes(query)
|
||||
})
|
||||
|
||||
return list.slice().sort((a, b) => {
|
||||
const linkedDelta = Number(linkedIds.has(a.id)) - Number(linkedIds.has(b.id))
|
||||
if (linkedDelta !== 0) return linkedDelta
|
||||
if (customItemModalGameSort.value === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0)
|
||||
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
|
||||
})
|
||||
})
|
||||
|
||||
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
|
||||
const imageStatsYearOptions = computed(() => {
|
||||
@@ -532,7 +548,7 @@ async function refreshCustomItems() {
|
||||
customItemPage.value = data.page || 1
|
||||
customItemLimit.value = data.limit || customItemLimit.value
|
||||
} catch (e) {
|
||||
error.value = '사용자 커스텀 아이템을 불러오지 못했어요.'
|
||||
error.value = '아이템 라이브러리를 불러오지 못했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1035,6 +1051,8 @@ function moveCustomItemPage(direction) {
|
||||
function openCustomItemModal(item) {
|
||||
modalTargetCustomItem.value = item || null
|
||||
customItemModalTargetGameId.value = ''
|
||||
customItemModalGameQuery.value = ''
|
||||
customItemModalGameSort.value = 'recent'
|
||||
customItemModalOpen.value = true
|
||||
}
|
||||
|
||||
@@ -1042,12 +1060,14 @@ function closeCustomItemModal() {
|
||||
customItemModalOpen.value = false
|
||||
modalTargetCustomItem.value = null
|
||||
customItemModalTargetGameId.value = ''
|
||||
customItemModalGameQuery.value = ''
|
||||
customItemModalGameSort.value = 'recent'
|
||||
}
|
||||
|
||||
function openCustomItemDeleteModal(item) {
|
||||
if (!item) return
|
||||
if (item.usageCount > 0) {
|
||||
error.value = '사용 중인 커스텀 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||
error.value = '사용 중인 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||
return
|
||||
}
|
||||
modalTargetCustomItem.value = item
|
||||
@@ -1062,7 +1082,7 @@ async function removeCustomItem(item = modalTargetCustomItem.value) {
|
||||
resetMessages()
|
||||
if (!item) return
|
||||
if (item.usageCount > 0) {
|
||||
error.value = '사용 중인 커스텀 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||
error.value = '사용 중인 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1071,7 +1091,7 @@ async function removeCustomItem(item = modalTargetCustomItem.value) {
|
||||
closeCustomItemDeleteModal()
|
||||
closeCustomItemModal()
|
||||
await refreshCustomItems()
|
||||
success.value = '미사용 커스텀 이미지를 삭제했어요.'
|
||||
success.value = '미사용 사용자 업로드 이미지를 삭제했어요.'
|
||||
} catch (e) {
|
||||
error.value = '커스텀 이미지 삭제에 실패했어요.'
|
||||
}
|
||||
@@ -1085,7 +1105,7 @@ async function removeUnusedCustomItems() {
|
||||
try {
|
||||
const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value })
|
||||
await refreshCustomItems()
|
||||
success.value = `${data.deletedCount || 0}개의 미사용 커스텀 이미지를 삭제했어요.`
|
||||
success.value = `${data.deletedCount || 0}개의 미사용 사용자 업로드 이미지를 삭제했어요.`
|
||||
} catch (e) {
|
||||
error.value = '미사용 커스텀 이미지 일괄 삭제에 실패했어요.'
|
||||
}
|
||||
@@ -1094,7 +1114,7 @@ async function removeUnusedCustomItems() {
|
||||
async function promoteCustomItem(item) {
|
||||
resetMessages()
|
||||
if (!customItemModalTargetGameId.value) {
|
||||
error.value = '가져올 게임을 먼저 선택해주세요.'
|
||||
error.value = '추가할 게임을 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1104,9 +1124,9 @@ async function promoteCustomItem(item) {
|
||||
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} 기본 템플릿으로 추가했어요.`
|
||||
success.value = `"${item.label}" 이미지를 ${targetGameName} 템플릿으로 추가했어요.`
|
||||
} catch (e) {
|
||||
error.value = '커스텀 아이템을 기본 템플릿으로 가져오지 못했어요.'
|
||||
error.value = '선택한 이미지를 템플릿으로 추가하지 못했어요.'
|
||||
} finally {
|
||||
item.isPromoting = false
|
||||
}
|
||||
@@ -1535,9 +1555,10 @@ async function saveFeaturedOrder() {
|
||||
|
||||
<template v-else-if="activeTab === 'items'">
|
||||
<div class="panel">
|
||||
<div v-if="!customItems.length" class="hint">조건에 맞는 커스텀 아이템이 없어요.</div>
|
||||
<div v-if="!customItems.length" class="hint">조건에 맞는 관리 대상 아이템이 없어요.</div>
|
||||
<div v-else class="customItemGrid">
|
||||
<button v-for="item in customItems" :key="item.id" type="button" class="customItemCard" @click="openCustomItemModal(item)">
|
||||
<span class="customItemCard__badge" :class="{ 'customItemCard__badge--template': item.sourceType === 'template' }">{{ item.sourceLabel }}</span>
|
||||
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
|
||||
</button>
|
||||
@@ -1912,19 +1933,54 @@ async function saveFeaturedOrder() {
|
||||
<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>
|
||||
<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>
|
||||
<aside class="customItemModal__pickerPanel">
|
||||
<div class="customItemModal__pickerHead">
|
||||
<div class="customItemModal__pickerEyebrow">GAME LIBRARY</div>
|
||||
<div class="customItemModal__pickerTitle">템플릿으로 추가할 게임</div>
|
||||
</div>
|
||||
<div class="customItemModal__pickerControls">
|
||||
<input v-model="customItemModalGameQuery" class="input input--dense" placeholder="게임 이름, ID 검색" />
|
||||
<select v-model="customItemModalGameSort" class="select">
|
||||
<option value="recent">최신순</option>
|
||||
<option value="oldest">오래된순</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="customItemModal__gameList">
|
||||
<button
|
||||
v-for="game in filteredCustomItemModalGames"
|
||||
:key="game.id"
|
||||
type="button"
|
||||
class="customItemModal__gameItem"
|
||||
:class="{
|
||||
'customItemModal__gameItem--active': customItemModalTargetGameId === game.id,
|
||||
'customItemModal__gameItem--linked': visibleLinkedGames.some((entry) => entry.id === game.id),
|
||||
}"
|
||||
@click="customItemModalTargetGameId = game.id"
|
||||
>
|
||||
<span class="customItemModal__gameName">{{ game.name }}</span>
|
||||
<span class="customItemModal__gameMeta">{{ game.id }}</span>
|
||||
<span v-if="visibleLinkedGames.some((entry) => entry.id === game.id)" class="customItemModal__gameState">이미 포함됨</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="customItemModal__body">
|
||||
<div class="customItemModal__titleRow">
|
||||
<div>
|
||||
<div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div>
|
||||
<div class="customItemModal__source">{{ modalTargetCustomItem.sourceLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<img class="customItemModal__image" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
|
||||
<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>{{ visibleLinkedGames.length }}개 게임</strong></div>
|
||||
<div class="customItemModal__metaRow"><span>등록일</span><strong>{{ fmt(modalTargetCustomItem.createdAt) }}</strong></div>
|
||||
</div>
|
||||
<div class="customItemModal__linked">
|
||||
<span class="customItemModal__label">템플릿에 사용 중인 게임</span>
|
||||
<div v-if="visibleLinkedGames.length" class="customItemModal__chips">
|
||||
@@ -1932,21 +1988,12 @@ async function saveFeaturedOrder() {
|
||||
</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)">
|
||||
<a class="btn btn--ghost customItemModal__action" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a>
|
||||
<button class="btn btn--ghost customItemModal__action" :disabled="!customItemModalTargetGameId || modalTargetCustomItem.isPromoting" @click="promoteCustomItem(modalTargetCustomItem)">
|
||||
{{ modalTargetCustomItem.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
|
||||
</button>
|
||||
<button class="btn btn--danger" :disabled="modalTargetCustomItem.usageCount > 0" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
|
||||
<button v-if="modalTargetCustomItem.canDelete" class="btn btn--danger customItemModal__action" :disabled="modalTargetCustomItem.usageCount > 0 || visibleLinkedGames.length > 0" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1956,7 +2003,7 @@ async function saveFeaturedOrder() {
|
||||
<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__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>
|
||||
@@ -2110,13 +2157,9 @@ async function saveFeaturedOrder() {
|
||||
<option :value="50">50개씩 보기</option>
|
||||
<option :value="200">200개씩 보기</option>
|
||||
</select>
|
||||
<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>
|
||||
<label class="checkRow checkRow--compact">
|
||||
<input v-model="customItemOrphanOnly" type="checkbox" @change="toggleCustomItemOrphanOnly" />
|
||||
<span>미사용 커스텀 이미지만 보기</span>
|
||||
<span>미사용 사용자 업로드만 보기</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="adminSidebar__actions">
|
||||
@@ -3039,6 +3082,7 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.customItemCard {
|
||||
appearance: none;
|
||||
position: relative;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 18px;
|
||||
background: var(--theme-surface-soft);
|
||||
@@ -3051,6 +3095,24 @@ async function saveFeaturedOrder() {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
.customItemCard__badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--theme-main-bg) 82%, transparent);
|
||||
color: var(--theme-text);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.customItemCard__badge--template {
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
.customItemCard:hover {
|
||||
border-color: rgba(126, 162, 255, 0.42);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
@@ -3076,33 +3138,88 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.customItemModal {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 240px) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
.customItemModal__side {
|
||||
.customItemModal__pickerPanel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-pill-bg);
|
||||
}
|
||||
.customItemModal__image {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
border-radius: 18px;
|
||||
.customItemModal__pickerHead {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.customItemModal__pickerEyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
.customItemModal__pickerTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.customItemModal__pickerControls {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.customItemModal__gameList {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
.customItemModal__gameItem {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px 13px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--theme-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.customItemModal__gameItem--active {
|
||||
border-color: rgba(96, 165, 250, 0.42);
|
||||
background: rgba(96, 165, 250, 0.12);
|
||||
}
|
||||
.customItemModal__gameItem--linked {
|
||||
border-style: dashed;
|
||||
}
|
||||
.customItemModal__gameName {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.customItemModal__gameMeta,
|
||||
.customItemModal__gameState {
|
||||
font-size: 11px;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.customItemModal__body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.customItemModal__selector,
|
||||
.customItemModal__titleRow,
|
||||
.customItemModal__linked {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.customItemModal__image {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
border-radius: 20px;
|
||||
background: var(--theme-surface-soft);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.customItemModal__label {
|
||||
font-size: 11px;
|
||||
color: var(--theme-text-faint);
|
||||
@@ -3118,6 +3235,11 @@ async function saveFeaturedOrder() {
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
.customItemModal__source {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.customItemModal__metaList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -3140,12 +3262,20 @@ async function saveFeaturedOrder() {
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.customItemModal__actions {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.customItemModal__action {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-inline: 12px;
|
||||
white-space: normal;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
}
|
||||
.modalCard--customItem {
|
||||
width: min(760px, 100%);
|
||||
width: min(980px, 100%);
|
||||
}
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
@@ -3764,6 +3894,9 @@ async function saveFeaturedOrder() {
|
||||
.customItemModal {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.customItemModal__actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.adminSidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user