-
- {{ item.isSavingLabel ? '저장중...' : '메타 저장' }}
+ {{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
아이템 삭제
diff --git a/frontend/src/composables/useAdminCustomItems.js b/frontend/src/composables/useAdminCustomItems.js
index a998bf8..4be50d1 100644
--- a/frontend/src/composables/useAdminCustomItems.js
+++ b/frontend/src/composables/useAdminCustomItems.js
@@ -89,7 +89,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 +104,7 @@ export function useAdminCustomItems({
customItemDeleteModalOpen.value = false
modalTargetCustomItem.value = null
customItemModalDraftLabel.value = ''
- customItemModalDraftTags.value = ''
+ customItemModalDraftTags.value = []
customItemModalLabelSaving.value = false
customItemModalTargetTemplateId.value = ''
customItemReplacementQuery.value = ''
@@ -203,8 +203,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 +223,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) {
diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue
index 7506553..fe19609 100644
--- a/frontend/src/views/AdminView.vue
+++ b/frontend/src/views/AdminView.vue
@@ -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) {