feat: 템플릿 태그와 병합 가져오기 및 에디터 제거 추가
This commit is contained in:
@@ -9,12 +9,14 @@ const props = defineProps({
|
||||
stagedRequestDraftCount: { type: Number, required: true },
|
||||
appliedRequestItemCount: { type: Number, required: true },
|
||||
openTemplateCreateModal: { type: Function, required: true },
|
||||
openTemplateSourceImportModal: { 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 },
|
||||
@@ -52,7 +54,7 @@ const props = defineProps({
|
||||
selectedTemplateId: { type: String, default: '' },
|
||||
})
|
||||
|
||||
defineEmits(['update:templateMetaDraftName', 'update:templateMetaDraftSlug'])
|
||||
defineEmits(['update:templateMetaDraftName', 'update:templateMetaDraftSlug', 'update:templateMetaDraftTags'])
|
||||
|
||||
function setTemplateItemListElement(el) {
|
||||
props.templateItemListRef(el)
|
||||
@@ -161,6 +163,17 @@ 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 }">
|
||||
@@ -170,8 +183,9 @@ function setThumbFileElement(el) {
|
||||
</label>
|
||||
<div class="templateSettingsCard__actions">
|
||||
<button class="btn btn--ghost" :disabled="!props.canSaveTemplateMeta || props.templateMetaSaving" @click="props.saveTemplateMeta">
|
||||
{{ props.templateMetaSaving ? '저장중...' : '이름/slug 저장' }}
|
||||
{{ props.templateMetaSaving ? '저장중...' : '템플릿 메타 저장' }}
|
||||
</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>
|
||||
</div>
|
||||
@@ -218,10 +232,11 @@ 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'">
|
||||
{{ draft.kind === 'request' ? '요청 아이템' : '직접 추가 파일' }}
|
||||
{{ draft.kind === 'request' ? '요청 아이템' : draft.kind === 'library' ? '기존 템플릿' : '직접 추가 파일' }}
|
||||
</span>
|
||||
<button class="btn btn--danger btn--small" type="button" @click="props.removeUploadDraft(draft)">제외</button>
|
||||
</div>
|
||||
@@ -246,18 +261,19 @@ function setThumbFileElement(el) {
|
||||
<button class="btn btn--primary btn--small" :disabled="!props.hasTemplateItemOrderChanges" @click="props.saveTemplateItemOrder">순서 저장</button>
|
||||
</div>
|
||||
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
|
||||
<div v-else :ref="setTemplateItemListElement" class="thumbGrid">
|
||||
<div v-else :ref="setTemplateItemListElement" class="thumbGrid">
|
||||
<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"
|
||||
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || (item.draftLabel.trim() === item.label && (item.draftTags || '') === ((item.tags || []).join(', ')))"
|
||||
@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>
|
||||
|
||||
@@ -14,6 +14,7 @@ export function useAdminCustomItems({
|
||||
customItemModalHistoryActive,
|
||||
modalTargetCustomItem,
|
||||
customItemModalDraftLabel,
|
||||
customItemModalDraftTags,
|
||||
customItemModalLabelSaving,
|
||||
customItemModalTargetTemplateId,
|
||||
customItemReplacementQuery,
|
||||
@@ -88,6 +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(', ') : ''
|
||||
customItemModalTargetTemplateId.value = ''
|
||||
customItemReplacementQuery.value = ''
|
||||
customItemReplacementItems.value = []
|
||||
@@ -102,6 +104,7 @@ export function useAdminCustomItems({
|
||||
customItemDeleteModalOpen.value = false
|
||||
modalTargetCustomItem.value = null
|
||||
customItemModalDraftLabel.value = ''
|
||||
customItemModalDraftTags.value = ''
|
||||
customItemModalLabelSaving.value = false
|
||||
customItemModalTargetTemplateId.value = ''
|
||||
customItemReplacementQuery.value = ''
|
||||
@@ -198,17 +201,34 @@ export function useAdminCustomItems({
|
||||
async function saveCustomItemModalLabel() {
|
||||
const item = modalTargetCustomItem.value
|
||||
const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60)
|
||||
if (!item || !nextLabel || nextLabel === item.label || customItemModalLabelSaving.value) return
|
||||
const nextTags = Array.from(
|
||||
new Set(
|
||||
String(customItemModalDraftTags.value || '')
|
||||
.split(',')
|
||||
.map((tag) => tag.trim().replace(/^#/, '').slice(0, 40))
|
||||
.filter(Boolean)
|
||||
)
|
||||
).slice(0, 30)
|
||||
if (
|
||||
!item ||
|
||||
!nextLabel ||
|
||||
(nextLabel === item.label && JSON.stringify(nextTags) === JSON.stringify(Array.isArray(item.tags) ? item.tags : [])) ||
|
||||
customItemModalLabelSaving.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
customItemModalLabelSaving.value = true
|
||||
const data = await api.updateAdminCustomItemLabel(item.id, { label: nextLabel, sourceType: item.sourceType })
|
||||
const data = await api.updateAdminCustomItemLabel(item.id, { label: nextLabel, tags: nextTags, sourceType: item.sourceType })
|
||||
item.label = data.item?.label || nextLabel
|
||||
item.tags = Array.isArray(data.item?.tags) ? data.item.tags : nextTags
|
||||
customItemModalDraftLabel.value = item.label
|
||||
customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label } : entry))
|
||||
toast.success('아이템 이름을 변경했어요.')
|
||||
customItemModalDraftTags.value = item.tags.join(', ')
|
||||
customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label, tags: item.tags } : entry))
|
||||
toast.success('아이템 메타를 변경했어요.')
|
||||
} catch (e) {
|
||||
error.value = '아이템 이름 변경에 실패했어요.'
|
||||
error.value = '아이템 메타 변경에 실패했어요.'
|
||||
} finally {
|
||||
customItemModalLabelSaving.value = false
|
||||
}
|
||||
|
||||
@@ -31,6 +31,17 @@ export function useAdminTemplateManager({
|
||||
success,
|
||||
error,
|
||||
}) {
|
||||
function parseTagsText(value) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
String(value || '')
|
||||
.split(',')
|
||||
.map((tag) => tag.trim().replace(/^#/, '').slice(0, 40))
|
||||
.filter(Boolean)
|
||||
)
|
||||
).slice(0, 30)
|
||||
}
|
||||
|
||||
function normalizeDraftSrc(src) {
|
||||
if (typeof src !== 'string') return ''
|
||||
const raw = src.trim()
|
||||
@@ -108,6 +119,32 @@ export function useAdminTemplateManager({
|
||||
}
|
||||
}
|
||||
|
||||
function mergeLibraryItemsIntoDrafts(items, sourceLabel = '') {
|
||||
const existingTemplateSrcs = new Set((selectedTemplate.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean))
|
||||
const existingDraftSrcs = new Set(uploadItemDrafts.value.map((draft) => normalizeDraftSrc(draft?.src)).filter(Boolean))
|
||||
const existingKeys = new Set(uploadItemDrafts.value.map((draft) => `${draft.kind}:${draft.itemSourceType || ''}:${draft.itemId || draft.file?.name || ''}`))
|
||||
const nextDrafts = (items || [])
|
||||
.filter((item) => item?.id && item?.src)
|
||||
.map((item) => ({
|
||||
kind: 'library',
|
||||
itemId: item.id,
|
||||
itemSourceType: item.sourceType || 'template',
|
||||
previewUrl: toApiUrl(item.src),
|
||||
label: item.label || '',
|
||||
tagsText: Array.isArray(item.tags) ? item.tags.join(', ') : '',
|
||||
sourceName: sourceLabel ? `${sourceLabel} · ${item.label || item.id}` : (item.label || item.id),
|
||||
src: item.src,
|
||||
}))
|
||||
.filter((draft) => !existingTemplateSrcs.has(normalizeDraftSrc(draft.src)))
|
||||
.filter((draft) => !existingDraftSrcs.has(normalizeDraftSrc(draft.src)))
|
||||
.filter((draft) => !existingKeys.has(`${draft.kind}:${draft.itemSourceType}:${draft.itemId}`))
|
||||
|
||||
if (nextDrafts.length) {
|
||||
uploadItemDrafts.value = [...uploadItemDrafts.value, ...nextDrafts]
|
||||
}
|
||||
return nextDrafts.length
|
||||
}
|
||||
|
||||
function removeUploadDraft(targetDraft) {
|
||||
const targetKey = `${targetDraft.kind}:${targetDraft.requestId || ''}:${targetDraft.itemId || targetDraft.file?.name || ''}:${targetDraft.previewUrl || ''}`
|
||||
uploadItemDrafts.value = uploadItemDrafts.value.filter((draft) => {
|
||||
@@ -139,6 +176,7 @@ export function useAdminTemplateManager({
|
||||
items: (data.items || []).map((item) => ({
|
||||
...item,
|
||||
draftLabel: item.label,
|
||||
draftTags: Array.isArray(item.tags) ? item.tags.join(', ') : '',
|
||||
})),
|
||||
}
|
||||
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||
@@ -293,6 +331,7 @@ export function useAdminTemplateManager({
|
||||
|
||||
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
|
||||
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
|
||||
const libraryDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'library')
|
||||
const totalUploadBytes = fileDrafts.reduce((sum, entry) => sum + Number(entry.file?.size || 0), 0)
|
||||
let uploadCount = 0
|
||||
|
||||
@@ -352,6 +391,28 @@ export function useAdminTemplateManager({
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryDrafts.length) {
|
||||
for (const draft of libraryDrafts) {
|
||||
const promoted = await api.promoteAdminTemplateItem(draft.itemId, {
|
||||
topicId: selectedTemplateId.value,
|
||||
})
|
||||
const createdItem = promoted?.item || null
|
||||
if (createdItem?.id) {
|
||||
const nextTags = parseTagsText(draft.tagsText)
|
||||
const needsMetaUpdate =
|
||||
(draft.label || '').trim() !== (createdItem.label || '').trim() ||
|
||||
JSON.stringify(nextTags) !== JSON.stringify(Array.isArray(createdItem.tags) ? createdItem.tags : [])
|
||||
if (needsMetaUpdate) {
|
||||
await api.updateAdminTemplateItem(selectedTemplateId.value, createdItem.id, {
|
||||
label: (draft.label || createdItem.label || '').trim(),
|
||||
tags: nextTags,
|
||||
})
|
||||
}
|
||||
uploadCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetUploadState()
|
||||
await loadTemplate()
|
||||
success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
|
||||
@@ -397,6 +458,7 @@ export function useAdminTemplateManager({
|
||||
items: (data.items || []).map((item) => ({
|
||||
...item,
|
||||
draftLabel: item.label,
|
||||
draftTags: Array.isArray(item.tags) ? item.tags.join(', ') : '',
|
||||
})),
|
||||
}
|
||||
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||
@@ -412,6 +474,7 @@ export function useAdminTemplateManager({
|
||||
destroyTemplateItemSortable,
|
||||
syncTemplateItemSortable,
|
||||
mergeRequestItemsIntoDrafts,
|
||||
mergeLibraryItemsIntoDrafts,
|
||||
removeUploadDraft,
|
||||
loadTemplate,
|
||||
createTemplate,
|
||||
|
||||
@@ -70,6 +70,9 @@ const importModalItems = ref([])
|
||||
const importModalTargetTemplateId = ref('')
|
||||
const importModalNewTemplateId = ref('')
|
||||
const importModalNewTemplateName = ref('')
|
||||
const templateSourceImportModalOpen = ref(false)
|
||||
const templateSourceImportQuery = ref('')
|
||||
const templateSourceImportSelectedIds = ref([])
|
||||
const previewModalOpen = ref(false)
|
||||
const previewTierList = ref(null)
|
||||
const adminTierListManageModalOpen = ref(false)
|
||||
@@ -89,6 +92,7 @@ const modalUserDraftNickname = ref('')
|
||||
const modalUserDraftIsAdmin = ref(false)
|
||||
const modalTargetCustomItem = ref(null)
|
||||
const customItemModalDraftLabel = ref('')
|
||||
const customItemModalDraftTags = ref('')
|
||||
const customItemModalLabelSaving = ref(false)
|
||||
const modalTargetAdminTierList = ref(null)
|
||||
const adminTierListDraftTitle = ref('')
|
||||
@@ -117,6 +121,7 @@ const newTemplateName = ref('')
|
||||
const newTemplateIsPublic = ref(false)
|
||||
const templateMetaDraftName = ref('')
|
||||
const templateMetaDraftSlug = ref('')
|
||||
const templateMetaDraftTags = ref('')
|
||||
const templateMetaSaving = ref(false)
|
||||
const templateVisibilitySaving = ref(false)
|
||||
|
||||
@@ -184,16 +189,30 @@ function normalizeAdminSrc(src) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseAdminTagsText(value) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
String(value || '')
|
||||
.split(',')
|
||||
.map((tag) => tag.trim().replace(/^#/, '').slice(0, 40))
|
||||
.filter(Boolean)
|
||||
)
|
||||
).slice(0, 30)
|
||||
}
|
||||
|
||||
const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.template?.id)
|
||||
const canSaveTemplateMeta = computed(() => {
|
||||
const template = selectedTemplate.value?.template
|
||||
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 || ''))
|
||||
(nextName !== (template.name || '') ||
|
||||
nextSlug !== (template.slug || template.id || '') ||
|
||||
JSON.stringify(nextTags) !== JSON.stringify(Array.isArray(template.tags) ? template.tags : []))
|
||||
)
|
||||
})
|
||||
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value)
|
||||
@@ -222,7 +241,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 || ''}`.toLowerCase()
|
||||
const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''} ${(template.tags || []).join(' ')}`.toLowerCase()
|
||||
return haystack.includes(query)
|
||||
})
|
||||
|
||||
@@ -236,6 +255,17 @@ const customItemReplacementTarget = computed(
|
||||
() => customItemReplacementItems.value.find((item) => item.id === customItemReplacementTargetId.value) || null
|
||||
)
|
||||
const importModalItemCount = computed(() => importModalItems.value.length)
|
||||
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()
|
||||
return haystack.includes(query)
|
||||
})
|
||||
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
|
||||
})
|
||||
const activeTabTitle = computed(() => {
|
||||
if (activeTab.value === 'featured') return '목록 관리'
|
||||
if (activeTab.value === 'template-admin') return '템플릿 관리'
|
||||
@@ -486,6 +516,7 @@ 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 }
|
||||
@@ -986,6 +1017,7 @@ const {
|
||||
destroyTemplateItemSortable,
|
||||
syncTemplateItemSortable,
|
||||
mergeRequestItemsIntoDrafts,
|
||||
mergeLibraryItemsIntoDrafts,
|
||||
removeUploadDraft,
|
||||
loadTemplate,
|
||||
createTemplate,
|
||||
@@ -1079,6 +1111,7 @@ const {
|
||||
customItemModalHistoryActive,
|
||||
modalTargetCustomItem,
|
||||
customItemModalDraftLabel,
|
||||
customItemModalDraftTags,
|
||||
customItemModalLabelSaving,
|
||||
customItemModalTargetTemplateId,
|
||||
customItemReplacementQuery,
|
||||
@@ -1237,6 +1270,7 @@ async function saveTemplateVisibility() {
|
||||
templateVisibilitySaving.value = true
|
||||
const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, {
|
||||
isPublic: !!selectedTemplate.value.template.isPublic,
|
||||
tags: Array.isArray(selectedTemplate.value.template.tags) ? selectedTemplate.value.template.tags : [],
|
||||
})
|
||||
const nextTemplate = data.template || {}
|
||||
selectedTemplate.value = {
|
||||
@@ -1265,6 +1299,7 @@ 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 || {}
|
||||
@@ -1277,8 +1312,9 @@ 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 = '템플릿 이름과 slug를 저장했어요.'
|
||||
success.value = '템플릿 메타를 저장했어요.'
|
||||
} catch (e) {
|
||||
const errorCode = e?.data?.error || ''
|
||||
if (errorCode === 'topic_slug_taken') {
|
||||
@@ -1289,7 +1325,7 @@ async function saveTemplateMeta() {
|
||||
error.value = 'slug는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.'
|
||||
return
|
||||
}
|
||||
error.value = '템플릿 이름/slug를 저장하지 못했어요.'
|
||||
error.value = '템플릿 메타를 저장하지 못했어요.'
|
||||
} finally {
|
||||
templateMetaSaving.value = false
|
||||
}
|
||||
@@ -1360,20 +1396,23 @@ 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) return
|
||||
if (nextLabel === item.label && JSON.stringify(nextTags) === JSON.stringify(Array.isArray(item.tags) ? item.tags : [])) return
|
||||
|
||||
try {
|
||||
item.isSavingLabel = true
|
||||
const data = await api.updateAdminTemplateItem(selectedTemplateId.value, item.id, { label: nextLabel })
|
||||
const data = await api.updateAdminTemplateItem(selectedTemplateId.value, item.id, { label: nextLabel, tags: nextTags })
|
||||
item.label = data.item.label
|
||||
item.draftLabel = data.item.label
|
||||
success.value = '기본 아이템 이름을 수정했어요.'
|
||||
item.tags = Array.isArray(data.item.tags) ? data.item.tags : []
|
||||
item.draftTags = item.tags.join(', ')
|
||||
success.value = '기본 아이템 메타를 수정했어요.'
|
||||
} catch (e) {
|
||||
error.value = '기본 아이템 이름 수정에 실패했어요.'
|
||||
error.value = '기본 아이템 메타 수정에 실패했어요.'
|
||||
} finally {
|
||||
item.isSavingLabel = false
|
||||
}
|
||||
@@ -1482,6 +1521,7 @@ function buildModalItemFromTierListItem(item, tierList) {
|
||||
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'asset' : 'user'),
|
||||
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
|
||||
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
|
||||
tags: Array.isArray(matchedItem?.tags) ? matchedItem.tags : [],
|
||||
linkedTemplates: Array.isArray(matchedItem?.linkedTemplates) ? matchedItem.linkedTemplates : [],
|
||||
usageCount: matchedItem?.usageCount || 0,
|
||||
canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false,
|
||||
@@ -1716,6 +1756,63 @@ function closeTierListImportModal() {
|
||||
importModalItems.value = []
|
||||
}
|
||||
|
||||
function openTemplateSourceImportModal() {
|
||||
resetMessages()
|
||||
if (!selectedTemplateId.value) {
|
||||
error.value = '먼저 가져올 대상을 받을 템플릿을 선택해주세요.'
|
||||
return
|
||||
}
|
||||
templateSourceImportSelectedIds.value = []
|
||||
templateSourceImportQuery.value = ''
|
||||
templateSourceImportModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeTemplateSourceImportModal() {
|
||||
templateSourceImportModalOpen.value = false
|
||||
templateSourceImportSelectedIds.value = []
|
||||
templateSourceImportQuery.value = ''
|
||||
}
|
||||
|
||||
function toggleTemplateSourceImportSelection(templateId) {
|
||||
if (!templateId) return
|
||||
if (templateSourceImportSelectedIds.value.includes(templateId)) {
|
||||
templateSourceImportSelectedIds.value = templateSourceImportSelectedIds.value.filter((id) => id !== templateId)
|
||||
return
|
||||
}
|
||||
templateSourceImportSelectedIds.value = [...templateSourceImportSelectedIds.value, templateId]
|
||||
}
|
||||
|
||||
async function confirmTemplateSourceImport() {
|
||||
resetMessages()
|
||||
if (!selectedTemplateId.value) {
|
||||
error.value = '가져올 대상을 받을 템플릿을 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
if (!templateSourceImportSelectedIds.value.length) {
|
||||
error.value = '아이템을 가져올 원본 템플릿을 하나 이상 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let stagedCount = 0
|
||||
for (const templateId of templateSourceImportSelectedIds.value) {
|
||||
const template = templates.value.find((entry) => entry.id === templateId)
|
||||
const data = await api.getTopic(templateId)
|
||||
const libraryItems = (data.items || []).map((item) => ({
|
||||
...item,
|
||||
sourceType: 'template',
|
||||
}))
|
||||
stagedCount += mergeLibraryItemsIntoDrafts(libraryItems, template?.name || data.topic?.name || templateId)
|
||||
}
|
||||
closeTemplateSourceImportModal()
|
||||
success.value = stagedCount
|
||||
? `${stagedCount}개의 기존 템플릿 아이템을 초안 목록에 추가했어요. 필요 없는 항목은 제외한 뒤 저장하면 됩니다.`
|
||||
: '추가로 가져올 새 아이템이 없었어요.'
|
||||
} catch (e) {
|
||||
error.value = '기존 템플릿 아이템을 가져오지 못했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmTierListImport() {
|
||||
resetMessages()
|
||||
if (!importModalTierList.value || !importModalItems.value.length) {
|
||||
@@ -1849,12 +1946,14 @@ function openUserProfile(user) {
|
||||
:staged-request-draft-count="stagedRequestDraftCount"
|
||||
:applied-request-item-count="appliedRequestItemCount"
|
||||
:open-template-create-modal="openTemplateCreateModal"
|
||||
:open-template-source-import-modal="openTemplateSourceImportModal"
|
||||
: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"
|
||||
@@ -2091,6 +2190,40 @@ function openUserProfile(user) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="templateSourceImportModalOpen" class="modalOverlay" @click.self="closeTemplateSourceImportModal">
|
||||
<div class="modalCard modalCard--import" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">기존 템플릿 아이템 가져오기</div>
|
||||
<div class="modalCard__desc">
|
||||
여러 템플릿의 기본 아이템을 초안 목록으로 모아둘 수 있어요. 이후 필요 없는 항목은 제외하고 현재 템플릿에만 저장하면 됩니다.
|
||||
</div>
|
||||
|
||||
<div class="modalCard__form">
|
||||
<input v-model="templateSourceImportQuery" class="input" placeholder="템플릿 이름, slug, 태그 검색" />
|
||||
</div>
|
||||
|
||||
<div class="templateImportList">
|
||||
<button
|
||||
v-for="template in filteredTemplateSourceImportTemplates"
|
||||
:key="template.id"
|
||||
type="button"
|
||||
class="adminTemplatePicker__item"
|
||||
:class="{ 'adminTemplatePicker__item--active': templateSourceImportSelectedIds.includes(template.id) }"
|
||||
@click="toggleTemplateSourceImportSelection(template.id)"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeTemplateSourceImportModal">취소</button>
|
||||
<button class="btn btn--primary" @click="confirmTemplateSourceImport">초안으로 가져오기</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">
|
||||
@@ -2174,13 +2307,18 @@ function openUserProfile(user) {
|
||||
<span class="field__label">아이템 이름</span>
|
||||
<input v-model="customItemModalDraftLabel" class="field__input" type="text" maxlength="60" placeholder="아이템 이름" />
|
||||
</label>
|
||||
<button class="btn btn--ghost customItemModal__renameButton" type="button" :disabled="customItemModalLabelSaving || !customItemModalDraftLabel.trim() || customItemModalDraftLabel.trim() === modalTargetCustomItem.label" @click="saveCustomItemModalLabel">
|
||||
{{ customItemModalLabelSaving ? '저장중...' : '이름 저장' }}
|
||||
<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" />
|
||||
</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 ? '저장중...' : '메타 저장' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="customItemModal__metaList">
|
||||
<div class="customItemModal__metaRow"><span>파일</span><strong :title="modalTargetCustomItem.src.split('/').pop()">{{ modalTargetCustomItem.src.split('/').pop() }}</strong></div>
|
||||
<div class="customItemModal__metaRow"><span>업로더/출처</span><strong :title="modalTargetCustomItem.ownerName">{{ modalTargetCustomItem.ownerName }}</strong></div>
|
||||
<div class="customItemModal__metaRow"><span>태그</span><strong>{{ modalTargetCustomItem.tags?.length ? '#' + modalTargetCustomItem.tags.join(' #') : '없음' }}</strong></div>
|
||||
<div class="customItemModal__metaRow"><span>템플릿 연결</span><strong>{{ visibleLinkedTemplates.length }}개 템플릿</strong></div>
|
||||
<div class="customItemModal__metaRow"><span>등록일</span><strong>{{ fmt(modalTargetCustomItem.createdAt) }}</strong></div>
|
||||
</div>
|
||||
@@ -2443,7 +2581,7 @@ function openUserProfile(user) {
|
||||
<div class="adminSidebar__label">Filters</div>
|
||||
<div class="adminSidebar__group">
|
||||
<div class="adminSidebar__inlineRow">
|
||||
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
|
||||
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 태그, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
|
||||
<button class="btn btn--ghost" @click="submitCustomItemSearch">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2830,6 +2968,12 @@ function openUserProfile(user) {
|
||||
opacity: 0.58;
|
||||
border-style: dashed;
|
||||
}
|
||||
.adminUiScope .templateImportList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
.adminUiScope .adminTemplatePicker__name {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
@@ -3302,7 +3446,7 @@ function openUserProfile(user) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
/* flex-wrap: wrap; */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.adminUiScope .selectedThumb {
|
||||
width: min(100%, 256px);
|
||||
|
||||
@@ -473,6 +473,25 @@ function duplicateItemToPool() {
|
||||
toast.success('아이템 추가 완료')
|
||||
}
|
||||
|
||||
function canRemoveEditorItem(itemId) {
|
||||
return canEdit.value && itemsById.value[itemId]?.origin === 'custom'
|
||||
}
|
||||
|
||||
function deleteEditorItem(itemId) {
|
||||
if (!canRemoveEditorItem(itemId)) return
|
||||
const targetItem = itemsById.value[itemId]
|
||||
detachItemById(itemId)
|
||||
if (typeof targetItem?.src === 'string' && targetItem.src.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(targetItem.src)
|
||||
}
|
||||
const nextItems = { ...itemsById.value }
|
||||
delete nextItems[itemId]
|
||||
itemsById.value = nextItems
|
||||
if (selectedItemId.value === itemId) selectedItemId.value = ''
|
||||
if (itemContextMenu.value.itemId === itemId) closeItemContextMenu()
|
||||
toast.success('커스텀 이미지를 현재 티어표에서 제거했어요.')
|
||||
}
|
||||
|
||||
function handleGlobalContextMenu(event) {
|
||||
const target = event?.target
|
||||
if (target?.closest?.('[data-item-context-menu]')) {
|
||||
@@ -1684,6 +1703,16 @@ onUnmounted(() => {
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<button
|
||||
v-if="canRemoveEditorItem(id) && !isExporting"
|
||||
class="cellDeleteBtn"
|
||||
type="button"
|
||||
title="커스텀 이미지 제거"
|
||||
@pointerdown.stop
|
||||
@click.stop="deleteEditorItem(id)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1760,6 +1789,16 @@ onUnmounted(() => {
|
||||
draggable="false"
|
||||
/>
|
||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||
<button
|
||||
v-if="canRemoveEditorItem(id)"
|
||||
class="poolItem__deleteBtn"
|
||||
type="button"
|
||||
title="커스텀 이미지 제거"
|
||||
@pointerdown.stop
|
||||
@click.stop="deleteEditorItem(id)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1777,6 +1816,9 @@ onUnmounted(() => {
|
||||
<button class="itemContextMenu__action" type="button" @click="duplicateItemToPool">
|
||||
아이템 복제
|
||||
</button>
|
||||
<button v-if="canRemoveEditorItem(itemContextMenu.itemId)" class="itemContextMenu__action itemContextMenu__action--danger" type="button" @click="deleteEditorItem(itemContextMenu.itemId)">
|
||||
이 커스텀 이미지 제거
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -3024,6 +3066,7 @@ onUnmounted(() => {
|
||||
cursor: copy;
|
||||
}
|
||||
.poolItem {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
@@ -3070,6 +3113,23 @@ onUnmounted(() => {
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.poolItem__deleteBtn,
|
||||
.cellDeleteBtn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 6px 9px;
|
||||
background: rgba(11, 18, 32, 0.84);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cellDeleteBtn {
|
||||
bottom: 34px;
|
||||
}
|
||||
.poolItem--hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -3101,6 +3161,9 @@ onUnmounted(() => {
|
||||
.itemContextMenu__action:hover {
|
||||
background: var(--theme-pill-bg);
|
||||
}
|
||||
.itemContextMenu__action--danger {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
|
||||
Reference in New Issue
Block a user