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

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

View File

@@ -1322,7 +1322,7 @@ async function getCustomItemUsageMeta() {
}
}
async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) {
async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMode = 'all' } = {}) {
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
const normalizedPage = Math.max(Number(page) || 1, 1)
const searchText = (queryText || '').trim()
@@ -1446,8 +1446,20 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
const allItems = [...customItems, ...templateItems, ...assetLibraryItems]
.filter((item) => {
if (!orphanOnly) return true
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0
switch (filterMode) {
case 'user':
return item.sourceType === 'user'
case 'template':
return item.sourceType === 'template' && !item.isAssetLibraryItem
case 'asset':
return !!item.isAssetLibraryItem
case 'unused-user':
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0
case 'unused-admin':
return !!item.isAssetLibraryItem
default:
return true
}
})
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))

View File

@@ -277,11 +277,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
q: z.string().trim().max(120).optional().default(''),
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
orphanOnly: z
.union([z.literal('true'), z.literal('false'), z.boolean()])
.optional()
.default('false')
.transform((value) => value === true || value === 'true'),
filter: z.enum(['all', 'user', 'template', 'asset', 'unused-user', 'unused-admin']).optional().default('all'),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -290,7 +286,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
queryText: parsed.data.q,
page: parsed.data.page,
limit: parsed.data.limit,
orphanOnly: parsed.data.orphanOnly,
filterMode: parsed.data.filter,
})
res.json(result)
})
@@ -571,7 +567,7 @@ async function createGameTemplateFromRequest({ templateRequest, gameId, gameName
}
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
const result = await listCustomItems({ page: 1, limit: 10000, orphanOnly: false })
const result = await listCustomItems({ page: 1, limit: 10000, filterMode: 'all' })
const target = result.items.find((item) => item.id === req.params.itemId)
if (!target) return res.status(404).json({ error: 'not_found' })
if (target.sourceType === 'template') {

View File

@@ -1,5 +1,10 @@
# 의사결정 이력
## 2026-04-02 v1.3.64
- 관리자 이미지 최적화 기록은 “재사용” 자체보다 이번 업로드와 재사용 자산의 관계가 더 중요하므로, 용량 숫자와 설명을 함께 보여주는 편이 운영자가 오해하지 않기 쉽다고 정리했다.
- 관리자 아이템 라이브러리는 `사용자 업로드 미사용만` 같은 단일 체크박스보다 출처와 사용 상태를 함께 고를 수 있는 필터 모드가 더 실용적이므로, 사용자·템플릿·보관 자산·미사용 상태를 분리해 보는 구조가 맞다고 판단했다.
- 게임 목록이 커질수록 선택 게임 설정을 사이드바 하단에 두는 구조는 스크롤 부담이 커지므로, 공개 상태와 썸네일 관리 액션은 선택된 게임 본문 상단 카드로 올리는 편이 더 안정적이라고 정리했다.
## 2026-04-02 v1.3.63
- 이미지 최적화 기록은 내부 라우트 카테고리를 그대로 보여주면 운영자가 실제 의미를 해석해야 하므로, 관리자 화면에는 기능 기준의 한국어 라벨과 재사용 여부를 함께 보여주는 편이 맞다고 정리했다.

View File

@@ -6,6 +6,7 @@
- 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다.
- 커스텀 이미지가 많은 티어표를 저장할 때 커스텀 이름 정리 목록과 미배치 아이템 영역 순서가 실제로 안정적으로 유지되는지, 10장 이상 업로드한 상태로 한 번 더 QA한다.
- 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다.
- 관리자 게임 설정 카드를 본문 상단으로 옮겼으므로, 긴 게임 목록 상태에서 선택-썸네일 교체-공개 전환-삭제 흐름이 스크롤 없이 자연스러운지 한 번 더 QA한다.
## 중기 개선
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.

View File

@@ -1,5 +1,10 @@
# 업데이트 로그
## 2026-04-02 v1.3.64
- 관리자 이미지 최적화 최근 작업에서 `기존 최적화 파일 재사용` 건은 이제 `이번 업로드 용량 · 재사용 자산 용량` 형태로 표시하고, 동일한 최적화 결과가 이미 있어 새 파일을 다시 만들지 않았다는 설명을 함께 보여 혼동을 줄임.
- 관리자 아이템 관리는 기존의 `미사용 사용자 업로드만 보기` 체크박스 대신 `전체 / 사용자 업로드 / 템플릿 사용 이미지 / 관리자 보관 자산 / 미사용 사용자 / 미사용 관리자` 필터로 바꿔, 관리자 업로드 자산도 별도로 확인할 수 있게 정리함.
- 게임 관리의 선택된 게임 설정은 더 이상 우측 사이드바 아래쪽에 쌓지 않고, 본문 상단에 썸네일과 공개 상태·썸네일 적용·게임 삭제 액션을 함께 둔 카드로 옮겨 게임 목록이 많아져도 작업 영역을 더 안정적으로 읽을 수 있게 조정함.
## 2026-04-02 v1.3.63
- 관리자 이미지 최적화 최근 작업 목록은 더 이상 내부 카테고리 문자열 `custom / tierlists / games / avatars`를 그대로 노출하지 않고, 각각 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타`처럼 사람이 이해할 수 있는 이름으로 표시함.
- 같은 이미지 해시를 다시 업로드해 기존 최적화 파일을 재사용한 경우에는 최근 작업 목록에 `기존 최적화 파일 재사용` 문구를 함께 보여, 새로 압축된 건지 중복 자산이 재사용된 건지 운영자가 바로 구분할 수 있게 함.

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,