From fd2969c78040515b0aac84509daace428f29e34c Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 1 Apr 2026 17:07:42 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v1.3.39=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=EC=99=80=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 53 ++++++++++++++++++++++++----- backend/src/routes/admin.js | 28 +++++++++++++++ docs/todo.md | 4 +++ docs/update.md | 5 +++ frontend/src/lib/api.js | 2 ++ frontend/src/views/AdminView.vue | 58 ++++++++++++++++++++++++++++---- 6 files changed, 136 insertions(+), 14 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 0c0d7ed..adea41d 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -95,6 +95,7 @@ function mapImageAssetRow(row) { id: row.id, contentHash: row.content_hash, src: row.src || '', + labelOverride: row.label_override || '', mimeType: row.mime_type || 'image/webp', byteSize: Number(row.byte_size || 0), originalByteSize: Number(row.original_byte_size || 0), @@ -342,6 +343,7 @@ async function ensureSchema() { id VARCHAR(64) PRIMARY KEY, content_hash CHAR(64) NOT NULL UNIQUE, src VARCHAR(255) NOT NULL UNIQUE, + label_override VARCHAR(120) NOT NULL DEFAULT '', mime_type VARCHAR(32) NOT NULL DEFAULT 'image/webp', byte_size INT UNSIGNED NOT NULL, original_byte_size INT UNSIGNED NOT NULL, @@ -352,6 +354,11 @@ async function ensureSchema() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) + const imageAssetLabelColumns = await query("SHOW COLUMNS FROM image_assets LIKE 'label_override'") + if (!imageAssetLabelColumns.length) { + await query("ALTER TABLE image_assets ADD COLUMN label_override VARCHAR(120) NOT NULL DEFAULT '' AFTER src") + } + await query(` CREATE TABLE IF NOT EXISTS image_optimization_jobs ( id VARCHAR(64) PRIMARY KEY, @@ -679,7 +686,7 @@ async function updateGameThumbnail(gameId, thumbnailSrc) { async function findImageAssetByHash(contentHash) { const rows = await query( - 'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE content_hash = ? LIMIT 1', + 'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE content_hash = ? LIMIT 1', [contentHash] ) return mapImageAssetRow(rows[0]) @@ -687,7 +694,7 @@ async function findImageAssetByHash(contentHash) { async function findImageAssetBySrc(src) { const rows = await query( - 'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE src = ? LIMIT 1', + 'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE src = ? LIMIT 1', [src] ) return mapImageAssetRow(rows[0]) @@ -765,7 +772,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) { const cutoff = now() - safeMinAgeHours * 60 * 60 * 1000 const assets = (await query( - `SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE created_at <= ? ORDER BY created_at ASC LIMIT ${safeLimit}`, + `SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE created_at <= ? ORDER BY created_at ASC LIMIT ${safeLimit}`, [cutoff] )).map(mapImageAssetRow) @@ -806,7 +813,7 @@ async function deleteImageAssets(ids) { if (!uniqueIds.length) return [] const placeholders = uniqueIds.map(() => '?').join(', ') 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 IN (${placeholders})`, + `SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id IN (${placeholders})`, uniqueIds ) await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds) @@ -931,14 +938,14 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) { async function listImageAssets() { const rows = await query( - 'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets ORDER BY created_at DESC' + 'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets ORDER BY created_at DESC' ) 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', + 'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1', [id] ) return mapImageAssetRow(rows[0]) @@ -1069,6 +1076,34 @@ async function updateGameItemLabel(itemId, label) { return mapGameItemRow(rows[0]) } +async function updateCustomItemLabel(itemId, label) { + await query('UPDATE custom_items SET label = ? WHERE id = ?', [label, itemId]) + 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 + WHERE c.id = ? + LIMIT 1 + `, [itemId]) + const row = rows[0] + if (!row) return null + return { + id: row.id, + ownerId: row.owner_id, + src: row.src, + label: row.label, + createdAt: Number(row.created_at), + ownerName: row.nickname || row.email, + ownerEmail: row.email, + } +} + +async function updateImageAssetLabel(assetId, label) { + await query('UPDATE image_assets SET label_override = ? WHERE id = ?', [label, assetId]) + const rows = await query('SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1', [assetId]) + return mapImageAssetRow(rows[0]) +} + async function deleteGameItem(itemId) { const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId]) const gameId = gameItemRows[0]?.game_id @@ -1261,7 +1296,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl ), query( ` - SELECT ia.id, ia.src, ia.created_at + SELECT ia.id, ia.src, ia.label_override, ia.created_at FROM image_assets ia WHERE ia.src LIKE '/uploads/assets/%' ${hasQuery ? 'AND ia.src LIKE ?' : ''} @@ -1309,7 +1344,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl assetId: row.id, ownerId: '', src: row.src, - label: (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음', + label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음', createdAt: Number(row.created_at || 0), ownerName: '관리자 보관 자산', ownerEmail: '', @@ -2057,6 +2092,8 @@ module.exports = { getImageAssetStats, createGameItem, updateGameItemLabel, + updateCustomItemLabel, + updateImageAssetLabel, deleteGameItem, deleteGame, updateGameDisplayOrder, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index bc28678..4506199 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -15,6 +15,8 @@ const { updateGameThumbnail, createGameItem, updateGameItemLabel, + updateCustomItemLabel, + updateImageAssetLabel, deleteGameItem, deleteGame, updateGameDisplayOrder, @@ -192,6 +194,32 @@ router.delete('/games/:gameId', requireAdmin, async (req, res) => { res.json({ ok: true }) }) +router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => { + const schema = z.object({ + label: z.string().trim().min(1).max(60), + sourceType: z.enum(['template', 'user']).optional().default('user'), + }) + const parsed = schema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const itemId = req.params.itemId + if (itemId.startsWith('asset:')) { + const updated = await updateImageAssetLabel(itemId.slice(6), parsed.data.label) + if (!updated) return res.status(404).json({ error: 'not_found' }) + return res.json({ item: updated }) + } + + if (parsed.data.sourceType === 'template') { + const updated = await updateGameItemLabel(itemId, parsed.data.label) + if (!updated) return res.status(404).json({ error: 'not_found' }) + return res.json({ item: updated }) + } + + const updated = await updateCustomItemLabel(itemId, parsed.data.label) + if (!updated) return res.status(404).json({ error: 'not_found' }) + return res.json({ item: updated }) +}) + router.get('/custom-items', requireAdmin, async (req, res) => { const schema = z.object({ q: z.string().trim().max(120).optional().default(''), diff --git a/docs/todo.md b/docs/todo.md index af86b80..f2541e1 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,4 +1,6 @@ # 할 일 및 이슈 +- 아이템 관리, 티어표 관리, 전체 티어표 관리 등에서 아이템의 이름을 관리자가 직접 수정할 수 있어야 한다. +(사용자가 이름없이 파일을 그대로 올렸을 경우 해당 아이템을 사용 할 수 없기 때문) ## 중기 개선 - 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다. @@ -18,3 +20,5 @@ - 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다. - 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다. + +- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다. diff --git a/docs/update.md b/docs/update.md index 1e960e1..a5d305e 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-01 v1.3.39 +- 관리자 템플릿 요청 미리보기는 요청 시 저장된 보드 스냅샷이 비어 있을 경우 요청 아이템 배열을 fallback으로 사용해, 대표 썸네일만 보이는 상황을 줄이고 요청 내용을 더 안정적으로 확인할 수 있게 보정함. +- 관리자 아이템 상세 모달에는 아이템 이름 입력과 저장 버튼을 추가해, 템플릿 아이템·사용자 업로드·보관 자산 모두 파일명과 무관하게 사람이 읽기 좋은 이름으로 다시 정리할 수 있게 함. +- 보관 자산용 image asset에는 이름 override 컬럼을 추가해, 무작위 WebP 파일명을 그대로 노출하지 않고 라이브러리 표시명만 따로 관리할 수 있게 확장함. + ## 2026-04-01 v1.3.38 - Settings 화면 오른쪽 사이드의 테마 설정 패널은 다시 쓰기 전까지 숨김 처리하고, 현재 기본 다크모드를 유지한 채 다른 화면과 동일하게 스폰서 광고만 노출되도록 정리함. - 관리자 아이템 모달에서 템플릿에 사용 중인 게임 배지는 다크모드에서도 읽히는 텍스트 색으로 맞추고, hover/focus 전환 효과를 추가해 상호작용이 더 분명하게 보이도록 보강함. diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 1d7f677..1f0d620 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -55,6 +55,8 @@ export const api = { cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }), promoteAdminCustomItem: (itemId, payload) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }), + updateAdminCustomItemLabel: (itemId, payload) => + request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }), promoteAdminTierListItems: (tierListId, payload) => request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }), createAdminGameTemplateFromTierList: (tierListId, payload) => diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index ae8ee9c..8956ec9 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -64,6 +64,8 @@ const modalUserDraftEmail = ref('') const modalUserDraftNickname = ref('') const modalUserDraftIsAdmin = ref(false) const modalTargetCustomItem = ref(null) +const customItemModalDraftLabel = ref('') +const customItemModalLabelSaving = ref(false) const users = ref([]) const userQuery = ref('') @@ -1076,6 +1078,7 @@ function pushCustomItemModalHistoryState() { function openCustomItemModal(item) { modalTargetCustomItem.value = item || null + customItemModalDraftLabel.value = item?.label || '' customItemModalTargetGameId.value = '' customItemModalGameQuery.value = '' customItemModalGameSort.value = 'recent' @@ -1087,6 +1090,8 @@ function closeCustomItemModal({ fromPopState = false } = {}) { customItemModalOpen.value = false customItemDeleteModalOpen.value = false modalTargetCustomItem.value = null + customItemModalDraftLabel.value = '' + customItemModalLabelSaving.value = false customItemModalTargetGameId.value = '' customItemModalGameQuery.value = '' customItemModalGameSort.value = 'recent' @@ -1158,6 +1163,25 @@ async function removeUnusedCustomItems() { } } +async function saveCustomItemModalLabel() { + const item = modalTargetCustomItem.value + const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60) + if (!item || !nextLabel || nextLabel === item.label || customItemModalLabelSaving.value) return + + try { + customItemModalLabelSaving.value = true + const data = await api.updateAdminCustomItemLabel(item.id, { label: nextLabel, sourceType: item.sourceType }) + item.label = data.item?.label || nextLabel + customItemModalDraftLabel.value = item.label + customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label } : entry)) + toast.success('아이템 이름을 변경했어요.') + } catch (e) { + error.value = '아이템 이름 변경에 실패했어요.' + } finally { + customItemModalLabelSaving.value = false + } +} + async function promoteCustomItem(item) { resetMessages() if (!customItemModalTargetGameId.value) { @@ -1215,6 +1239,7 @@ function previewRequestPoolItems(preview) { } function openTemplateRequestPreview(request) { + const snapshotItems = Array.isArray(request.snapshotItems) && request.snapshotItems.length ? request.snapshotItems : Array.isArray(request.items) ? request.items : [] previewTierList.value = { id: request.id, title: request.sourceTierListTitle || '템플릿 요청 미리보기', @@ -1222,7 +1247,7 @@ function openTemplateRequestPreview(request) { thumbnailSrc: request.thumbnailSrc || '', requestPreview: true, snapshotGroups: request.snapshotGroups || [], - snapshotItems: request.snapshotItems || [], + snapshotItems, snapshotShowCharacterNames: !!request.snapshotShowCharacterNames, } previewModalOpen.value = true @@ -2014,12 +2039,21 @@ async function saveFeaturedOrder() {
-
-
{{ modalTargetCustomItem.label }}
-
{{ modalTargetCustomItem.sourceLabel }}
+
+
{{ modalTargetCustomItem.label }}
+
{{ modalTargetCustomItem.sourceLabel }}
+
-
- +
+ + +
+
파일{{ modalTargetCustomItem.src.split('/').pop() }}
업로더/출처{{ modalTargetCustomItem.ownerName }}
@@ -3266,6 +3300,15 @@ async function saveFeaturedOrder() { overflow: auto; padding-right: 2px; } +.customItemModal__labelEditor { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: end; +} +.customItemModal__renameButton { + white-space: nowrap; +} .customItemModal__titleRow, .customItemModal__linked { display: grid; @@ -4016,6 +4059,9 @@ async function saveFeaturedOrder() { .customItemModal__content { min-height: 0; } + .customItemModal__labelEditor { + grid-template-columns: 1fr; + } .customItemModal__actions { grid-template-columns: 1fr; }