From 47638b8b3e8a7ca1da096b4500305f23fc36eccc Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 6 Apr 2026 12:10:46 +0900 Subject: [PATCH] admin: streamline item modal actions --- backend/src/db.js | 38 ++++++++++++++++-- backend/src/routes/admin.js | 26 ++++++++++++ docs/history.md | 5 +++ docs/update.md | 7 ++++ frontend/src/components/TagBadgeInput.vue | 13 ++++++ .../src/composables/useAdminCustomItems.js | 40 ++++++++++++------- frontend/src/lib/api.js | 6 ++- frontend/src/views/AdminView.vue | 35 ++++++++++++---- 8 files changed, 143 insertions(+), 27 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index b9a18ff..2ad86a7 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -148,6 +148,37 @@ function mapCustomItemRow(row) { } } +function getSharedItemDisplayPriority(item) { + if (!item) return 99 + if (item.sourceType === 'user' && !item.replacedAt) return 0 + if (item.sourceType === 'user') return 1 + if (item.sourceType === 'template') return 2 + if (item.sourceType === 'asset' || item.isAssetLibraryItem) return 3 + return 4 +} + +function collapseSharedLibraryItems(items) { + const grouped = new Map() + for (const item of items || []) { + const key = String(item?.src || '').trim() + if (!key) continue + if (!grouped.has(key)) grouped.set(key, []) + grouped.get(key).push(item) + } + + return Array.from(grouped.values()) + .map((group) => + group + .slice() + .sort((a, b) => { + const priorityDiff = getSharedItemDisplayPriority(a) - getSharedItemDisplayPriority(b) + if (priorityDiff !== 0) return priorityDiff + return Number(b.createdAt || 0) - Number(a.createdAt || 0) + })[0] + ) + .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0)) +} + function mapImageAssetRow(row) { if (!row) return null return { @@ -1832,7 +1863,7 @@ async function getCustomItemUsageMeta() { } } -async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMode = 'all' } = {}) { +async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMode = 'all', collapseShared = false } = {}) { const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200) const normalizedPage = Math.max(Number(page) || 1, 1) const searchText = (queryText || '').trim() @@ -2076,9 +2107,10 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod }) .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0)) - const total = allItems.length + const visibleItems = collapseShared ? collapseSharedLibraryItems(allItems) : allItems + const total = visibleItems.length const offset = (normalizedPage - 1) * normalizedLimit - const pagedItems = allItems.slice(offset, offset + normalizedLimit) + const pagedItems = visibleItems.slice(offset, offset + normalizedLimit) return { items: pagedItems, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index c6f7444..0e2a9a9 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -394,6 +394,10 @@ 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), + collapseShared: z + .union([z.string(), z.boolean(), z.number()]) + .optional() + .transform((value) => value === true || value === 1 || value === '1' || value === 'true'), filter: z .enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused', 'unused-user', 'replaced-user', 'unused-admin']) .optional() @@ -407,6 +411,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => { page: parsed.data.page, limit: parsed.data.limit, filterMode: parsed.data.filter, + collapseShared: parsed.data.collapseShared, }) res.json(result) }) @@ -862,6 +867,27 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => { res.json({ item }) }) +router.post('/custom-items/:itemId/unlink-template', requireAdmin, async (req, res) => { + const schema = z.object({ + topicId: z.string().trim().min(1), + }) + const parsed = schema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const template = await findTopicById(parsed.data.topicId) + if (!template) return res.status(404).json({ error: 'topic_not_found' }) + + const sourceItem = await findLibraryItemForReplacement(req.params.itemId) + if (!sourceItem?.src) return res.status(404).json({ error: 'not_found' }) + + const templateItems = await listTopicItems(template.id) + const matchedItems = templateItems.filter((item) => item?.src === sourceItem.src) + if (!matchedItems.length) return res.status(404).json({ error: 'linked_template_item_not_found' }) + + await Promise.all(matchedItems.map((item) => deleteTopicItem(item.id))) + res.json({ ok: true, deletedCount: matchedItems.length, topicId: template.id, src: sourceItem.src }) +}) + router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => { const schema = z.object({ targetItemId: z.string().trim().min(1), diff --git a/docs/history.md b/docs/history.md index 8387082..4ff8d67 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-04-06 v1.4.88 +- 같은 이미지를 사용자 항목, 템플릿 항목, 관리자 자산으로 각각 따로 카드에 늘어놓으면 운영자가 실제로 보고 싶은 “이미지 단위 상태”보다 내부 저장 단위가 더 크게 드러나므로, 관리자 목록과 검색은 기본적으로 같은 `src`를 하나로 묶어 보여주는 편이 더 자연스럽다고 정리했다. +- 아이템 모달의 연결 템플릿 배지는 단순히 해당 템플릿 화면으로 점프하는 것보다, 그 자리에서 `이 템플릿에서 제외`를 바로 수행하는 액션이 훨씬 직접적이므로 배지에 제거 버튼을 붙이는 쪽이 더 낫다고 판단했다. +- 한글 태그 입력은 IME 조합 중 `Enter`가 중간 문자열까지 함께 커밋될 수 있으므로, 배지형 태그 입력에서는 조합 상태를 명시적으로 감지해 완성된 문자열만 태그로 추가하는 편이 맞다고 정리했다. + ## 2026-04-06 v1.4.87 - 템플릿 태그는 이름/slug 검색과 역할이 겹치고, 운영자가 실제로 원하는 것은 “템플릿 찾기”보다 “아이템 묶음 분류”에 가까웠으므로 템플릿 화면에서 직접 노출하지 않는 편이 더 맞다고 정리했다. - 태그 입력도 카드 곳곳에 흩어져 있으면 메인 작업인 업로드와 이름 정리가 묻히기 쉬우므로, 태그는 관리자 아이템 모달에서만 배지형으로 다루고 템플릿 화면 본문은 가볍게 유지하는 쪽을 택했다. diff --git a/docs/update.md b/docs/update.md index ea0f077..8bd1e3c 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,12 @@ # 업데이트 로그 +## 2026-04-06 v1.4.88 +- 관리자 아이템 목록과 개별 아이템 검색에서는 같은 이미지 `src`를 공유하는 항목을 하나로 묶어 보여주도록 조정했다. 사용자 아이템, 템플릿 아이템, 관리자 자산이 같은 이미지를 가리키는 경우 카드가 반복해서 보이던 문제를 줄였다. +- 아이템 모달의 `이 이미지를 사용하는 템플릿` 배지는 더 이상 단순 이동 버튼이 아니고, 각 배지의 `X` 버튼으로 해당 템플릿에서 이미지를 바로 제외할 수 있게 바꿨다. +- 아이템 모달의 `새 템플릿 만들기` 버튼은 현재 흐름에선 분기만 늘린다고 보고 숨겼다. 이제 아이템 추가는 이미 선택한 템플릿 기준으로만 진행된다. +- 배지형 태그 입력은 한글 IME 조합 중 `Enter`를 눌렀을 때 초성/중간 문자열이 중복 등록되던 문제를 막기 위해 조합 중 입력을 따로 감지하도록 보강했다. +- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build` + ## 2026-04-06 v1.4.87 - 템플릿 설정 화면에서는 더 이상 템플릿 태그를 직접 입력하지 않도록 정리했다. 템플릿 자체는 이름과 slug로만 관리하고, 운영용 태그는 아이템 모달 안에서만 다루는 흐름으로 단순화했다. - 관리자 아이템 모달의 태그 입력은 쉼표 문자열 대신 배지형 입력으로 바꿨다. 태그를 입력하고 `Enter`를 누르면 아래에 배지로 붙고, 각 배지의 `X` 버튼으로 개별 제거할 수 있다. diff --git a/frontend/src/components/TagBadgeInput.vue b/frontend/src/components/TagBadgeInput.vue index f74b97e..5f11480 100644 --- a/frontend/src/components/TagBadgeInput.vue +++ b/frontend/src/components/TagBadgeInput.vue @@ -12,6 +12,7 @@ const props = defineProps({ const emit = defineEmits(['update:modelValue']) const draft = ref('') +const isComposing = ref(false) const normalizedTags = computed(() => Array.from( @@ -50,6 +51,7 @@ function removeTag(tag) { } function handleKeydown(event) { + if (event.isComposing || isComposing.value) return if (event.key === 'Enter') { event.preventDefault() addDraftTag() @@ -62,8 +64,17 @@ function handleKeydown(event) { } function handleBlur() { + if (isComposing.value) return addDraftTag() } + +function handleCompositionStart() { + isComposing.value = true +} + +function handleCompositionEnd() { + isComposing.value = false +} diff --git a/frontend/src/composables/useAdminCustomItems.js b/frontend/src/composables/useAdminCustomItems.js index 4be50d1..f3322a6 100644 --- a/frontend/src/composables/useAdminCustomItems.js +++ b/frontend/src/composables/useAdminCustomItems.js @@ -1,5 +1,3 @@ -import { nextTick } from 'vue' - export function useAdminCustomItems({ api, toast, @@ -26,8 +24,6 @@ export function useAdminCustomItems({ selectedTemplateId, refreshCustomItems, loadTemplate, - setTab, - selectAdminTemplate, resetMessages, success, error, @@ -76,6 +72,7 @@ export function useAdminCustomItems({ page: 1, limit: 50, filter: 'all', + collapseShared: true, }) customItemReplacementItems.value = (data.items || []).filter((item) => item?.id && item.id !== currentItemId) } catch (e) { @@ -138,15 +135,6 @@ export function useAdminCustomItems({ customItemDeleteModalOpen.value = false } - function jumpToTemplateAdmin(templateId) { - if (!templateId) return - closeCustomItemModal() - setTab('template-admin') - nextTick(() => { - selectAdminTemplate(templateId) - }) - } - async function removeCustomItem(item = modalTargetCustomItem.value) { resetMessages() if (!item) return @@ -255,6 +243,30 @@ export function useAdminCustomItems({ } } + async function unlinkCustomItemTemplate(item = modalTargetCustomItem.value, template) { + resetMessages() + if (!item?.id || !template?.id) { + error.value = '제외할 템플릿 정보를 찾지 못했어요.' + return + } + + const ok = window.confirm(`"${template.name}" 템플릿에서 이 이미지를 제외할까요?`) + if (!ok) return + + try { + await api.unlinkAdminCustomItemTemplate(item.id, { topicId: template.id }) + if (selectedTemplateId.value === template.id) await loadTemplate() + await refreshCustomItems() + modalTargetCustomItem.value = { + ...item, + linkedTemplates: (item.linkedTemplates || []).filter((entry) => entry.id !== template.id), + } + success.value = `"${template.name}" 템플릿에서 이미지를 제외했어요.` + } catch (e) { + error.value = '템플릿 연결 해제에 실패했어요.' + } + } + async function replaceCustomItem(item = modalTargetCustomItem.value) { resetMessages() const targetItem = customItemReplacementItems.value.find((entry) => entry.id === customItemReplacementTargetId.value) @@ -315,12 +327,12 @@ export function useAdminCustomItems({ closeCustomItemModal, openCustomItemDeleteModal, closeCustomItemDeleteModal, - jumpToTemplateAdmin, removeCustomItem, removeUnusedCustomItems, showUnusedCustomItems, saveCustomItemModalLabel, promoteCustomItem, + unlinkCustomItemTemplate, refreshReplacementCandidates, replaceCustomItem, restoreCustomItem, diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 343a3de..acd7918 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -77,9 +77,9 @@ export const api = { request(`/api/admin/templates/${encodeURIComponent(templateId)}`, { method: 'PATCH', body: payload }), updateAdminTemplateItem: (templateId, itemId, payload) => request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }), - listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) => + listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all', collapseShared = false } = {}) => request( - `/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}` + `/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}&collapseShared=${encodeURIComponent(collapseShared ? '1' : '0')}` ), listAdminTierLists: ({ q = '', topicId = '', sort = 'recent', minFavorites = 0, page = 1, limit = 50 } = {}) => request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&sort=${encodeURIComponent(sort)}&minFavorites=${encodeURIComponent(minFavorites)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`), @@ -104,6 +104,8 @@ export const api = { cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }), promoteAdminTemplateItem: (itemId, payload) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }), + unlinkAdminCustomItemTemplate: (itemId, payload) => + request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/unlink-template`, { method: 'POST', body: payload }), replaceAdminCustomItem: (itemId, payload) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/replace`, { method: 'POST', body: payload }), restoreAdminCustomItem: (itemId) => diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index fe19609..72c6add 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -891,6 +891,7 @@ async function refreshCustomItems() { page: customItemPage.value, limit: customItemLimit.value, filter: customItemFilter.value, + collapseShared: !['user', 'template', 'unused-user', 'replaced-user'].includes(customItemFilter.value), }) customItems.value = data.items || [] customItemTotal.value = data.total || 0 @@ -1096,12 +1097,12 @@ const { closeCustomItemModal, openCustomItemDeleteModal, closeCustomItemDeleteModal, - jumpToTemplateAdmin, removeCustomItem, removeUnusedCustomItems, showUnusedCustomItems, saveCustomItemModalLabel, promoteCustomItem, + unlinkCustomItemTemplate, refreshReplacementCandidates, replaceCustomItem, restoreCustomItem, @@ -1131,8 +1132,6 @@ const { selectedTemplateId, refreshCustomItems, loadTemplate, - setTab, - selectAdminTemplate, resetMessages, success, error, @@ -1826,6 +1825,7 @@ async function searchTemplateLibraryItems() { page: 1, limit: 50, filter: 'library', + collapseShared: true, }) templateLibraryItemResults.value = (data.items || []).filter((item) => item?.id) templateLibraryItemSelectedIds.value = templateLibraryItemSelectedIds.value.filter((id) => @@ -2377,7 +2377,6 @@ function openUserProfile(user) {
-