릴리스: v1.3.64 관리자 필터와 게임 설정 패널 정리

This commit is contained in:
2026-04-02 14:23:02 +09:00
parent b542b963d2
commit 2918a0423c
9 changed files with 156 additions and 68 deletions

View File

@@ -12,6 +12,20 @@ const props = defineProps({
isGameLoading: { type: Boolean, required: true },
hasSelectedGame: { type: Boolean, required: true },
selectedGame: { type: Object, default: null },
displayThumbnailUrl: { type: String, default: '' },
canApplyThumbnail: { type: Boolean, required: true },
gameVisibilitySaving: { type: Boolean, required: true },
thumbFileInputRef: { type: Function, required: true },
openThumbFilePicker: { type: Function, required: true },
onThumb: { type: Function, required: true },
onThumbDragEnter: { type: Function, required: true },
onThumbDragOver: { type: Function, required: true },
onThumbDragLeave: { type: Function, required: true },
onThumbDrop: { type: Function, required: true },
isThumbDragOver: { type: Boolean, required: true },
uploadThumbnail: { type: Function, required: true },
removeGame: { type: Function, required: true },
toggleSelectedGameVisibility: { type: Function, required: true },
itemFileInputRef: { type: Function, required: true },
onFile: { type: Function, required: true },
isItemDragOver: { type: Boolean, required: true },
@@ -36,6 +50,10 @@ const props = defineProps({
function setGameItemListElement(el) {
props.gameItemListRef(el)
}
function setThumbFileElement(el) {
props.thumbFileInputRef(el)
}
</script>
<template>
@@ -91,13 +109,43 @@ function setGameItemListElement(el) {
</div>
</div>
<div v-else-if="props.hasSelectedGame" class="panel">
<div class="detailHead">
<div>
<div class="panel__title">선택된 게임 정보</div>
<div class="selectedGame__name">{{ props.selectedGame.game.name }}</div>
<div class="selectedGame__id">{{ props.selectedGame.game.id }}</div>
<section class="adminCard gameSettingsCard">
<div class="gameSettingsCard__media">
<input :ref="setThumbFileElement" type="file" accept="image/*" class="srOnlyInput" @change="props.onThumb" />
<button
class="thumbDropZone"
:class="{ 'thumbDropZone--active': props.isThumbDragOver }"
type="button"
@click="props.openThumbFilePicker"
@dragenter="props.onThumbDragEnter"
@dragover="props.onThumbDragOver"
@dragleave="props.onThumbDragLeave"
@drop="props.onThumbDrop"
>
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedGame.game.name" />
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
<div class="thumbDropZone__copy">
<div class="thumbDropZone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" />
</div>
<div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
</div>
</button>
</div>
</div>
<div class="gameSettingsCard__body">
<div class="panel__title">게임 설정</div>
<div class="gameSettingsCard__meta">{{ props.selectedGame.game.name }} · {{ props.selectedGame.game.id }}</div>
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.gameVisibilitySaving }">
<input :checked="!!props.selectedGame.game.isPublic" type="checkbox" @change="props.toggleSelectedGameVisibility($event.target.checked)" />
<span class="toggleSwitch__label">{{ props.selectedGame.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="gameSettingsCard__actions">
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
<button class="btn btn--danger" @click="props.removeGame">게임 삭제</button>
</div>
</div>
</section>
<div class="section">
<section class="adminCard">

View File

@@ -8,7 +8,7 @@ export function useAdminCustomItems({
customItemLimit,
customItemPageCount,
customItemQuery,
customItemOrphanOnly,
customItemFilter,
customItemModalOpen,
customItemDeleteModalOpen,
customItemModalHistoryActive,
@@ -33,7 +33,8 @@ export function useAdminCustomItems({
refreshCustomItems()
}
function toggleCustomItemOrphanOnly() {
function changeCustomItemFilter(filter) {
customItemFilter.value = filter
customItemPage.value = 1
refreshCustomItems()
}
@@ -186,7 +187,7 @@ export function useAdminCustomItems({
return {
submitCustomItemSearch,
toggleCustomItemOrphanOnly,
changeCustomItemFilter,
changeCustomItemLimit,
moveCustomItemPage,
pushCustomItemModalHistoryState,

View File

@@ -41,9 +41,9 @@ export const api = {
request(`/api/admin/games/${encodeURIComponent(gameId)}`, { method: 'PATCH', body: payload }),
updateAdminGameItem: (gameId, itemId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) =>
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) =>
request(
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
),
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),

View File

@@ -5,7 +5,6 @@ import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import lockResetIcon from '../assets/icons/lock_reset.svg'
import deleteIcon from '../assets/icons/delete.svg'
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
import SvgIcon from '../components/SvgIcon.vue'
import AdminFeaturedSection from '../components/admin/AdminFeaturedSection.vue'
import AdminGamesSection from '../components/admin/AdminGamesSection.vue'
@@ -43,7 +42,7 @@ const customItemQuery = ref('')
const customItemPage = ref(1)
const customItemLimit = ref(50)
const customItemTotal = ref(0)
const customItemOrphanOnly = ref(false)
const customItemFilter = ref('all')
const customItemModalTargetGameId = ref('')
const customItemModalGameQuery = ref('')
const customItemModalGameSort = ref('recent')
@@ -128,6 +127,10 @@ function setItemFileInputRef(el) {
itemFileInput.value = el
}
function setThumbFileInputRef(el) {
thumbFileInput.value = el
}
function scheduleGameItemSortableSync() {
if (gameItemSortableSyncTimer) {
clearTimeout(gameItemSortableSyncTimer)
@@ -445,7 +448,7 @@ watch(
if (tab === 'items') {
customItemQuery.value = ''
customItemOrphanOnly.value = false
customItemFilter.value = 'all'
customItemPage.value = 1
customItemModalGameQuery.value = ''
await refreshCustomItems()
@@ -682,7 +685,7 @@ function setTab(tab) {
}
if (tab === 'items') {
customItemQuery.value = ''
customItemOrphanOnly.value = false
customItemFilter.value = 'all'
customItemPage.value = 1
refreshCustomItems()
}
@@ -743,7 +746,7 @@ async function refreshCustomItems() {
q: customItemQuery.value,
page: customItemPage.value,
limit: customItemLimit.value,
orphanOnly: customItemOrphanOnly.value,
filter: customItemFilter.value,
})
customItems.value = data.items || []
customItemTotal.value = data.total || 0
@@ -900,7 +903,7 @@ const {
const {
submitCustomItemSearch,
toggleCustomItemOrphanOnly,
changeCustomItemFilter,
changeCustomItemLimit,
moveCustomItemPage,
openCustomItemModal,
@@ -920,7 +923,7 @@ const {
customItemLimit,
customItemPageCount,
customItemQuery,
customItemOrphanOnly,
customItemFilter,
customItemModalOpen,
customItemDeleteModalOpen,
customItemModalHistoryActive,
@@ -1483,6 +1486,20 @@ function userAvatarFallback(user) {
:is-game-loading="isGameLoading"
:has-selected-game="hasSelectedGame"
:selected-game="selectedGame"
:display-thumbnail-url="displayThumbnailUrl"
:can-apply-thumbnail="canApplyThumbnail"
:game-visibility-saving="gameVisibilitySaving"
:thumb-file-input-ref="setThumbFileInputRef"
:open-thumb-file-picker="openThumbFilePicker"
:on-thumb="onThumb"
:on-thumb-drag-enter="onThumbDragEnter"
:on-thumb-drag-over="onThumbDragOver"
:on-thumb-drag-leave="onThumbDragLeave"
:on-thumb-drop="onThumbDrop"
:is-thumb-drag-over="isThumbDragOver"
:upload-thumbnail="uploadThumbnail"
:remove-game="removeGame"
:toggle-selected-game-visibility="toggleSelectedGameVisibility"
:item-file-input-ref="setItemFileInputRef"
:on-file="onFile"
:is-item-drag-over="isItemDragOver"
@@ -1944,39 +1961,6 @@ function userAvatarFallback(user) {
</div>
<div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
</div>
<div v-if="hasSelectedGame" class="adminSidebar__group">
<div class="selectedGameSidebar__name">{{ selectedGame.game.name }}</div>
<div class="selectedGameSidebar__id">{{ selectedGame.game.id }}</div>
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': gameVisibilitySaving }">
<input :checked="!!selectedGame.game.isPublic" type="checkbox" @change="toggleSelectedGameVisibility($event.target.checked)" />
<span class="toggleSwitch__label">{{ selectedGame.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<input ref="thumbFileInput" type="file" accept="image/*" class="srOnlyInput" @change="onThumb" />
<button
class="thumbDropZone"
:class="{ 'thumbDropZone--active': isThumbDragOver }"
type="button"
@click="openThumbFilePicker"
@dragenter="onThumbDragEnter"
@dragover="onThumbDragOver"
@dragleave="onThumbDragLeave"
@drop="onThumbDrop"
>
<img v-if="displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="displayThumbnailUrl" :alt="selectedGame.game.name" />
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
<div class="thumbDropZone__copy">
<div class="thumbDropZone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" />
</div>
<div class="thumbDropZone__title">{{ displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
</div>
</button>
<div class="adminSidebar__actions adminSidebar__actions--stack">
<button class="btn" :disabled="!canApplyThumbnail" @click="uploadThumbnail">썸네일 적용</button>
<button class="btn btn--danger" @click="removeGame">게임 삭제</button>
</div>
</div>
</section>
<section v-else-if="activeTab === 'items'" class="adminSidebar__panel">
@@ -1990,14 +1974,18 @@ function userAvatarFallback(user) {
<option :value="50">50개씩 보기</option>
<option :value="200">200개씩 보기</option>
</select>
<label class="checkRow checkRow--compact">
<input v-model="customItemOrphanOnly" type="checkbox" @change="toggleCustomItemOrphanOnly" />
<span>미사용 사용자 업로드 보기</span>
</label>
<select :value="customItemFilter" class="select" @change="changeCustomItemFilter($event.target.value)">
<option value="all">전체 이미지</option>
<option value="user">사용자 업로드</option>
<option value="template">템플릿 사용 이미지</option>
<option value="asset">관리자 보관 자산</option>
<option value="unused-user">미사용 사용자 업로드</option>
<option value="unused-admin">미사용 관리자 자산</option>
</select>
</div>
<div class="adminSidebar__actions">
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
<button class="btn btn--danger" :disabled="!customItems.length" @click="removeUnusedCustomItems">미사용 이미지 일괄 삭제</button>
<button class="btn btn--danger" :disabled="customItemFilter !== 'unused-user' || !customItems.length" @click="removeUnusedCustomItems">미사용 사용자 이미지 일괄 삭제</button>
</div>
<div class="adminSidebar__stats">
<div class="sidebarStat">
@@ -2112,8 +2100,14 @@ function userAvatarFallback(user) {
<strong>{{ formatImageJobSourceCategory(job.sourceCategory) }}</strong>
<span class="imageJobRow__status">{{ formatImageJobStatus(job.status) }}</span>
</div>
<div class="hint hint--tight">{{ formatBytes(job.originalByteSize) }} {{ formatBytes(job.optimizedByteSize) }}</div>
<div v-if="job.reusedAsset" class="hint hint--tight">기존 최적화 파일 재사용</div>
<div class="hint hint--tight">
{{
job.reusedAsset
? `이번 업로드 ${formatBytes(job.originalByteSize)} · 재사용 자산 ${formatBytes(job.optimizedByteSize)}`
: `${formatBytes(job.originalByteSize)}${formatBytes(job.optimizedByteSize)}`
}}
</div>
<div v-if="job.reusedAsset" class="hint hint--tight">동일한 최적화 결과가 이미 있어 파일을 다시 만들지 않았어요.</div>
<div class="hint hint--tight">{{ fmt(job.queuedAt) }}</div>
</article>
</div>
@@ -2720,6 +2714,31 @@ function userAvatarFallback(user) {
opacity: 0.72;
word-break: break-all;
}
.adminUiScope .gameSettingsCard {
display: grid;
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
gap: 18px;
align-items: center;
}
.adminUiScope .gameSettingsCard__media {
min-width: 0;
}
.adminUiScope .gameSettingsCard__body {
display: grid;
gap: 14px;
align-content: center;
}
.adminUiScope .gameSettingsCard__meta {
color: var(--theme-text-soft);
font-size: 13px;
line-height: 1.5;
word-break: break-all;
}
.adminUiScope .gameSettingsCard__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.adminUiScope .selectedThumb {
width: min(100%, 256px);
aspect-ratio: 16 / 9;
@@ -4122,6 +4141,7 @@ function userAvatarFallback(user) {
.adminUiScope .featuredOrderPanel,
.adminUiScope .section--topGrid,
.adminUiScope .gameManagerGrid,
.adminUiScope .gameSettingsCard,
.adminUiScope .toolbar,
.adminUiScope .itemComposer,
.adminUiScope .tierAdminCard,