admin: simplify template tagging flow
This commit is contained in:
145
frontend/src/components/TagBadgeInput.vue
Normal file
145
frontend/src/components/TagBadgeInput.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<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 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.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
addDraftTag()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' && !draft.value && normalizedTags.value.length) {
|
||||
removeTag(normalizedTags.value[normalizedTags.value.length - 1])
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
addDraftTag()
|
||||
}
|
||||
</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"
|
||||
/>
|
||||
</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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user