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({
게시물 설정
-