diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue index 374f14f..d81f980 100644 --- a/components/admin/AdminPostForm.vue +++ b/components/admin/AdminPostForm.vue @@ -95,6 +95,9 @@ const { data: siteSettings } = useFetch('/admin/api/settings', { }) const defaultTagColor = '#15171a' +const recentTagSuggestionLimit = 5 +const defaultTagSuggestionLimit = 8 +const emptyTagSuggestionLimit = 13 /** @type {number} 한국어 본문 예상 읽기 속도(분당 공백 제외 문자 수) */ const READING_CHARS_PER_MINUTE = 600 @@ -506,25 +509,77 @@ const availableAdminTags = computed(() => Array.isArray(adminTags.value) ? admin const managedTagOptions = computed(() => availableAdminTags.value.filter((tag) => tag.tagType === 'managed')) +/** + * 태그의 최근 사용 시각을 timestamp로 변환한다. + * @param {Object} tag - 태그 항목 + * @returns {number} 최근 사용 timestamp + */ +const getTagLastUsedTime = (tag) => { + const timestamp = Date.parse(tag?.lastUsedAt || '') + return Number.isFinite(timestamp) ? timestamp : 0 +} + +const recentTagOptions = computed(() => availableAdminTags.value + .filter((tag) => getTagLastUsedTime(tag) > 0) + .sort((left, right) => getTagLastUsedTime(right) - getTagLastUsedTime(left)) + .slice(0, recentTagSuggestionLimit)) + +const recentTagSlugSet = computed(() => new Set(recentTagOptions.value.map((tag) => String(tag.slug || '').toLowerCase()))) + +/** + * 태그 추천 후보를 선택 가능한 항목으로 정리한다. + * @param {Array} sourceTags - 원본 태그 목록 + * @param {string} keyword - 검색어 + * @returns {Array} 선택 가능한 추천 태그 + */ +const filterTagSuggestionSource = (sourceTags, keyword = '') => sourceTags.filter((tag) => { + if (!canAddMoreTags.value) { + return false + } + 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) +}) + +/** + * 중복 태그를 제거한다. + * @param {Array} tags - 태그 목록 + * @returns {Array} 중복 제거된 태그 목록 + */ +const dedupeTagsBySlug = (tags) => { + const seen = new Set() + const out = [] + + for (const tag of tags) { + const slugKey = String(tag?.slug || '').toLowerCase() + + if (!slugKey || seen.has(slugKey)) { + continue + } + + seen.add(slugKey) + out.push(tag) + } + + return out +} + const tagSuggestionOptions = computed(() => { const keyword = normalizeTagToken(tagInput.value) - const sourceTags = keyword ? availableAdminTags.value : managedTagOptions.value - return sourceTags - .filter((tag) => { - if (!canAddMoreTags.value) { - return false - } - if (!tag?.slug || selectedTagKeys.value.has(tag.slug.toLowerCase())) { - return false - } - if (!keyword) { - return true - } + if (keyword) { + return filterTagSuggestionSource(availableAdminTags.value, keyword).slice(0, defaultTagSuggestionLimit) + } - return tag.name.toLowerCase().includes(keyword) || tag.slug.toLowerCase().includes(keyword) - }) - .slice(0, 8) + return dedupeTagsBySlug([ + ...filterTagSuggestionSource(recentTagOptions.value), + ...filterTagSuggestionSource(managedTagOptions.value) + ]).slice(0, emptyTagSuggestionLimit) }) const hasTagSuggestions = computed(() => isTagSuggestionsOpen.value && tagSuggestionOptions.value.length > 0) @@ -930,7 +985,10 @@ const createTagBadgeStyle = (color) => ({ * @returns {string} 보조 라벨 */ const getTagSuggestionMeta = (tag) => { - const typeLabel = tag.tagType === 'managed' ? '메인' : '일반' + const slugKey = String(tag?.slug || '').toLowerCase() + const typeLabel = recentTagSlugSet.value.has(slugKey) + ? '최근' + : (tag.tagType === 'managed' ? '메인' : '일반') return tag.name.toLowerCase() === tag.slug.toLowerCase() ? typeLabel : `${typeLabel} · ${tag.slug}` } @@ -1986,7 +2044,7 @@ defineExpose({ type="button" :aria-expanded="hasTagSuggestions" :disabled="!canAddMoreTags" - aria-label="메인 태그 목록 열기" + aria-label="태그 추천 목록 열기" @mousedown.prevent="toggleTagSuggestions" >