diff --git a/backend/src/db.js b/backend/src/db.js index a1c8ae8..ce37d0c 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -1294,7 +1294,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl linkedGames: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()), sourceType: 'template', sourceLabel: '관리자 템플릿', - canDelete: false, + canDelete: true, sourceGameId: row.game_id, sourceGameName: row.game_name || row.game_id, })) diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 6d3d965..8f2a306 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -429,6 +429,11 @@ 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.sourceType === 'template') { + await deleteGameItem(target.id) + return res.json({ ok: true, sourceType: 'template' }) + } + 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' }) @@ -436,7 +441,7 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => { const items = await findCustomItemsByIds([target.id]) await deleteCustomItems([target.id]) await removeCustomItemFiles(items) - res.json({ ok: true }) + res.json({ ok: true, sourceType: 'user' }) }) router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => { diff --git a/docs/todo.md b/docs/todo.md index 39632be..ef74744 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -12,3 +12,5 @@ - 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다. - 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다. + +- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다. diff --git a/docs/update.md b/docs/update.md index 5df98fe..9065fcd 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-01 v1.3.35 +- 라이트모드에서 홈 게임 카드의 메타 텍스트와 대표 썸네일 플레이스홀더, 브랜드 타이틀 색을 다시 정리하고, 전체 밝기도 약간 눌러 눈부심이 덜한 회백색 톤으로 보정함. +- 관리자 아이템 상세 모달은 더 넓은 2단 레이아웃으로 키우고, 브라우저 뒤로가기 시 페이지 이탈 대신 모달이 먼저 닫히도록 히스토리 동작을 보강함. +- 아이템 라이브러리의 삭제 기준을 다시 정리해, 사용자 업로드는 어디에도 연결되지 않았을 때만 삭제하고 관리자 템플릿 이미지는 라이브러리에서도 해당 템플릿 항목을 제거할 수 있게 확장함. + ## 2026-04-01 v1.3.34 - 관리자 아이템 관리 오른쪽 사이드에서는 `가져올 게임` 셀렉트를 제거하고, 사용자 업로드와 관리자 템플릿 이미지를 함께 검수하는 라이브러리 흐름으로 단순화함. - 아이템 상세 모달은 좌측에 검색/정렬 가능한 게임 리스트를 두고 우측에 이미지·메타·액션을 배치하는 2단 레이아웃으로 재구성해, 많은 게임 속에서도 직접 검수 후 템플릿에 연결하기 쉽게 정리함. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index dcbd44f..4e3fbd5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1065,10 +1065,7 @@ function submitGlobalSearch() { font-size: 28px; font-weight: 900; letter-spacing: -0.05em; - background-image: linear-gradient(90deg, #ff75c3 0%, #ffa647 20%, #ffe83f 40%, #9fff5b 60%, #70e2ff 80%, #cd93ff 100%); - -webkit-background-clip: text; - background-clip: text; - color: transparent; + color: var(--theme-text-strong); } .workspaceHead__brandSub { diff --git a/frontend/src/style.css b/frontend/src/style.css index 9788fba..6821ad6 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -38,28 +38,28 @@ } :root[data-theme='light'] { - --theme-body-bg: #edf1f7; - --theme-shell-bg: rgba(244, 247, 252, 0.98); - --theme-rail-bg: rgba(248, 250, 253, 0.96); - --theme-main-bg: rgba(241, 244, 249, 0.98); - --theme-workspace-bg: rgba(250, 252, 255, 0.95); - --theme-card-bg: rgba(255, 255, 255, 0.98); - --theme-card-bg-hover: rgba(245, 248, 255, 0.98); - --theme-card-border: rgba(26, 32, 44, 0.1); - --theme-card-shadow: 0 18px 34px rgba(31, 41, 55, 0.08); - --theme-surface-soft: rgba(15, 23, 42, 0.05); - --theme-surface-soft-2: rgba(15, 23, 42, 0.07); - --theme-surface-soft-3: rgba(15, 23, 42, 0.1); - --theme-pill-bg: rgba(15, 23, 42, 0.04); - --theme-border: rgba(15, 23, 42, 0.1); - --theme-border-strong: rgba(15, 23, 42, 0.14); - --theme-text: rgba(20, 27, 40, 0.9); + --theme-body-bg: #e7ebf2; + --theme-shell-bg: rgba(237, 241, 247, 0.98); + --theme-rail-bg: rgba(243, 246, 251, 0.97); + --theme-main-bg: rgba(232, 236, 243, 0.98); + --theme-workspace-bg: rgba(247, 249, 252, 0.96); + --theme-card-bg: rgba(252, 253, 255, 0.98); + --theme-card-bg-hover: rgba(244, 247, 251, 0.98); + --theme-card-border: rgba(31, 41, 55, 0.11); + --theme-card-shadow: 0 18px 34px rgba(31, 41, 55, 0.07); + --theme-surface-soft: rgba(30, 41, 59, 0.055); + --theme-surface-soft-2: rgba(30, 41, 59, 0.075); + --theme-surface-soft-3: rgba(30, 41, 59, 0.105); + --theme-pill-bg: rgba(30, 41, 59, 0.045); + --theme-border: rgba(30, 41, 59, 0.11); + --theme-border-strong: rgba(30, 41, 59, 0.16); + --theme-text: rgba(20, 27, 40, 0.92); --theme-text-strong: rgba(10, 15, 28, 0.98); - --theme-text-muted: rgba(55, 65, 81, 0.74); - --theme-text-soft: rgba(75, 85, 99, 0.64); - --theme-text-faint: rgba(100, 116, 139, 0.82); - --theme-thumb-fallback-bg: #d8dde8; - --theme-select-arrow: rgba(55, 65, 81, 0.72); + --theme-text-muted: rgba(55, 65, 81, 0.76); + --theme-text-soft: rgba(75, 85, 99, 0.72); + --theme-text-faint: rgba(100, 116, 139, 0.88); + --theme-thumb-fallback-bg: #f6f8fb; + --theme-select-arrow: rgba(55, 65, 81, 0.74); --theme-danger-bg: rgba(239, 68, 68, 0.1); --theme-danger-border: rgba(239, 68, 68, 0.22); --theme-accent-bg: rgba(64, 110, 226, 0.94); diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index f9ccfb5..0d80be5 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -56,6 +56,7 @@ const userDeleteModalOpen = ref(false) const userRoleModalOpen = ref(false) const customItemModalOpen = ref(false) const customItemDeleteModalOpen = ref(false) +const customItemModalHistoryActive = ref(false) const modalTargetUser = ref(null) const modalPasswordDraft = ref('') const modalRoleNextAdmin = ref(false) @@ -194,13 +195,26 @@ const adminOverviewStats = computed(() => { ] }) +function handleAdminPopState() { + if (customItemDeleteModalOpen.value) { + customItemDeleteModalOpen.value = false + if (customItemModalOpen.value) pushCustomItemModalHistoryState() + return + } + if (customItemModalOpen.value) { + closeCustomItemModal({ fromPopState: true }) + } +} + onMounted(async () => { + if (typeof window !== 'undefined') window.addEventListener('popstate', handleAdminPopState) await auth.refresh() await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()]) await syncFeaturedSortable() }) onUnmounted(() => { + if (typeof window !== 'undefined') window.removeEventListener('popstate', handleAdminPopState) clearPreviewUrl('item') clearPreviewUrl('thumb') destroyFeaturedSortable() @@ -1048,26 +1062,44 @@ function moveCustomItemPage(direction) { refreshCustomItems() } +function pushCustomItemModalHistoryState() { + if (typeof window === 'undefined') return + window.history.pushState({ ...(window.history.state || {}), adminCustomItemModal: true }, '', window.location.href) + customItemModalHistoryActive.value = true +} + function openCustomItemModal(item) { modalTargetCustomItem.value = item || null customItemModalTargetGameId.value = '' customItemModalGameQuery.value = '' customItemModalGameSort.value = 'recent' customItemModalOpen.value = true + pushCustomItemModalHistoryState() } -function closeCustomItemModal() { +function closeCustomItemModal({ fromPopState = false } = {}) { customItemModalOpen.value = false + customItemDeleteModalOpen.value = false modalTargetCustomItem.value = null customItemModalTargetGameId.value = '' customItemModalGameQuery.value = '' customItemModalGameSort.value = 'recent' + + if (fromPopState) { + customItemModalHistoryActive.value = false + return + } + + if (customItemModalHistoryActive.value && typeof window !== 'undefined') { + customItemModalHistoryActive.value = false + window.history.back() + } } function openCustomItemDeleteModal(item) { if (!item) return - if (item.usageCount > 0) { - error.value = '사용 중인 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.' + if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) { + error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.' return } modalTargetCustomItem.value = item @@ -1081,8 +1113,8 @@ function closeCustomItemDeleteModal() { async function removeCustomItem(item = modalTargetCustomItem.value) { resetMessages() if (!item) return - if (item.usageCount > 0) { - error.value = '사용 중인 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.' + if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) { + error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.' return } @@ -1091,9 +1123,9 @@ async function removeCustomItem(item = modalTargetCustomItem.value) { closeCustomItemDeleteModal() closeCustomItemModal() await refreshCustomItems() - success.value = '미사용 사용자 업로드 이미지를 삭제했어요.' + success.value = item.sourceType === 'template' ? '선택한 템플릿 아이템을 제거했어요.' : '사용자 업로드 이미지를 삭제했어요.' } catch (e) { - error.value = '커스텀 이미지 삭제에 실패했어요.' + error.value = item.sourceType === 'template' ? '템플릿 아이템 제거에 실패했어요.' : '사용자 업로드 이미지 삭제에 실패했어요.' } } @@ -1939,7 +1971,7 @@ async function saveFeaturedOrder() {