admin: simplify template tagging flow
This commit is contained in:
@@ -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 }
|
||||
@@ -1299,7 +1307,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 +1319,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 +1402,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 +1780,84 @@ 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',
|
||||
})
|
||||
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">
|
||||
@@ -2309,7 +2447,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 ? '저장중...' : '메타 저장' }}
|
||||
@@ -2974,6 +3112,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 +3586,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;
|
||||
@@ -4882,6 +5028,9 @@ function openUserProfile(user) {
|
||||
.adminUiScope .customItemModal__actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.adminUiScope .modalCard__form--search {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.adminUiScope.adminSidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user