diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue index f97b15c..dc78edc 100644 --- a/components/admin/AdminPostForm.vue +++ b/components/admin/AdminPostForm.vue @@ -65,6 +65,9 @@ const isLoadingMedia = ref(false) const isUploadingFeaturedImage = ref(false) const isSettingsOpen = ref(true) const tagInput = ref('') +const tagInputRef = ref(null) +const isTagSuggestionsOpen = ref(false) +const activeTagSuggestionIndex = ref(0) const isTagInputComposing = ref(false) const isTitleInputComposing = ref(false) const activeMediaPickerTab = ref('upload') @@ -77,6 +80,12 @@ const publishTiming = ref('now') const scheduledPublishAt = ref('') const publishModalExpandedSection = ref(null) +const { data: adminTags } = useFetch('/admin/api/tags', { + default: () => [] +}) + +const defaultTagColor = '#15171a' + /** * ISO 날짜를 datetime-local 입력값으로 변환 * @param {string} value - ISO 날짜 문자열 @@ -364,6 +373,32 @@ const parseTags = (value) => { const selectedTags = computed(() => parseTags(form.tagsText)) +const selectedTagKeys = computed(() => new Set(selectedTags.value.map((tag) => tag.toLowerCase()))) + +const availableAdminTags = computed(() => Array.isArray(adminTags.value) ? adminTags.value : []) + +const managedTagOptions = computed(() => availableAdminTags.value.filter((tag) => tag.tagType === 'managed')) + +const tagSuggestionOptions = computed(() => { + const keyword = normalizeTagToken(tagInput.value) + const sourceTags = keyword ? availableAdminTags.value : managedTagOptions.value + + return sourceTags + .filter((tag) => { + if (!tag?.slug || selectedTagKeys.value.has(tag.slug.toLowerCase())) { + return false + } + if (!keyword) { + return true + } + + return tag.name.toLowerCase().includes(keyword) || tag.slug.toLowerCase().includes(keyword) + }) + .slice(0, 8) +}) + +const hasTagSuggestions = computed(() => isTagSuggestionsOpen.value && tagSuggestionOptions.value.length > 0) + /** * 예약 발행 여부 확인 * @returns {boolean} 예약 발행 여부 @@ -656,16 +691,41 @@ const removeFeaturedImage = () => { * 태그 입력값을 배지 목록에 추가 * @returns {void} */ -const addTagFromInput = () => { - const nextTag = normalizeTagToken(tagInput.value) +const addTagToken = (value) => { + const nextTag = normalizeTagToken(value) if (!nextTag) { tagInput.value = '' return } - form.tagsText = [...new Set([...selectedTags.value, nextTag])].join(', ') + const nextTags = [...selectedTags.value] + if (!selectedTagKeys.value.has(nextTag.toLowerCase())) { + nextTags.push(nextTag) + } + + form.tagsText = nextTags.join(', ') tagInput.value = '' + activeTagSuggestionIndex.value = 0 +} + +/** + * 태그 입력값을 배지 목록에 추가 + * @returns {void} + */ +const addTagFromInput = () => { + addTagToken(tagInput.value) +} + +/** + * 기존 태그 추천 항목을 배지 목록에 추가 + * @param {Object} tag - 태그 항목 + * @returns {void} + */ +const selectTagSuggestion = (tag) => { + addTagToken(tag.slug) + isTagSuggestionsOpen.value = false + tagInputRef.value?.focus() } /** @@ -677,6 +737,75 @@ const removeTag = (tag) => { form.tagsText = selectedTags.value.filter((item) => item !== tag).join(', ') } +/** + * 태그 슬러그의 화면 표시 이름을 반환한다. + * @param {string} slug - 태그 슬러그 + * @returns {string} 태그 표시 이름 + */ +const getTagDisplayName = (slug) => availableAdminTags.value.find((tag) => tag.slug === slug)?.name || slug + +/** + * 태그 슬러그의 고유 색상을 반환한다. + * @param {string} slug - 태그 슬러그 + * @returns {string} 태그 색상 + */ +const getTagColor = (slug) => availableAdminTags.value.find((tag) => tag.slug === slug)?.color || defaultTagColor + +/** + * 태그 배지 스타일을 생성한다. + * @param {string} color - 태그 색상 + * @returns {Object} 배지 인라인 스타일 + */ +const createTagBadgeStyle = (color) => ({ + backgroundColor: `color-mix(in srgb, ${color} 14%, white)`, + borderColor: `color-mix(in srgb, ${color} 34%, white)`, + color +}) + +/** + * 태그 추천 목록의 보조 라벨을 반환한다. + * @param {Object} tag - 태그 항목 + * @returns {string} 보조 라벨 + */ +const getTagSuggestionMeta = (tag) => { + const typeLabel = tag.tagType === 'managed' ? '메인' : '일반' + return tag.name.toLowerCase() === tag.slug.toLowerCase() ? typeLabel : `${typeLabel} · ${tag.slug}` +} + +/** + * 태그 추천 목록을 열고 입력에 포커스한다. + * @returns {void} + */ +const openTagSuggestions = () => { + isTagSuggestionsOpen.value = true + activeTagSuggestionIndex.value = 0 + tagInputRef.value?.focus() +} + +/** + * 태그 추천 목록을 토글한다. + * @returns {void} + */ +const toggleTagSuggestions = () => { + if (isTagSuggestionsOpen.value) { + isTagSuggestionsOpen.value = false + return + } + + openTagSuggestions() +} + +/** + * 태그 입력 포커스 해제 처리 + * @returns {void} + */ +const handleTagBlur = () => { + window.setTimeout(() => { + addTagFromInput() + isTagSuggestionsOpen.value = false + }, 80) +} + /** * 태그 입력 키 처리 * @param {KeyboardEvent} event - 키보드 이벤트 @@ -687,8 +816,44 @@ const handleTagKeydown = (event) => { return } + if (event.key === 'ArrowDown' && tagSuggestionOptions.value.length) { + event.preventDefault() + if (!isTagSuggestionsOpen.value) { + isTagSuggestionsOpen.value = true + activeTagSuggestionIndex.value = 0 + return + } + isTagSuggestionsOpen.value = true + activeTagSuggestionIndex.value = (activeTagSuggestionIndex.value + 1) % tagSuggestionOptions.value.length + return + } + + if (event.key === 'ArrowUp' && tagSuggestionOptions.value.length) { + event.preventDefault() + if (!isTagSuggestionsOpen.value) { + isTagSuggestionsOpen.value = true + activeTagSuggestionIndex.value = tagSuggestionOptions.value.length - 1 + return + } + isTagSuggestionsOpen.value = true + activeTagSuggestionIndex.value = activeTagSuggestionIndex.value === 0 + ? tagSuggestionOptions.value.length - 1 + : activeTagSuggestionIndex.value - 1 + return + } + + if (event.key === 'Escape' && isTagSuggestionsOpen.value) { + event.preventDefault() + isTagSuggestionsOpen.value = false + return + } + if (event.key === 'Enter' || event.key === ',') { event.preventDefault() + if (event.key === 'Enter' && hasTagSuggestions.value) { + selectTagSuggestion(tagSuggestionOptions.value[activeTagSuggestionIndex.value] || tagSuggestionOptions.value[0]) + return + } addTagFromInput() return } @@ -1393,8 +1558,10 @@ defineExpose({

게시물 설정

- @@ -1443,10 +1610,16 @@ defineExpose({
@@ -1504,36 +1677,76 @@ defineExpose({
Tags -