From fb00ddb1d8d97aa214175092164c68c30406ddb6 Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 1 Apr 2026 15:59:09 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v1.3.34=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 117 ++++++++++++---- backend/src/lib/image-storage.js | 2 +- backend/src/routes/admin.js | 15 +- docs/todo.md | 2 + docs/update.md | 6 + frontend/src/views/AdminView.vue | 233 ++++++++++++++++++++++++------- 6 files changed, 291 insertions(+), 84 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index a2343a3..a1c8ae8 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -649,6 +649,11 @@ async function listGameItems(gameId) { return rows.map(mapGameItemRow) } +async function findGameItemById(itemId) { + const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId]) + return mapGameItemRow(rows[0]) +} + async function getGameDetail(gameId) { const game = await findGameById(gameId) if (!game) return null @@ -1208,32 +1213,60 @@ async function getCustomItemUsageMeta() { async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) { const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200) const normalizedPage = Math.max(Number(page) || 1, 1) - const hasQuery = !!(queryText || '').trim() - const search = `%${(queryText || '').trim()}%` - const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : '' - const params = hasQuery ? [search, search, search, search] : [] + const searchText = (queryText || '').trim() + const hasQuery = !!searchText + const search = `%${searchText}%` - const rows = await query( - ` - SELECT - c.id, - c.owner_id, - c.src, - c.label, - c.created_at, - u.nickname, - u.email - FROM custom_items c - INNER JOIN users u ON u.id = c.owner_id - ${whereClause} - ORDER BY c.created_at DESC - `, - params - ) + const [customRows, gameItemRows, usageMeta] = await Promise.all([ + query( + ` + SELECT + c.id, + c.owner_id, + c.src, + c.label, + c.created_at, + u.nickname, + u.email + FROM custom_items c + INNER JOIN users u ON u.id = c.owner_id + ${hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''} + ORDER BY c.created_at DESC + `, + hasQuery ? [search, search, search, search] : [] + ), + query( + ` + SELECT + gi.id, + gi.game_id, + gi.src, + gi.label, + gi.created_at, + g.name AS game_name + FROM game_items gi + INNER JOIN games g ON g.id = gi.game_id + ${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.game_id LIKE ? OR g.name LIKE ?' : ''} + ORDER BY gi.created_at DESC + `, + hasQuery ? [search, search, search, search] : [] + ), + getCustomItemUsageMeta(), + ]) - const { usageMap, linkedGamesMap } = await getCustomItemUsageMeta() - const allItems = rows - .map((row) => ({ + const templateLinkedBySrc = new Map() + gameItemRows.forEach((row) => { + if (!row?.src) return + if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map()) + templateLinkedBySrc.get(row.src).set(row.game_id, { + id: row.game_id, + name: row.game_name || row.game_id, + }) + }) + + const customItems = customRows.map((row) => { + const linkedGames = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()) + return { id: row.id, ownerId: row.owner_id, src: row.src, @@ -1241,10 +1274,37 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl createdAt: Number(row.created_at), ownerName: row.nickname || row.email, ownerEmail: row.email, - usageCount: usageMap.get(row.id) || 0, - linkedGames: linkedGamesMap.get(row.id) || [], - })) - .filter((item) => (orphanOnly ? item.usageCount === 0 : true)) + usageCount: usageMeta.usageMap.get(row.id) || 0, + linkedGames, + sourceType: 'user', + sourceLabel: '사용자 업로드', + canDelete: true, + } + }) + + const templateItems = gameItemRows.map((row) => ({ + id: row.id, + ownerId: '', + src: row.src, + label: row.label, + createdAt: Number(row.created_at), + ownerName: row.game_name || row.game_id, + ownerEmail: '', + usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size, + linkedGames: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()), + sourceType: 'template', + sourceLabel: '관리자 템플릿', + canDelete: false, + sourceGameId: row.game_id, + sourceGameName: row.game_name || row.game_id, + })) + + const allItems = [...customItems, ...templateItems] + .filter((item) => { + if (!orphanOnly) return true + return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0 + }) + .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0)) const total = allItems.length const offset = (normalizedPage - 1) * normalizedLimit @@ -1935,6 +1995,7 @@ module.exports = { listGames, findGameById, listGameItems, + findGameItemById, getGameDetail, createGame, updateGameThumbnail, diff --git a/backend/src/lib/image-storage.js b/backend/src/lib/image-storage.js index 1bb1cbf..8dc50e3 100644 --- a/backend/src/lib/image-storage.js +++ b/backend/src/lib/image-storage.js @@ -75,7 +75,7 @@ async function optimizeAndPersist({ file, width, height, fit, quality }) { } } - const filename = String(Date.now()) + '-' + nanoid() + '.webp' + const filename = nanoid() + '.webp' const absoluteDir = path.join(UPLOAD_ROOT, OPTIMIZED_DIR) const absolutePath = path.join(absoluteDir, filename) const src = '/uploads/' + OPTIMIZED_DIR + '/' + filename diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 88e2065..6d3d965 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -8,6 +8,7 @@ const { nanoid } = require('nanoid') const { findUserById, findGameById, + findGameItemById, createGame, listGames, updateGameThumbnail, @@ -322,12 +323,12 @@ async function removeCustomItemFiles(items) { ) } -async function promoteCustomItemToGameItem({ customItem, gameId }) { +async function promoteLibraryItemToGameItem({ item, gameId }) { return createGameItem({ id: nanoid(), gameId, - src: customItem.src || '', - label: customItem.label, + src: item.src || '', + label: item.label, }) } @@ -428,6 +429,8 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => { const result = await listCustomItems({ page: 1, limit: 200, orphanOnly: false }) const target = result.items.find((item) => item.id === req.params.itemId) if (!target) return res.status(404).json({ error: 'not_found' }) + if (!target.canDelete) return res.status(409).json({ error: 'item_locked' }) + if (target.linkedGames.length > 0) return res.status(409).json({ error: 'item_linked' }) if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' }) const items = await findCustomItemsByIds([target.id]) @@ -447,9 +450,11 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => { if (!game) return res.status(404).json({ error: 'game_not_found' }) const customItem = await findCustomItemById(req.params.itemId) - if (!customItem) return res.status(404).json({ error: 'not_found' }) + const gameItem = customItem ? null : await findGameItemById(req.params.itemId) + const sourceItem = customItem || gameItem + if (!sourceItem) return res.status(404).json({ error: 'not_found' }) - const item = await promoteCustomItemToGameItem({ customItem, gameId: game.id }) + const item = await promoteLibraryItemToGameItem({ item: sourceItem, gameId: game.id }) res.json({ item }) }) diff --git a/docs/todo.md b/docs/todo.md index 1292e86..39632be 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -10,3 +10,5 @@ - production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다. - helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다. - 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다. + +- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다. diff --git a/docs/update.md b/docs/update.md index 1b26116..5df98fe 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 로그 +## 2026-04-01 v1.3.34 +- 관리자 아이템 관리 오른쪽 사이드에서는 `가져올 게임` 셀렉트를 제거하고, 사용자 업로드와 관리자 템플릿 이미지를 함께 검수하는 라이브러리 흐름으로 단순화함. +- 아이템 상세 모달은 좌측에 검색/정렬 가능한 게임 리스트를 두고 우측에 이미지·메타·액션을 배치하는 2단 레이아웃으로 재구성해, 많은 게임 속에서도 직접 검수 후 템플릿에 연결하기 쉽게 정리함. +- 아이템 라이브러리에는 이제 관리자 템플릿 이미지도 함께 표시하고, 배지로 `사용자 업로드 / 관리자 템플릿`을 구분하며 새 업로드 WebP 파일명에서는 시간 정보처럼 보이는 접두 숫자를 제거함. +- 템플릿 아이템까지 함께 보이는 구조에 맞춰 삭제 API도 사용자 업로드이면서 템플릿에 연결되지 않은 항목만 지울 수 있도록 안전 장치를 보강함. + ## 2026-04-01 v1.3.33 - 라이트모드/다크모드 2차 보정으로 관리자 화면과 티어 에디터의 카드, 패널, 입력창, 모달, 썸네일 프레임을 전역 테마 변수 기준으로 다시 맞춰, 후속 화면에서도 명도 차가 더 자연스럽게 이어지도록 정리함. - 공통 셸도 함께 손봐서 좌측 사이드 아이콘 필터와 텍스트 대비를 테마 변수 기반으로 전환하고, 가이드 모달·축소 검색 모달·내비 활성 상태까지 라이트모드에서 읽기 쉬운 톤으로 보정함. diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 1c5965b..f9ccfb5 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -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() {