diff --git a/backend/src/db.js b/backend/src/db.js index ce37d0c..0c0d7ed 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -936,6 +936,14 @@ async function listImageAssets() { return rows.map(mapImageAssetRow) } +async function findImageAssetById(id) { + const rows = await query( + 'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1', + [id] + ) + return mapImageAssetRow(rows[0]) +} + async function getReferencedUploadFootprint() { const [referencedSrcs, assets] = await Promise.all([listReferencedUploadSources(), listImageAssets()]) const assetMap = new Map(assets.map((asset) => [asset.src, asset])) @@ -1217,7 +1225,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl const hasQuery = !!searchText const search = `%${searchText}%` - const [customRows, gameItemRows, usageMeta] = await Promise.all([ + const [customRows, gameItemRows, assetRows, usageMeta] = await Promise.all([ query( ` SELECT @@ -1251,6 +1259,16 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl `, hasQuery ? [search, search, search, search] : [] ), + query( + ` + SELECT ia.id, ia.src, ia.created_at + FROM image_assets ia + WHERE ia.src LIKE '/uploads/assets/%' + ${hasQuery ? 'AND ia.src LIKE ?' : ''} + ORDER BY ia.created_at DESC + `, + hasQuery ? [search] : [] + ), getCustomItemUsageMeta(), ]) @@ -1282,6 +1300,29 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl } }) + const templateSrcSet = new Set(gameItemRows.map((row) => row.src).filter(Boolean)) + const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean)) + const assetLibraryItems = assetRows + .filter((row) => row?.src && !templateSrcSet.has(row.src) && !customSrcSet.has(row.src)) + .map((row) => ({ + id: `asset:${row.id}`, + assetId: row.id, + ownerId: '', + src: row.src, + label: (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음', + createdAt: Number(row.created_at || 0), + ownerName: '관리자 보관 자산', + ownerEmail: '', + usageCount: 0, + linkedGames: [], + sourceType: 'template', + sourceLabel: '관리자 템플릿', + canDelete: true, + sourceGameId: '', + sourceGameName: '', + isAssetLibraryItem: true, + })) + const templateItems = gameItemRows.map((row) => ({ id: row.id, ownerId: '', @@ -1299,7 +1340,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl sourceGameName: row.game_name || row.game_id, })) - const allItems = [...customItems, ...templateItems] + const allItems = [...customItems, ...templateItems, ...assetLibraryItems] .filter((item) => { if (!orphanOnly) return true return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0 @@ -2001,6 +2042,7 @@ module.exports = { updateGameThumbnail, findImageAssetByHash, findImageAssetBySrc, + findImageAssetById, createImageAsset, createImageOptimizationJob, findImageOptimizationJobById, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 8f2a306..bc28678 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -9,6 +9,7 @@ const { findUserById, findGameById, findGameItemById, + findImageAssetById, createGame, listGames, updateGameThumbnail, @@ -309,6 +310,20 @@ router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => { res.json({ deletedCount }) }) +async function removeUploadFiles(srcs) { + await Promise.all( + (srcs || []).map(async (src) => { + if (!src || !src.startsWith('/uploads/')) return + const absolutePath = path.join(__dirname, '..', '..', src.replace(/^\//, '')) + try { + await fs.unlink(absolutePath) + } catch (e) { + if (e?.code !== 'ENOENT') throw e + } + }) + ) +} + async function removeCustomItemFiles(items) { await Promise.all( items.map(async (item) => { @@ -426,10 +441,19 @@ async function createGameTemplateFromRequest({ templateRequest, gameId, gameName } router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => { - const result = await listCustomItems({ page: 1, limit: 200, orphanOnly: false }) + const result = await listCustomItems({ page: 1, limit: 10000, 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.sourceType === 'template') { + if (String(target.id || '').startsWith('asset:')) { + const assetId = String(target.id).slice('asset:'.length) + const asset = await findImageAssetById(assetId) + if (!asset) return res.status(404).json({ error: 'not_found' }) + await deleteImageAssets([assetId]) + await removeUploadFiles([asset.src]) + return res.json({ ok: true, sourceType: 'template-asset' }) + } + await deleteGameItem(target.id) return res.json({ ok: true, sourceType: 'template' }) } diff --git a/docs/todo.md b/docs/todo.md index ef74744..8d406fb 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -14,3 +14,5 @@ - 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다. - 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다. + +- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다. diff --git a/docs/update.md b/docs/update.md index 9065fcd..4023c00 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 로그 +## 2026-04-01 v1.3.36 +- `내 티어표` 화면 헤더를 공통 `pageHead` 문법으로 통일하고, 라이트모드에서는 공통 `railHeader` 배경을 사이드 레일과 같은 톤으로 맞춰 화면 간 상단 밀도 차를 줄임. +- 관리자 아이템 상세 모달은 더 넓은 비율로 키우고, 템플릿에 연결된 게임 이름은 hover 가능한 버튼으로 바꿔 클릭 시 해당 게임이 선택된 `게임 관리` 탭으로 바로 이동할 수 있게 함. +- 관리자 아이템 라이브러리는 이제 게임에 연결된 템플릿 이미지뿐 아니라 연결이 해제된 `/uploads/assets/` 보관 자산도 함께 보여줘, 게임 목록에서 아이템을 제거해도 아이템 관리에서는 계속 검수·재연결할 수 있게 정리함. +- 아이템 관리 탭은 다른 탭으로 이동했다가 돌아오면 검색어와 필터를 초기화해, 결과가 남아 있어 목록이 비어 보이는 오해를 줄이도록 조정함. + ## 2026-04-01 v1.3.35 - 라이트모드에서 홈 게임 카드의 메타 텍스트와 대표 썸네일 플레이스홀더, 브랜드 타이틀 색을 다시 정리하고, 전체 밝기도 약간 눌러 눈부심이 덜한 회백색 톤으로 보정함. - 관리자 아이템 상세 모달은 더 넓은 2단 레이아웃으로 키우고, 브라우저 뒤로가기 시 페이지 이탈 대신 모달이 먼저 닫히도록 히스토리 동작을 보강함. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4e3fbd5..d5087f3 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -684,6 +684,7 @@ function submitGlobalSearch() { align-items: center; padding: 0 12px; border-bottom: 1px solid var(--theme-border); + background: var(--theme-rail-bg); box-sizing: border-box; } diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 0d80be5..128e172 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -400,6 +400,12 @@ function setTab(tab) { if (tab === 'tierlists') { tierlistsMode.value = 'requests' } + if (tab === 'items') { + customItemQuery.value = '' + customItemOrphanOnly.value = false + customItemPage.value = 1 + refreshCustomItems() + } } function setTierlistsMode(mode) { @@ -1110,6 +1116,15 @@ function closeCustomItemDeleteModal() { customItemDeleteModalOpen.value = false } +function jumpToGameAdmin(gameId) { + if (!gameId) return + closeCustomItemModal() + setTab('game-admin') + nextTick(() => { + selectAdminGame(gameId) + }) +} + async function removeCustomItem(item = modalTargetCustomItem.value) { resetMessages() if (!item) return @@ -2016,7 +2031,7 @@ async function saveFeaturedOrder() {
템플릿에 사용 중인 게임
- {{ game.name }} +
아직 템플릿에 연결된 게임이 없어요.
@@ -2035,7 +2050,7 @@ async function saveFeaturedOrder() {