Compare commits

...

2 Commits

Author SHA1 Message Date
47638b8b3e admin: streamline item modal actions 2026-04-06 12:10:46 +09:00
632bebb8f9 admin: simplify template tagging flow 2026-04-06 12:05:04 +09:00
9 changed files with 481 additions and 72 deletions

View File

@@ -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,

View File

@@ -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),

View File

@@ -1,5 +1,15 @@
# 의사결정 이력
## 2026-04-06 v1.4.88
- 같은 이미지를 사용자 항목, 템플릿 항목, 관리자 자산으로 각각 따로 카드에 늘어놓으면 운영자가 실제로 보고 싶은 “이미지 단위 상태”보다 내부 저장 단위가 더 크게 드러나므로, 관리자 목록과 검색은 기본적으로 같은 `src`를 하나로 묶어 보여주는 편이 더 자연스럽다고 정리했다.
- 아이템 모달의 연결 템플릿 배지는 단순히 해당 템플릿 화면으로 점프하는 것보다, 그 자리에서 `이 템플릿에서 제외`를 바로 수행하는 액션이 훨씬 직접적이므로 배지에 제거 버튼을 붙이는 쪽이 더 낫다고 판단했다.
- 한글 태그 입력은 IME 조합 중 `Enter`가 중간 문자열까지 함께 커밋될 수 있으므로, 배지형 태그 입력에서는 조합 상태를 명시적으로 감지해 완성된 문자열만 태그로 추가하는 편이 맞다고 정리했다.
## 2026-04-06 v1.4.87
- 템플릿 태그는 이름/slug 검색과 역할이 겹치고, 운영자가 실제로 원하는 것은 “템플릿 찾기”보다 “아이템 묶음 분류”에 가까웠으므로 템플릿 화면에서 직접 노출하지 않는 편이 더 맞다고 정리했다.
- 태그 입력도 카드 곳곳에 흩어져 있으면 메인 작업인 업로드와 이름 정리가 묻히기 쉬우므로, 태그는 관리자 아이템 모달에서만 배지형으로 다루고 템플릿 화면 본문은 가볍게 유지하는 쪽을 택했다.
- 기존 템플릿을 통째로 가져오는 기능만으로는 운영자가 “조건에 맞는 일부 아이템만” 빠르게 합치는 흐름이 부족하므로, 이름·파일명·태그 기반 개별 아이템 검색 후 초안 목록으로 담아두는 보조 모달을 추가하는 편이 더 실용적이라고 판단했다.
## 2026-04-06 v1.4.86
- 내부 운영용 분류는 공개 노출용 필드와 분리하는 편이 맞다고 보고, 태그는 템플릿과 아이템의 관리자 전용 메타로만 저장하고 검색에 활용하는 방향으로 정리했다.
- 기존 분기 템플릿이나 요청 아이템을 다시 재조합해 새 템플릿을 만드는 흐름은 완전히 별도 마법 기능보다, 이미 있는 `초안 목록`에 다른 출처의 아이템을 합류시키고 `제외` 버튼으로 정리하는 편이 훨씬 예측 가능하다고 판단했다.

View File

@@ -1,5 +1,19 @@
# 업데이트 로그
## 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` 버튼으로 개별 제거할 수 있다.
- 템플릿 관리 액션 영역은 좁은 화면에서도 버튼이 자연스럽게 오른쪽 정렬로 줄바꿈되도록 손봤고, `개별 아이템 검색` 모달을 추가해 이름·파일명·태그로 원하는 아이템만 직접 찾아 현재 템플릿 초안에 넣을 수 있게 했다.
- 템플릿 화면의 기본 아이템 목록과 업로드 초안 카드에서는 태그 입력창을 제거해, 메인 작업 흐름이 이름 편집과 아이템 추가에 더 집중되도록 정리했다.
- 확인: `npm run build`
## 2026-04-06 v1.4.86
- 관리자 전용 내부 태그를 템플릿과 아이템 메타에 추가했다. 이제 템플릿 설정과 기본 아이템/관리자 아이템 모달에서 태그를 쉼표 기준으로 저장할 수 있고, 관리자 검색에서도 이름·파일명뿐 아니라 태그까지 함께 조회할 수 있다.
- 템플릿 관리 화면에 `기존 템플릿 가져오기`를 추가했다. 현재 선택한 템플릿에 다른 템플릿들의 기본 아이템을 초안으로 모아 온 뒤, 필요 없는 항목은 `제외`하고 저장할 수 있다. 여러 분기 템플릿을 합쳐 연간 통합 템플릿을 만드는 흐름을 염두에 둔 기능이다.

View File

@@ -0,0 +1,158 @@
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
placeholder: { type: String, default: '태그 입력 후 Enter' },
disabled: { type: Boolean, default: false },
maxTags: { type: Number, default: 30 },
maxTagLength: { type: Number, default: 40 },
})
const emit = defineEmits(['update:modelValue'])
const draft = ref('')
const isComposing = ref(false)
const normalizedTags = computed(() =>
Array.from(
new Set(
(Array.isArray(props.modelValue) ? props.modelValue : [])
.map((tag) => String(tag || '').trim().replace(/^#/, '').slice(0, props.maxTagLength))
.filter(Boolean)
)
).slice(0, props.maxTags)
)
function commitTags(nextTags) {
emit(
'update:modelValue',
Array.from(
new Set(
(nextTags || [])
.map((tag) => String(tag || '').trim().replace(/^#/, '').slice(0, props.maxTagLength))
.filter(Boolean)
)
).slice(0, props.maxTags)
)
}
function addDraftTag() {
if (props.disabled) return
const nextTag = String(draft.value || '').trim().replace(/^#/, '').slice(0, props.maxTagLength)
if (!nextTag) return
commitTags([...normalizedTags.value, nextTag])
draft.value = ''
}
function removeTag(tag) {
if (props.disabled) return
commitTags(normalizedTags.value.filter((entry) => entry !== tag))
}
function handleKeydown(event) {
if (event.isComposing || isComposing.value) return
if (event.key === 'Enter') {
event.preventDefault()
addDraftTag()
return
}
if (event.key === 'Backspace' && !draft.value && normalizedTags.value.length) {
removeTag(normalizedTags.value[normalizedTags.value.length - 1])
}
}
function handleBlur() {
if (isComposing.value) return
addDraftTag()
}
function handleCompositionStart() {
isComposing.value = true
}
function handleCompositionEnd() {
isComposing.value = false
}
</script>
<template>
<div class="tagBadgeInput" :class="{ 'tagBadgeInput--disabled': disabled }">
<div v-if="normalizedTags.length" class="tagBadgeInput__list">
<span v-for="tag in normalizedTags" :key="tag" class="tagBadgeInput__badge">
<span>#{{ tag }}</span>
<button class="tagBadgeInput__remove" type="button" :disabled="disabled" @click="removeTag(tag)">X</button>
</span>
</div>
<input
v-model="draft"
class="tagBadgeInput__input"
type="text"
:placeholder="placeholder"
:disabled="disabled || normalizedTags.length >= maxTags"
:maxlength="maxTagLength"
@keydown="handleKeydown"
@blur="handleBlur"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
/>
</div>
</template>
<style scoped>
.tagBadgeInput {
display: grid;
gap: 10px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid color-mix(in srgb, var(--theme-border-strong) 72%, rgba(255, 255, 255, 0.08));
background: color-mix(in srgb, var(--theme-surface-soft) 82%, rgba(255, 255, 255, 0.02));
}
.tagBadgeInput--disabled {
opacity: 0.72;
}
.tagBadgeInput__list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tagBadgeInput__badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 11px;
border-radius: 999px;
background: color-mix(in srgb, var(--theme-accent-soft) 55%, rgba(255, 255, 255, 0.06));
color: var(--theme-text);
font-size: 12px;
font-weight: 700;
}
.tagBadgeInput__remove {
border: none;
background: transparent;
color: inherit;
font-size: 11px;
font-weight: 800;
cursor: pointer;
padding: 0;
}
.tagBadgeInput__input {
width: 100%;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--theme-text);
font: inherit;
}
.tagBadgeInput__input::placeholder {
color: var(--theme-text-soft);
}
</style>

View File

@@ -10,13 +10,13 @@ const props = defineProps({
appliedRequestItemCount: { type: Number, required: true },
openTemplateCreateModal: { type: Function, required: true },
openTemplateSourceImportModal: { type: Function, required: true },
openTemplateLibraryItemModal: { type: Function, required: true },
isTemplateLoading: { type: Boolean, required: true },
hasSelectedTemplate: { type: Boolean, required: true },
selectedTemplate: { type: Object, default: null },
displayThumbnailUrl: { type: String, default: '' },
templateMetaDraftName: { type: String, default: '' },
templateMetaDraftSlug: { type: String, default: '' },
templateMetaDraftTags: { type: String, default: '' },
templateMetaSaving: { type: Boolean, required: true },
canSaveTemplateMeta: { type: Boolean, required: true },
saveTemplateMeta: { type: Function, required: true },
@@ -54,7 +54,7 @@ const props = defineProps({
selectedTemplateId: { type: String, default: '' },
})
defineEmits(['update:templateMetaDraftName', 'update:templateMetaDraftSlug', 'update:templateMetaDraftTags'])
defineEmits(['update:templateMetaDraftName', 'update:templateMetaDraftSlug'])
function setTemplateItemListElement(el) {
props.templateItemListRef(el)
@@ -163,17 +163,6 @@ function setThumbFileElement(el) {
@input="$emit('update:templateMetaDraftSlug', $event.target.value)"
/>
</label>
<label class="templateMetaField">
<span class="templateMetaField__label">내부 태그</span>
<input
class="input input--dense"
type="text"
maxlength="240"
:value="props.templateMetaDraftTags"
placeholder="예: 2026Q1, 애니, 여캐릭"
@input="$emit('update:templateMetaDraftTags', $event.target.value)"
/>
</label>
</div>
<div class="templateSettingsCard__meta">공개 URL: /topics/{{ props.selectedTemplate.template.slug || props.selectedTemplate.template.id }}</div>
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.templateVisibilitySaving }">
@@ -185,6 +174,7 @@ function setThumbFileElement(el) {
<button class="btn btn--ghost" :disabled="!props.canSaveTemplateMeta || props.templateMetaSaving" @click="props.saveTemplateMeta">
{{ props.templateMetaSaving ? '저장중...' : '템플릿 메타 저장' }}
</button>
<button class="btn btn--ghost" @click="props.openTemplateLibraryItemModal">개별 아이템 검색</button>
<button class="btn btn--ghost" @click="props.openTemplateSourceImportModal">기존 템플릿 가져오기</button>
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
<button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button>
@@ -232,7 +222,6 @@ function setThumbFileElement(el) {
</div>
<div class="itemDraftRow__body">
<input v-model="draft.label" class="input input--labelEdit input--dense" maxlength="60" placeholder="아이템 이름" />
<input v-model="draft.tagsText" class="input input--labelEdit input--dense" maxlength="240" placeholder="내부 태그 (쉼표로 구분)" />
<div class="hint hint--tight">{{ draft.sourceName }}</div>
<div class="itemDraftRow__meta">
<span class="pill" :class="draft.kind === 'request' ? 'pill--requestItem' : 'pill--directFile'">
@@ -265,15 +254,14 @@ function setThumbFileElement(el) {
<div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-template-item-id="item.id">
<img class="thumb thumb--template" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
<input v-model="item.draftTags" class="input input--labelEdit input--dense" placeholder="내부 태그 (쉼표로 구분)" data-no-drag />
<div class="thumbCard__actions">
<button
class="btn btn--ghost btn--small"
data-no-drag
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || (item.draftLabel.trim() === item.label && (item.draftTags || '') === ((item.tags || []).join(', ')))"
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
@click="props.saveTemplateItemLabel(item)"
>
{{ item.isSavingLabel ? '저장중...' : '메타 저장' }}
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
</button>
<button class="btn btn--danger btn--small" data-no-drag @click="props.removeTemplateItem(item.id)">아이템 삭제</button>
</div>

View File

@@ -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) {
@@ -89,7 +86,7 @@ export function useAdminCustomItems({
function openCustomItemModal(item) {
modalTargetCustomItem.value = item || null
customItemModalDraftLabel.value = item?.label || ''
customItemModalDraftTags.value = Array.isArray(item?.tags) ? item.tags.join(', ') : ''
customItemModalDraftTags.value = Array.isArray(item?.tags) ? [...item.tags] : []
customItemModalTargetTemplateId.value = ''
customItemReplacementQuery.value = ''
customItemReplacementItems.value = []
@@ -104,7 +101,7 @@ export function useAdminCustomItems({
customItemDeleteModalOpen.value = false
modalTargetCustomItem.value = null
customItemModalDraftLabel.value = ''
customItemModalDraftTags.value = ''
customItemModalDraftTags.value = []
customItemModalLabelSaving.value = false
customItemModalTargetTemplateId.value = ''
customItemReplacementQuery.value = ''
@@ -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
@@ -203,8 +191,7 @@ export function useAdminCustomItems({
const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60)
const nextTags = Array.from(
new Set(
String(customItemModalDraftTags.value || '')
.split(',')
(Array.isArray(customItemModalDraftTags.value) ? customItemModalDraftTags.value : [])
.map((tag) => tag.trim().replace(/^#/, '').slice(0, 40))
.filter(Boolean)
)
@@ -224,7 +211,7 @@ export function useAdminCustomItems({
item.label = data.item?.label || nextLabel
item.tags = Array.isArray(data.item?.tags) ? data.item.tags : nextTags
customItemModalDraftLabel.value = item.label
customItemModalDraftTags.value = item.tags.join(', ')
customItemModalDraftTags.value = [...item.tags]
customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label, tags: item.tags } : entry))
toast.success('아이템 메타를 변경했어요.')
} catch (e) {
@@ -256,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)
@@ -316,12 +327,12 @@ export function useAdminCustomItems({
closeCustomItemModal,
openCustomItemDeleteModal,
closeCustomItemDeleteModal,
jumpToTemplateAdmin,
removeCustomItem,
removeUnusedCustomItems,
showUnusedCustomItems,
saveCustomItemModalLabel,
promoteCustomItem,
unlinkCustomItemTemplate,
refreshReplacementCandidates,
replaceCustomItem,
restoreCustomItem,

View File

@@ -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) =>

View File

@@ -6,6 +6,7 @@ import { editorPath, userProfilePath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import deleteIcon from '../assets/icons/delete.svg'
import SvgIcon from '../components/SvgIcon.vue'
import TagBadgeInput from '../components/TagBadgeInput.vue'
import AdminFeaturedSection from '../components/admin/AdminFeaturedSection.vue'
import AdminTemplatesSection from '../components/admin/AdminTemplatesSection.vue'
import AdminItemsSection from '../components/admin/AdminItemsSection.vue'
@@ -73,6 +74,11 @@ const importModalNewTemplateName = ref('')
const templateSourceImportModalOpen = ref(false)
const templateSourceImportQuery = ref('')
const templateSourceImportSelectedIds = ref([])
const templateLibraryItemModalOpen = ref(false)
const templateLibraryItemQuery = ref('')
const templateLibraryItemResults = ref([])
const templateLibraryItemSelectedIds = ref([])
const templateLibraryItemLoading = ref(false)
const previewModalOpen = ref(false)
const previewTierList = ref(null)
const adminTierListManageModalOpen = ref(false)
@@ -92,7 +98,7 @@ const modalUserDraftNickname = ref('')
const modalUserDraftIsAdmin = ref(false)
const modalTargetCustomItem = ref(null)
const customItemModalDraftLabel = ref('')
const customItemModalDraftTags = ref('')
const customItemModalDraftTags = ref([])
const customItemModalLabelSaving = ref(false)
const modalTargetAdminTierList = ref(null)
const adminTierListDraftTitle = ref('')
@@ -121,7 +127,6 @@ const newTemplateName = ref('')
const newTemplateIsPublic = ref(false)
const templateMetaDraftName = ref('')
const templateMetaDraftSlug = ref('')
const templateMetaDraftTags = ref('')
const templateMetaSaving = ref(false)
const templateVisibilitySaving = ref(false)
@@ -190,10 +195,10 @@ function normalizeAdminSrc(src) {
}
function parseAdminTagsText(value) {
const values = Array.isArray(value) ? value : String(value || '').split(',')
return Array.from(
new Set(
String(value || '')
.split(',')
values
.map((tag) => tag.trim().replace(/^#/, '').slice(0, 40))
.filter(Boolean)
)
@@ -206,13 +211,11 @@ const canSaveTemplateMeta = computed(() => {
if (!template?.id) return false
const nextName = templateMetaDraftName.value.trim()
const nextSlug = templateMetaDraftSlug.value.trim()
const nextTags = parseAdminTagsText(templateMetaDraftTags.value)
return (
!!nextName &&
!!nextSlug &&
(nextName !== (template.name || '') ||
nextSlug !== (template.slug || template.id || '') ||
JSON.stringify(nextTags) !== JSON.stringify(Array.isArray(template.tags) ? template.tags : []))
nextSlug !== (template.slug || template.id || ''))
)
})
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value)
@@ -241,7 +244,7 @@ const filteredTemplatePickerTemplates = computed(() => {
const query = templatePickerQuery.value.trim().toLowerCase()
const list = templates.value.filter((template) => {
if (!query) return true
const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''} ${(template.tags || []).join(' ')}`.toLowerCase()
const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''}`.toLowerCase()
return haystack.includes(query)
})
@@ -255,13 +258,16 @@ const customItemReplacementTarget = computed(
() => customItemReplacementItems.value.find((item) => item.id === customItemReplacementTargetId.value) || null
)
const importModalItemCount = computed(() => importModalItems.value.length)
const selectedTemplateLibraryItems = computed(() =>
templateLibraryItemResults.value.filter((item) => templateLibraryItemSelectedIds.value.includes(item.id))
)
const filteredTemplateSourceImportTemplates = computed(() => {
const query = templateSourceImportQuery.value.trim().toLowerCase()
return templates.value
.filter((template) => template.id !== selectedTemplateId.value)
.filter((template) => {
if (!query) return true
const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''} ${(template.tags || []).join(' ')}`.toLowerCase()
const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''}`.toLowerCase()
return haystack.includes(query)
})
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
@@ -357,6 +363,9 @@ const isAnyModalOpen = computed(
userDeleteModalOpen.value ||
userRoleModalOpen.value ||
importModalOpen.value ||
templateSourceImportModalOpen.value ||
templateLibraryItemModalOpen.value ||
templatePickerModalOpen.value ||
customItemModalOpen.value ||
customItemDeleteModalOpen.value ||
adminTierListManageModalOpen.value ||
@@ -516,7 +525,6 @@ watch(
async (templateId) => {
templateMetaDraftName.value = selectedTemplate.value?.template?.name || ''
templateMetaDraftSlug.value = selectedTemplate.value?.template?.slug || selectedTemplate.value?.template?.id || ''
templateMetaDraftTags.value = Array.isArray(selectedTemplate.value?.template?.tags) ? selectedTemplate.value.template.tags.join(', ') : ''
await refreshSelectedTemplateTierListStats(templateId)
},
{ immediate: true }
@@ -883,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
@@ -1088,12 +1097,12 @@ const {
closeCustomItemModal,
openCustomItemDeleteModal,
closeCustomItemDeleteModal,
jumpToTemplateAdmin,
removeCustomItem,
removeUnusedCustomItems,
showUnusedCustomItems,
saveCustomItemModalLabel,
promoteCustomItem,
unlinkCustomItemTemplate,
refreshReplacementCandidates,
replaceCustomItem,
restoreCustomItem,
@@ -1123,8 +1132,6 @@ const {
selectedTemplateId,
refreshCustomItems,
loadTemplate,
setTab,
selectAdminTemplate,
resetMessages,
success,
error,
@@ -1299,7 +1306,6 @@ async function saveTemplateMeta() {
const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, {
name: templateMetaDraftName.value.trim(),
slug: templateMetaDraftSlug.value.trim().toLowerCase(),
tags: parseAdminTagsText(templateMetaDraftTags.value),
isPublic: !!selectedTemplate.value.template.isPublic,
})
const nextTemplate = data.template || {}
@@ -1312,7 +1318,6 @@ async function saveTemplateMeta() {
}
templateMetaDraftName.value = nextTemplate.name || selectedTemplate.value.template.name || ''
templateMetaDraftSlug.value = nextTemplate.slug || selectedTemplate.value.template.slug || selectedTemplate.value.template.id || ''
templateMetaDraftTags.value = Array.isArray(nextTemplate.tags) ? nextTemplate.tags.join(', ') : templateMetaDraftTags.value
await refreshTemplates()
success.value = '템플릿 메타를 저장했어요.'
} catch (e) {
@@ -1396,23 +1401,24 @@ async function saveTemplateItemLabel(item) {
resetMessages()
if (!selectedTemplateId.value) return
const nextLabel = (item.draftLabel || '').trim()
const nextTags = parseAdminTagsText(item.draftTags || '')
if (!nextLabel) {
error.value = '아이템 이름을 입력해주세요.'
return
}
if (nextLabel === item.label && JSON.stringify(nextTags) === JSON.stringify(Array.isArray(item.tags) ? item.tags : [])) return
if (nextLabel === item.label) return
try {
item.isSavingLabel = true
const data = await api.updateAdminTemplateItem(selectedTemplateId.value, item.id, { label: nextLabel, tags: nextTags })
const data = await api.updateAdminTemplateItem(selectedTemplateId.value, item.id, {
label: nextLabel,
tags: Array.isArray(item.tags) ? item.tags : [],
})
item.label = data.item.label
item.draftLabel = data.item.label
item.tags = Array.isArray(data.item.tags) ? data.item.tags : []
item.draftTags = item.tags.join(', ')
success.value = '기본 아이템 메타를 수정했어요.'
success.value = '기본 아이템 이름을 수정했어요.'
} catch (e) {
error.value = '기본 아이템 메타 수정에 실패했어요.'
error.value = '기본 아이템 이름 수정에 실패했어요.'
} finally {
item.isSavingLabel = false
}
@@ -1773,6 +1779,85 @@ function closeTemplateSourceImportModal() {
templateSourceImportQuery.value = ''
}
function openTemplateLibraryItemModal() {
resetMessages()
if (!selectedTemplateId.value) {
error.value = '먼저 아이템을 받을 템플릿을 선택해주세요.'
return
}
templateLibraryItemQuery.value = ''
templateLibraryItemResults.value = []
templateLibraryItemSelectedIds.value = []
templateLibraryItemLoading.value = false
templateLibraryItemModalOpen.value = true
}
function closeTemplateLibraryItemModal() {
templateLibraryItemModalOpen.value = false
templateLibraryItemQuery.value = ''
templateLibraryItemResults.value = []
templateLibraryItemSelectedIds.value = []
templateLibraryItemLoading.value = false
}
function toggleTemplateLibraryItemSelection(itemId) {
if (!itemId) return
if (templateLibraryItemSelectedIds.value.includes(itemId)) {
templateLibraryItemSelectedIds.value = templateLibraryItemSelectedIds.value.filter((id) => id !== itemId)
return
}
templateLibraryItemSelectedIds.value = [...templateLibraryItemSelectedIds.value, itemId]
}
async function searchTemplateLibraryItems() {
resetMessages()
const query = templateLibraryItemQuery.value.trim()
if (!query) {
templateLibraryItemResults.value = []
templateLibraryItemSelectedIds.value = []
return
}
try {
templateLibraryItemLoading.value = true
const data = await api.listAdminCustomItems({
q: query,
page: 1,
limit: 50,
filter: 'library',
collapseShared: true,
})
templateLibraryItemResults.value = (data.items || []).filter((item) => item?.id)
templateLibraryItemSelectedIds.value = templateLibraryItemSelectedIds.value.filter((id) =>
templateLibraryItemResults.value.some((item) => item.id === id)
)
} catch (e) {
templateLibraryItemResults.value = []
templateLibraryItemSelectedIds.value = []
error.value = '개별 아이템 검색에 실패했어요.'
} finally {
templateLibraryItemLoading.value = false
}
}
async function confirmTemplateLibraryItemImport() {
resetMessages()
if (!selectedTemplateId.value) {
error.value = '아이템을 받을 템플릿을 먼저 선택해주세요.'
return
}
if (!selectedTemplateLibraryItems.value.length) {
error.value = '가져올 아이템을 하나 이상 선택해주세요.'
return
}
const stagedCount = mergeLibraryItemsIntoDrafts(selectedTemplateLibraryItems.value, '라이브러리 검색')
closeTemplateLibraryItemModal()
success.value = stagedCount
? `${stagedCount}개의 아이템을 초안 목록에 추가했어요. 필요 없는 항목은 제외한 뒤 저장하면 됩니다.`
: '추가로 가져올 새 아이템이 없었어요.'
}
function toggleTemplateSourceImportSelection(templateId) {
if (!templateId) return
if (templateSourceImportSelectedIds.value.includes(templateId)) {
@@ -1947,13 +2032,13 @@ function openUserProfile(user) {
:applied-request-item-count="appliedRequestItemCount"
:open-template-create-modal="openTemplateCreateModal"
:open-template-source-import-modal="openTemplateSourceImportModal"
:open-template-library-item-modal="openTemplateLibraryItemModal"
:is-template-loading="isTemplateLoading"
:has-selected-template="hasSelectedTemplate"
:selected-template="selectedTemplate"
:display-thumbnail-url="displayThumbnailUrl"
v-model:template-meta-draft-name="templateMetaDraftName"
v-model:template-meta-draft-slug="templateMetaDraftSlug"
v-model:template-meta-draft-tags="templateMetaDraftTags"
:template-meta-saving="templateMetaSaving"
:can-save-template-meta="canSaveTemplateMeta"
:save-template-meta="saveTemplateMeta"
@@ -2198,7 +2283,7 @@ function openUserProfile(user) {
</div>
<div class="modalCard__form">
<input v-model="templateSourceImportQuery" class="input" placeholder="템플릿 이름, slug, 태그 검색" />
<input v-model="templateSourceImportQuery" class="input" placeholder="템플릿 이름 또는 slug 검색" />
</div>
<div class="templateImportList">
@@ -2212,7 +2297,6 @@ function openUserProfile(user) {
>
<span class="adminTemplatePicker__name">{{ template.name }}</span>
<span class="adminTemplatePicker__meta">{{ template.slug || template.id }}</span>
<span v-if="template.tags?.length" class="adminTemplatePicker__meta">#{{ template.tags.join(' #') }}</span>
</button>
<div v-if="!filteredTemplateSourceImportTemplates.length" class="hint">조건에 맞는 템플릿이 없어요.</div>
</div>
@@ -2224,6 +2308,60 @@ function openUserProfile(user) {
</div>
</div>
<div v-if="templateLibraryItemModalOpen" class="modalOverlay" @click.self="closeTemplateLibraryItemModal">
<div class="modalCard modalCard--import" role="dialog" aria-modal="true">
<div class="modalCard__title">개별 아이템 검색</div>
<div class="modalCard__desc">
이름, 파일명, 태그로 검색해서 현재 템플릿 초안 목록에 필요한 아이템만 모아둘 있어요.
</div>
<div class="modalCard__form modalCard__form--search">
<input
v-model="templateLibraryItemQuery"
class="input"
placeholder="아이템 이름, 파일명, 태그 검색"
@keydown.enter.prevent="searchTemplateLibraryItems"
/>
<button class="btn btn--ghost" type="button" @click="searchTemplateLibraryItems">
{{ templateLibraryItemLoading ? '검색중...' : '검색' }}
</button>
</div>
<div class="templateImportList">
<div v-if="!templateLibraryItemQuery.trim() && !templateLibraryItemLoading" class="hint">
검색 전에는 목록을 보여주지 않아요. 필요한 키워드로 직접 찾아서 추가해주세요.
</div>
<button
v-for="item in templateLibraryItemResults"
:key="item.id"
type="button"
class="adminTemplatePicker__item"
:class="{ 'adminTemplatePicker__item--active': templateLibraryItemSelectedIds.includes(item.id) }"
@click="toggleTemplateLibraryItemSelection(item.id)"
>
<span class="customItemModal__replacementRow">
<img class="customItemModal__replacementThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<span class="customItemModal__replacementCopy">
<span class="adminTemplatePicker__name">{{ item.label }}</span>
<span class="adminTemplatePicker__meta">{{ item.sourceLabel }} · {{ item.ownerName || '알 수 없음' }}</span>
<span v-if="item.tags?.length" class="adminTemplatePicker__meta">#{{ item.tags.join(' #') }}</span>
</span>
</span>
</button>
<div v-if="templateLibraryItemQuery.trim() && !templateLibraryItemLoading && !templateLibraryItemResults.length" class="hint">
조건에 맞는 아이템이 없어요.
</div>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateLibraryItemModal">취소</button>
<button class="btn btn--primary" :disabled="!selectedTemplateLibraryItems.length" @click="confirmTemplateLibraryItemImport">
초안으로 가져오기
</button>
</div>
</div>
</div>
<div v-if="customItemModalOpen" class="modalOverlay" @click.self="closeCustomItemModal">
<div class="modalCard modalCard--customItem" role="dialog" aria-modal="true">
<div v-if="modalTargetCustomItem" class="customItemModal">
@@ -2239,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">
@@ -2309,7 +2446,7 @@ function openUserProfile(user) {
</label>
<label v-if="modalTargetCustomItem.sourceType !== 'asset'" class="field">
<span class="field__label">내부 태그</span>
<input v-model="customItemModalDraftTags" class="field__input" type="text" maxlength="240" placeholder="예: 여캐릭, 귀멸, 2026Q1" />
<TagBadgeInput v-model="customItemModalDraftTags" placeholder="태그 입력 후 Enter" />
</label>
<button class="btn btn--ghost customItemModal__renameButton" type="button" :disabled="customItemModalLabelSaving || !customItemModalDraftLabel.trim() || (customItemModalDraftLabel.trim() === modalTargetCustomItem.label && (modalTargetCustomItem.sourceType === 'asset' || JSON.stringify(parseAdminTagsText(customItemModalDraftTags)) === JSON.stringify(modalTargetCustomItem.tags || [])))" @click="saveCustomItemModalLabel">
{{ customItemModalLabelSaving ? '저장중...' : '메타 저장' }}
@@ -2325,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>
@@ -2974,6 +3114,10 @@ function openUserProfile(user) {
max-height: 360px;
overflow: auto;
}
.adminUiScope .modalCard__form--search {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
.adminUiScope .adminTemplatePicker__name {
font-size: 13px;
font-weight: 800;
@@ -3444,10 +3588,14 @@ function openUserProfile(user) {
}
.adminUiScope .templateSettingsCard__actions {
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.adminUiScope .templateSettingsCard__actions > .btn {
flex: 0 0 auto;
}
.adminUiScope .selectedThumb {
width: min(100%, 256px);
aspect-ratio: 16 / 9;
@@ -3888,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;
@@ -3975,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;
@@ -4882,6 +5047,9 @@ function openUserProfile(user) {
.adminUiScope .customItemModal__actions {
grid-template-columns: 1fr;
}
.adminUiScope .modalCard__form--search {
grid-template-columns: 1fr;
}
.adminUiScope.adminSidebar {
display: none;
}