diff --git a/backend/src/db.js b/backend/src/db.js index 7308f44..2818583 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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)) diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 8e38587..c2a7d9b 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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') { diff --git a/docs/history.md b/docs/history.md index fdae48b..35629e0 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-04-02 v1.3.64 +- 관리자 이미지 최적화 기록은 “재사용” 자체보다 이번 업로드와 재사용 자산의 관계가 더 중요하므로, 용량 숫자와 설명을 함께 보여주는 편이 운영자가 오해하지 않기 쉽다고 정리했다. +- 관리자 아이템 라이브러리는 `사용자 업로드 미사용만` 같은 단일 체크박스보다 출처와 사용 상태를 함께 고를 수 있는 필터 모드가 더 실용적이므로, 사용자·템플릿·보관 자산·미사용 상태를 분리해 보는 구조가 맞다고 판단했다. +- 게임 목록이 커질수록 선택 게임 설정을 사이드바 하단에 두는 구조는 스크롤 부담이 커지므로, 공개 상태와 썸네일 관리 액션은 선택된 게임 본문 상단 카드로 올리는 편이 더 안정적이라고 정리했다. + ## 2026-04-02 v1.3.63 - 이미지 최적화 기록은 내부 라우트 카테고리를 그대로 보여주면 운영자가 실제 의미를 해석해야 하므로, 관리자 화면에는 기능 기준의 한국어 라벨과 재사용 여부를 함께 보여주는 편이 맞다고 정리했다. diff --git a/docs/todo.md b/docs/todo.md index 80d94ff..44ada03 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -6,6 +6,7 @@ - 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다. - 커스텀 이미지가 많은 티어표를 저장할 때 커스텀 이름 정리 목록과 미배치 아이템 영역 순서가 실제로 안정적으로 유지되는지, 10장 이상 업로드한 상태로 한 번 더 QA한다. - 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다. +- 관리자 게임 설정 카드를 본문 상단으로 옮겼으므로, 긴 게임 목록 상태에서 선택-썸네일 교체-공개 전환-삭제 흐름이 스크롤 없이 자연스러운지 한 번 더 QA한다. ## 중기 개선 - 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다. diff --git a/docs/update.md b/docs/update.md index d5f2b1f..6729195 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-02 v1.3.64 +- 관리자 이미지 최적화 최근 작업에서 `기존 최적화 파일 재사용` 건은 이제 `이번 업로드 용량 · 재사용 자산 용량` 형태로 표시하고, 동일한 최적화 결과가 이미 있어 새 파일을 다시 만들지 않았다는 설명을 함께 보여 혼동을 줄임. +- 관리자 아이템 관리는 기존의 `미사용 사용자 업로드만 보기` 체크박스 대신 `전체 / 사용자 업로드 / 템플릿 사용 이미지 / 관리자 보관 자산 / 미사용 사용자 / 미사용 관리자` 필터로 바꿔, 관리자 업로드 자산도 별도로 확인할 수 있게 정리함. +- 게임 관리의 선택된 게임 설정은 더 이상 우측 사이드바 아래쪽에 쌓지 않고, 본문 상단에 썸네일과 공개 상태·썸네일 적용·게임 삭제 액션을 함께 둔 카드로 옮겨 게임 목록이 많아져도 작업 영역을 더 안정적으로 읽을 수 있게 조정함. + ## 2026-04-02 v1.3.63 - 관리자 이미지 최적화 최근 작업 목록은 더 이상 내부 카테고리 문자열 `custom / tierlists / games / avatars`를 그대로 노출하지 않고, 각각 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타`처럼 사람이 이해할 수 있는 이름으로 표시함. - 같은 이미지 해시를 다시 업로드해 기존 최적화 파일을 재사용한 경우에는 최근 작업 목록에 `기존 최적화 파일 재사용` 문구를 함께 보여, 새로 압축된 건지 중복 자산이 재사용된 건지 운영자가 바로 구분할 수 있게 함. diff --git a/frontend/src/components/admin/AdminGamesSection.vue b/frontend/src/components/admin/AdminGamesSection.vue index 0a7e583..4d38aeb 100644 --- a/frontend/src/components/admin/AdminGamesSection.vue +++ b/frontend/src/components/admin/AdminGamesSection.vue @@ -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) +}