Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47638b8b3e |
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-06 v1.4.88
|
||||
- 같은 이미지를 사용자 항목, 템플릿 항목, 관리자 자산으로 각각 따로 카드에 늘어놓으면 운영자가 실제로 보고 싶은 “이미지 단위 상태”보다 내부 저장 단위가 더 크게 드러나므로, 관리자 목록과 검색은 기본적으로 같은 `src`를 하나로 묶어 보여주는 편이 더 자연스럽다고 정리했다.
|
||||
- 아이템 모달의 연결 템플릿 배지는 단순히 해당 템플릿 화면으로 점프하는 것보다, 그 자리에서 `이 템플릿에서 제외`를 바로 수행하는 액션이 훨씬 직접적이므로 배지에 제거 버튼을 붙이는 쪽이 더 낫다고 판단했다.
|
||||
- 한글 태그 입력은 IME 조합 중 `Enter`가 중간 문자열까지 함께 커밋될 수 있으므로, 배지형 태그 입력에서는 조합 상태를 명시적으로 감지해 완성된 문자열만 태그로 추가하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-06 v1.4.87
|
||||
- 템플릿 태그는 이름/slug 검색과 역할이 겹치고, 운영자가 실제로 원하는 것은 “템플릿 찾기”보다 “아이템 묶음 분류”에 가까웠으므로 템플릿 화면에서 직접 노출하지 않는 편이 더 맞다고 정리했다.
|
||||
- 태그 입력도 카드 곳곳에 흩어져 있으면 메인 작업인 업로드와 이름 정리가 묻히기 쉬우므로, 태그는 관리자 아이템 모달에서만 배지형으로 다루고 템플릿 화면 본문은 가볍게 유지하는 쪽을 택했다.
|
||||
|
||||
@@ -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` 버튼으로 개별 제거할 수 있다.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -83,6 +94,8 @@ function handleBlur() {
|
||||
:maxlength="maxTagLength"
|
||||
@keydown="handleKeydown"
|
||||
@blur="handleBlur"
|
||||
@compositionstart="handleCompositionStart"
|
||||
@compositionend="handleCompositionEnd"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) {
|
||||
</div>
|
||||
<div class="customItemModal__pickerActions">
|
||||
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
|
||||
<button class="btn btn--ghost btn--small customItemModal__createTemplateButton" type="button" @click="openTemplateCreateModal">새 템플릿 만들기</button>
|
||||
</div>
|
||||
<template v-if="canReplaceModalTarget">
|
||||
<div class="customItemModal__pickerHead">
|
||||
@@ -2463,7 +2462,10 @@ function openUserProfile(user) {
|
||||
<div class="customItemModal__linked">
|
||||
<span class="customItemModal__label">이 이미지를 사용하는 템플릿</span>
|
||||
<div v-if="visibleLinkedTemplates.length" class="customItemModal__chips">
|
||||
<button v-for="template in visibleLinkedTemplates" :key="template.id" type="button" class="pill pill--link" @click="jumpToTemplateAdmin(template.id)">{{ template.name }}</button>
|
||||
<span v-for="template in visibleLinkedTemplates" :key="template.id" class="customItemModal__templateChip">
|
||||
<span>{{ template.name }}</span>
|
||||
<button class="customItemModal__templateChipRemove" type="button" @click="unlinkCustomItemTemplate(modalTargetCustomItem, template)">X</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="hint hint--tight">아직 템플릿에 연결된 항목이 없어요.</div>
|
||||
</div>
|
||||
@@ -4034,9 +4036,6 @@ function openUserProfile(user) {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.adminUiScope .customItemModal__createTemplateButton {
|
||||
justify-self: start;
|
||||
}
|
||||
.adminUiScope .customItemModal__body {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -4121,6 +4120,26 @@ function openUserProfile(user) {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.adminUiScope .customItemModal__templateChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 11px;
|
||||
border-radius: 999px;
|
||||
background: var(--theme-pill-bg);
|
||||
border: 1px solid var(--theme-border);
|
||||
font-size: 12px;
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.adminUiScope .customItemModal__templateChipRemove {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--theme-text-soft);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.adminUiScope .customItemModal__title {
|
||||
font-size: 19px;
|
||||
font-weight: 900;
|
||||
|
||||
Reference in New Issue
Block a user