최근 태그 추천과 미디어 셀렉트 정리

This commit is contained in:
2026-07-03 10:04:47 +09:00
parent 7f20949669
commit b4e408dec8
10 changed files with 115 additions and 34 deletions

View File

@@ -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<Object>} sourceTags - 원본 태그 목록
* @param {string} keyword - 검색어
* @returns {Array<Object>} 선택 가능한 추천 태그
*/
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<Object>} tags - 태그 목록
* @returns {Array<Object>} 중복 제거된 태그 목록
*/
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"
>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down size-5" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">