diff --git a/docs/history.md b/docs/history.md index 4ff8d67..bbec674 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-04-06 v1.4.89 +- 템플릿 화면에서 이름/slug 저장과 아이템 태그 일괄 추가는 성격이 다르므로, 기존처럼 하나의 `메타` 개념으로 묶기보다 `이름/주소 저장`과 `공통 태그 추가`를 분리해 보여주는 편이 운영자가 이해하기 쉽다고 정리했다. +- 또 `templateSettingsCard`는 버튼 문구가 비교적 길고 썸네일/폼/토글이 함께 들어가는 카드라서, 좁은 폭에서 각 블록의 최소 너비를 풀어 주지 않으면 카드 밖으로 밀려나기 쉬우므로 입력 필드와 액션 버튼 모두 카드 내부에서 줄어들고 줄바꿈되게 하는 쪽이 맞다고 판단했다. +- 템플릿 기본 아이템 카드도 작은 썸네일 위에 버튼 두 개를 계속 노출하면 카드 높이가 불필요하게 커지고 반복 조작 밀도가 떨어지므로, 저장은 입력 후 `Enter`, 삭제는 우상단 `X`처럼 더 직접적인 마이크로 인터랙션으로 옮기는 편이 낫다고 정리했다. + ## 2026-04-06 v1.4.88 - 같은 이미지를 사용자 항목, 템플릿 항목, 관리자 자산으로 각각 따로 카드에 늘어놓으면 운영자가 실제로 보고 싶은 “이미지 단위 상태”보다 내부 저장 단위가 더 크게 드러나므로, 관리자 목록과 검색은 기본적으로 같은 `src`를 하나로 묶어 보여주는 편이 더 자연스럽다고 정리했다. - 아이템 모달의 연결 템플릿 배지는 단순히 해당 템플릿 화면으로 점프하는 것보다, 그 자리에서 `이 템플릿에서 제외`를 바로 수행하는 액션이 훨씬 직접적이므로 배지에 제거 버튼을 붙이는 쪽이 더 낫다고 판단했다. diff --git a/docs/update.md b/docs/update.md index 8bd1e3c..a8bfb56 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,13 @@ # 업데이트 로그 +## 2026-04-06 v1.4.89 +- 템플릿 관리의 `템플릿 메타 저장` 버튼은 실제 역할에 맞춰 `이름/주소 저장`으로 바꿨다. 이제 이 버튼은 템플릿 이름과 slug 저장만 담당한다. +- 대신 현재 템플릿의 기본 아이템 전체에 같은 태그를 한 번에 추가하는 `기본 아이템 공통 태그` 기능을 추가했다. 배지형 입력으로 태그를 넣고 적용하면, 같은 태그는 중복 없이 각 아이템에 합쳐 저장된다. +- 운영 문구도 `메타`보다 실제 의미가 분명한 `태그` 기준으로 맞췄다. +- `adminCard templateSettingsCard`는 화면이 좁아질 때 내부 액션 버튼과 입력 필드가 카드 밖으로 밀려나지 않도록 최소 너비와 버튼 줄바꿈 규칙을 보정했다. +- 템플릿 기본 아이템 카드(`thumbCard`)는 `이름 저장`/`아이템 삭제` 버튼을 걷어내고, 이름 입력 후 `Enter`로 바로 저장되게 바꿨다. 삭제는 티어표 편집기처럼 우상단 `X` 버튼으로 옮겨 카드 높이를 더 작게 유지한다. +- 확인: `npm run build` + ## 2026-04-06 v1.4.88 - 관리자 아이템 목록과 개별 아이템 검색에서는 같은 이미지 `src`를 공유하는 항목을 하나로 묶어 보여주도록 조정했다. 사용자 아이템, 템플릿 아이템, 관리자 자산이 같은 이미지를 가리키는 경우 카드가 반복해서 보이던 문제를 줄였다. - 아이템 모달의 `이 이미지를 사용하는 템플릿` 배지는 더 이상 단순 이동 버튼이 아니고, 각 배지의 `X` 버튼으로 해당 템플릿에서 이미지를 바로 제외할 수 있게 바꿨다. diff --git a/frontend/src/components/admin/AdminTemplatesSection.vue b/frontend/src/components/admin/AdminTemplatesSection.vue index 5136dbb..7891e45 100644 --- a/frontend/src/components/admin/AdminTemplatesSection.vue +++ b/frontend/src/components/admin/AdminTemplatesSection.vue @@ -11,6 +11,7 @@ const props = defineProps({ openTemplateCreateModal: { type: Function, required: true }, openTemplateSourceImportModal: { type: Function, required: true }, openTemplateLibraryItemModal: { type: Function, required: true }, + openTemplateBulkTagModal: { type: Function, required: true }, isTemplateLoading: { type: Boolean, required: true }, hasSelectedTemplate: { type: Boolean, required: true }, selectedTemplate: { type: Object, default: null }, @@ -20,6 +21,7 @@ const props = defineProps({ templateMetaSaving: { type: Boolean, required: true }, canSaveTemplateMeta: { type: Boolean, required: true }, saveTemplateMeta: { type: Function, required: true }, + canBulkTagTemplateItems: { type: Boolean, required: true }, canApplyThumbnail: { type: Boolean, required: true }, templateVisibilitySaving: { type: Boolean, required: true }, thumbFileInputRef: { type: Function, required: true }, @@ -172,8 +174,9 @@ function setThumbFileElement(el) {
+ @@ -252,19 +255,17 @@ function setThumbFileElement(el) {
아직 등록된 기본 아이템이 없어요.
- - -
- - +
+ +
+
diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 72c6add..5c2f7ad 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -79,6 +79,9 @@ const templateLibraryItemQuery = ref('') const templateLibraryItemResults = ref([]) const templateLibraryItemSelectedIds = ref([]) const templateLibraryItemLoading = ref(false) +const templateBulkTagModalOpen = ref(false) +const templateBulkTagDrafts = ref([]) +const templateBulkTagSaving = ref(false) const previewModalOpen = ref(false) const previewTierList = ref(null) const adminTierListManageModalOpen = ref(false) @@ -219,6 +222,7 @@ const canSaveTemplateMeta = computed(() => { ) }) const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value) +const canBulkTagTemplateItems = computed(() => !!selectedTemplate.value?.items?.length) const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedTemplateId.value) const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length) const appliedRequestItemCount = computed(() => { @@ -365,6 +369,7 @@ const isAnyModalOpen = computed( importModalOpen.value || templateSourceImportModalOpen.value || templateLibraryItemModalOpen.value || + templateBulkTagModalOpen.value || templatePickerModalOpen.value || customItemModalOpen.value || customItemDeleteModalOpen.value || @@ -1319,7 +1324,7 @@ async function saveTemplateMeta() { templateMetaDraftName.value = nextTemplate.name || selectedTemplate.value.template.name || '' templateMetaDraftSlug.value = nextTemplate.slug || selectedTemplate.value.template.slug || selectedTemplate.value.template.id || '' await refreshTemplates() - success.value = '템플릿 메타를 저장했어요.' + success.value = '템플릿 이름과 주소를 저장했어요.' } catch (e) { const errorCode = e?.data?.error || '' if (errorCode === 'topic_slug_taken') { @@ -1330,12 +1335,69 @@ async function saveTemplateMeta() { error.value = 'slug는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.' return } - error.value = '템플릿 메타를 저장하지 못했어요.' + error.value = '템플릿 이름과 주소를 저장하지 못했어요.' } finally { templateMetaSaving.value = false } } +function openTemplateBulkTagModal() { + resetMessages() + if (!selectedTemplate.value?.items?.length) { + error.value = '태그를 추가할 기본 아이템이 없어요.' + return + } + templateBulkTagDrafts.value = [] + templateBulkTagSaving.value = false + templateBulkTagModalOpen.value = true +} + +function closeTemplateBulkTagModal() { + templateBulkTagModalOpen.value = false + templateBulkTagDrafts.value = [] + templateBulkTagSaving.value = false +} + +async function applyTemplateBulkTags() { + resetMessages() + if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) { + error.value = '태그를 적용할 템플릿을 먼저 선택해주세요.' + return + } + + const nextTags = parseAdminTagsText(templateBulkTagDrafts.value) + if (!nextTags.length) { + error.value = '추가할 태그를 하나 이상 입력해주세요.' + return + } + + try { + templateBulkTagSaving.value = true + const items = selectedTemplate.value.items || [] + let updatedCount = 0 + + for (const item of items) { + const mergedTags = Array.from(new Set([...(Array.isArray(item.tags) ? item.tags : []), ...nextTags])) + if (JSON.stringify(mergedTags) === JSON.stringify(Array.isArray(item.tags) ? item.tags : [])) continue + const data = await api.updateAdminTemplateItem(selectedTemplateId.value, item.id, { + label: item.label, + tags: mergedTags, + }) + item.tags = Array.isArray(data.item?.tags) ? data.item.tags : mergedTags + updatedCount += 1 + } + + closeTemplateBulkTagModal() + success.value = updatedCount + ? `기본 아이템 ${updatedCount}개에 공통 태그를 추가했어요.` + : '이미 같은 태그가 들어 있어서 바뀐 항목이 없었어요.' + } catch (e) { + error.value = '기본 아이템 공통 태그 추가에 실패했어요.' + } finally { + templateBulkTagSaving.value = false + } +} + async function toggleSelectedTemplateVisibility(nextValue) { if (!selectedTemplate.value?.template?.id || templateVisibilitySaving.value) return const previous = !!selectedTemplate.value.template.isPublic @@ -2033,6 +2095,7 @@ function openUserProfile(user) { :open-template-create-modal="openTemplateCreateModal" :open-template-source-import-modal="openTemplateSourceImportModal" :open-template-library-item-modal="openTemplateLibraryItemModal" + :open-template-bulk-tag-modal="openTemplateBulkTagModal" :is-template-loading="isTemplateLoading" :has-selected-template="hasSelectedTemplate" :selected-template="selectedTemplate" @@ -2042,6 +2105,7 @@ function openUserProfile(user) { :template-meta-saving="templateMetaSaving" :can-save-template-meta="canSaveTemplateMeta" :save-template-meta="saveTemplateMeta" + :can-bulk-tag-template-items="canBulkTagTemplateItems" :can-apply-thumbnail="canApplyThumbnail" :template-visibility-saving="templateVisibilitySaving" :thumb-file-input-ref="setThumbFileInputRef" @@ -2362,6 +2426,27 @@ function openUserProfile(user) {
+
+ +
+