게시물 태그 입력 로직 분리
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
|||||||
isPostCardThumbnailUrl,
|
isPostCardThumbnailUrl,
|
||||||
withGeneratedImageCacheVersion
|
withGeneratedImageCacheVersion
|
||||||
} from '../../lib/post-card-thumbnail-url.js'
|
} from '../../lib/post-card-thumbnail-url.js'
|
||||||
import { DEFAULT_POST_TAG_LIMIT, normalizePostTagLimit } from '../../lib/post-tag-limit.js'
|
import { DEFAULT_POST_TAG_LIMIT } from '../../lib/post-tag-limit.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
initialPost: {
|
initialPost: {
|
||||||
@@ -72,12 +72,6 @@ const mediaPickerTarget = ref('featuredImage')
|
|||||||
const isLoadingMedia = ref(false)
|
const isLoadingMedia = ref(false)
|
||||||
const isUploadingFeaturedImage = ref(false)
|
const isUploadingFeaturedImage = ref(false)
|
||||||
const isSettingsOpen = ref(true)
|
const isSettingsOpen = ref(true)
|
||||||
const tagInput = ref('')
|
|
||||||
const tagInputRef = ref(null)
|
|
||||||
const pendingTagNames = ref({})
|
|
||||||
const isTagSuggestionsOpen = ref(false)
|
|
||||||
const activeTagSuggestionIndex = ref(0)
|
|
||||||
const isTagInputComposing = ref(false)
|
|
||||||
const isTitleInputComposing = ref(false)
|
const isTitleInputComposing = ref(false)
|
||||||
const activeMediaPickerTab = ref('upload')
|
const activeMediaPickerTab = ref('upload')
|
||||||
const selectedMediaPickerUrl = ref('')
|
const selectedMediaPickerUrl = ref('')
|
||||||
@@ -112,10 +106,6 @@ const { data: siteSettings } = useFetch('/admin/api/settings', {
|
|||||||
default: () => ({ postTagLimit: DEFAULT_POST_TAG_LIMIT })
|
default: () => ({ postTagLimit: DEFAULT_POST_TAG_LIMIT })
|
||||||
})
|
})
|
||||||
|
|
||||||
const defaultTagColor = '#15171a'
|
|
||||||
const recentTagSuggestionLimit = 5
|
|
||||||
const defaultTagSuggestionLimit = 8
|
|
||||||
const emptyTagSuggestionLimit = 13
|
|
||||||
/** @type {number} 한국어 본문 예상 읽기 속도(분당 공백 제외 문자 수) */
|
/** @type {number} 한국어 본문 예상 읽기 속도(분당 공백 제외 문자 수) */
|
||||||
const READING_CHARS_PER_MINUTE = 600
|
const READING_CHARS_PER_MINUTE = 600
|
||||||
|
|
||||||
@@ -437,36 +427,6 @@ const toSlug = (value) => value
|
|||||||
.replace(/[-_]+/g, (match) => match.includes('_') ? '_' : '-')
|
.replace(/[-_]+/g, (match) => match.includes('_') ? '_' : '-')
|
||||||
.replace(/^[-_]|[-_]$/g, '')
|
.replace(/^[-_]|[-_]$/g, '')
|
||||||
|
|
||||||
/**
|
|
||||||
* 게시물 태그 입력 토큰 정규화(한글 유지, 공백은 하이픈으로)
|
|
||||||
* @param {string} value - 원본 문자열
|
|
||||||
* @returns {string} 정규화된 태그 문자열
|
|
||||||
*/
|
|
||||||
const normalizeTagToken = (value) => {
|
|
||||||
const raw = String(value).normalize('NFC').trim().toLowerCase()
|
|
||||||
if (!raw) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return raw
|
|
||||||
.replace(/[\\/]+/g, '-')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/[^a-z0-9가-힣-]+/g, '')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.replace(/^-|-$/g, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 새 태그의 화면 표시 이름을 정리한다.
|
|
||||||
* @param {string} value - 원본 태그 입력값
|
|
||||||
* @returns {string} 표시 이름
|
|
||||||
*/
|
|
||||||
const normalizeTagDisplayName = (value) => String(value)
|
|
||||||
.normalize('NFC')
|
|
||||||
.trim()
|
|
||||||
.replace(/\s*\/\s*/g, '/')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 슬러그 입력 포커스 아웃 시 정규화·자동 모드 복귀
|
* 슬러그 입력 포커스 아웃 시 정규화·자동 모드 복귀
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -483,118 +443,32 @@ const commitSlugInput = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const {
|
||||||
* 쉼표 구분 태그 문자열을 토큰 배열로 변환
|
activeTagSuggestionIndex,
|
||||||
* @param {string} value - 태그 입력 문자열
|
canAddMoreTags,
|
||||||
* @returns {Array<string>} 태그 토큰 목록
|
createTagBadgeStyle,
|
||||||
*/
|
getTagColor,
|
||||||
const parseTags = (value) => {
|
getTagDisplayName,
|
||||||
const seen = new Set()
|
getTagSuggestionMeta,
|
||||||
const out = []
|
handleTagBlur,
|
||||||
|
handleTagKeydown,
|
||||||
for (const part of value.split(',')) {
|
hasTagSuggestions,
|
||||||
const tag = normalizeTagToken(part)
|
isTagInputComposing,
|
||||||
if (!tag) {
|
openTagSuggestions,
|
||||||
continue
|
postTagLimit,
|
||||||
}
|
removeTag,
|
||||||
const dedupeKey = tag.toLowerCase()
|
selectedTags,
|
||||||
if (seen.has(dedupeKey)) {
|
selectTagSuggestion,
|
||||||
continue
|
tagInput,
|
||||||
}
|
tagInputRef,
|
||||||
seen.add(dedupeKey)
|
tagSuggestionOptions,
|
||||||
out.push(tag)
|
toggleTagSuggestions
|
||||||
}
|
} = useAdminPostTags({
|
||||||
|
form,
|
||||||
return out
|
adminTags,
|
||||||
}
|
siteSettings
|
||||||
|
|
||||||
const selectedTags = computed(() => parseTags(form.tagsText))
|
|
||||||
|
|
||||||
const selectedTagKeys = computed(() => new Set(selectedTags.value.map((tag) => tag.toLowerCase())))
|
|
||||||
|
|
||||||
const postTagLimit = computed(() => normalizePostTagLimit(siteSettings.value?.postTagLimit))
|
|
||||||
|
|
||||||
const canAddMoreTags = computed(() => selectedTags.value.length < postTagLimit.value)
|
|
||||||
|
|
||||||
const availableAdminTags = computed(() => Array.isArray(adminTags.value) ? adminTags.value : [])
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
if (keyword) {
|
|
||||||
return filterTagSuggestionSource(availableAdminTags.value, keyword).slice(0, defaultTagSuggestionLimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
return dedupeTagsBySlug([
|
|
||||||
...filterTagSuggestionSource(recentTagOptions.value),
|
|
||||||
...filterTagSuggestionSource(managedTagOptions.value)
|
|
||||||
]).slice(0, emptyTagSuggestionLimit)
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasTagSuggestions = computed(() => isTagSuggestionsOpen.value && tagSuggestionOptions.value.length > 0)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 예약 발행 여부 확인
|
* 예약 발행 여부 확인
|
||||||
* @returns {boolean} 예약 발행 여부
|
* @returns {boolean} 예약 발행 여부
|
||||||
@@ -1057,215 +931,6 @@ const removeFeaturedImage = () => {
|
|||||||
form.featuredImage = ''
|
form.featuredImage = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 태그 입력값을 배지 목록에 추가
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const addTagToken = (value) => {
|
|
||||||
const displayName = normalizeTagDisplayName(value)
|
|
||||||
const nextTag = normalizeTagToken(value)
|
|
||||||
|
|
||||||
if (!nextTag) {
|
|
||||||
tagInput.value = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedTagKeys.value.has(nextTag.toLowerCase()) && !canAddMoreTags.value) {
|
|
||||||
tagInput.value = ''
|
|
||||||
isTagSuggestionsOpen.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextTags = [...selectedTags.value]
|
|
||||||
if (!selectedTagKeys.value.has(nextTag.toLowerCase())) {
|
|
||||||
nextTags.push(nextTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasExistingTag = availableAdminTags.value.some((tag) => tag.slug === nextTag)
|
|
||||||
if (!hasExistingTag && displayName) {
|
|
||||||
pendingTagNames.value = {
|
|
||||||
...pendingTagNames.value,
|
|
||||||
[nextTag]: displayName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = canAddMoreTags.value
|
|
||||||
nextTick(() => {
|
|
||||||
tagInputRef.value?.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 태그 배지 삭제
|
|
||||||
* @param {string} tag - 삭제할 태그
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
|| pendingTagNames.value[slug]
|
|
||||||
|| 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 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}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 태그 추천 목록을 열고 입력에 포커스한다.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const openTagSuggestions = () => {
|
|
||||||
if (!canAddMoreTags.value) {
|
|
||||||
isTagSuggestionsOpen.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isTagSuggestionsOpen.value = true
|
|
||||||
activeTagSuggestionIndex.value = 0
|
|
||||||
tagInputRef.value?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 태그 추천 목록을 토글한다.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const toggleTagSuggestions = () => {
|
|
||||||
if (!canAddMoreTags.value) {
|
|
||||||
isTagSuggestionsOpen.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTagSuggestionsOpen.value) {
|
|
||||||
isTagSuggestionsOpen.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
openTagSuggestions()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 태그 입력 포커스 해제 처리
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const handleTagBlur = () => {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
addTagFromInput()
|
|
||||||
isTagSuggestionsOpen.value = false
|
|
||||||
}, 80)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 태그 입력 키 처리
|
|
||||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const handleTagKeydown = (event) => {
|
|
||||||
if (event.isComposing || isTagInputComposing.value || event.keyCode === 229) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'Backspace' && !tagInput.value && selectedTags.value.length) {
|
|
||||||
event.preventDefault()
|
|
||||||
removeTag(selectedTags.value.at(-1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대표 이미지 파일 업로드
|
* 대표 이미지 파일 업로드
|
||||||
* @param {File} file - 업로드 파일
|
* @param {File} file - 업로드 파일
|
||||||
|
|||||||
398
composables/useAdminPostTags.js
Normal file
398
composables/useAdminPostTags.js
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
import { computed, nextTick, ref } from 'vue'
|
||||||
|
import { normalizePostTagLimit } from '../lib/post-tag-limit.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물 태그 입력 토큰 정규화(한글 유지, 공백은 하이픈으로).
|
||||||
|
* @param {string} value - 원본 문자열
|
||||||
|
* @returns {string} 정규화된 태그 문자열
|
||||||
|
*/
|
||||||
|
const normalizeTagToken = (value) => {
|
||||||
|
const raw = String(value).normalize('NFC').trim().toLowerCase()
|
||||||
|
if (!raw) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.replace(/[\\/]+/g, '-')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^a-z0-9가-힣-]+/g, '')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 태그의 화면 표시 이름을 정리한다.
|
||||||
|
* @param {string} value - 원본 태그 입력값
|
||||||
|
* @returns {string} 표시 이름
|
||||||
|
*/
|
||||||
|
const normalizeTagDisplayName = (value) => String(value)
|
||||||
|
.normalize('NFC')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s*\/\s*/g, '/')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쉼표 구분 태그 문자열을 토큰 배열로 변환한다.
|
||||||
|
* @param {string} value - 태그 입력 문자열
|
||||||
|
* @returns {Array<string>} 태그 토큰 목록
|
||||||
|
*/
|
||||||
|
const parseTags = (value) => {
|
||||||
|
const seen = new Set()
|
||||||
|
const out = []
|
||||||
|
|
||||||
|
for (const part of String(value || '').split(',')) {
|
||||||
|
const tag = normalizeTagToken(part)
|
||||||
|
if (!tag) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const dedupeKey = tag.toLowerCase()
|
||||||
|
if (seen.has(dedupeKey)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(dedupeKey)
|
||||||
|
out.push(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태그의 최근 사용 시각을 timestamp로 변환한다.
|
||||||
|
* @param {Object} tag - 태그 항목
|
||||||
|
* @returns {number} 최근 사용 timestamp
|
||||||
|
*/
|
||||||
|
const getTagLastUsedTime = (tag) => {
|
||||||
|
const timestamp = Date.parse(tag?.lastUsedAt || '')
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중복 태그를 제거한다.
|
||||||
|
* @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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 게시물 폼의 태그 입력 상태를 만든다.
|
||||||
|
* @param {Object} options - 태그 입력 옵션
|
||||||
|
* @param {Object} options.form - 게시물 폼 reactive 객체
|
||||||
|
* @param {import('vue').Ref<Array<Object>>} options.adminTags - 관리자 태그 목록
|
||||||
|
* @param {import('vue').Ref<Object>} options.siteSettings - 사이트 설정
|
||||||
|
* @param {string} [options.defaultTagColor='#15171a'] - 기본 태그 색상
|
||||||
|
* @param {number} [options.recentTagSuggestionLimit=5] - 최근 태그 추천 개수
|
||||||
|
* @param {number} [options.defaultTagSuggestionLimit=8] - 검색 추천 개수
|
||||||
|
* @param {number} [options.emptyTagSuggestionLimit=13] - 빈 입력 추천 개수
|
||||||
|
* @returns {Object} 태그 입력 상태와 이벤트 처리기
|
||||||
|
*/
|
||||||
|
export const useAdminPostTags = ({
|
||||||
|
form,
|
||||||
|
adminTags,
|
||||||
|
siteSettings,
|
||||||
|
defaultTagColor = '#15171a',
|
||||||
|
recentTagSuggestionLimit = 5,
|
||||||
|
defaultTagSuggestionLimit = 8,
|
||||||
|
emptyTagSuggestionLimit = 13
|
||||||
|
}) => {
|
||||||
|
const tagInput = ref('')
|
||||||
|
const tagInputRef = ref(null)
|
||||||
|
const pendingTagNames = ref({})
|
||||||
|
const isTagSuggestionsOpen = ref(false)
|
||||||
|
const activeTagSuggestionIndex = ref(0)
|
||||||
|
const isTagInputComposing = ref(false)
|
||||||
|
|
||||||
|
const selectedTags = computed(() => parseTags(form.tagsText))
|
||||||
|
const selectedTagKeys = computed(() => new Set(selectedTags.value.map((tag) => tag.toLowerCase())))
|
||||||
|
const postTagLimit = computed(() => normalizePostTagLimit(siteSettings.value?.postTagLimit))
|
||||||
|
const canAddMoreTags = computed(() => selectedTags.value.length < postTagLimit.value)
|
||||||
|
const availableAdminTags = computed(() => Array.isArray(adminTags.value) ? adminTags.value : [])
|
||||||
|
const managedTagOptions = computed(() => availableAdminTags.value.filter((tag) => tag.tagType === 'managed'))
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
const tagSuggestionOptions = computed(() => {
|
||||||
|
const keyword = normalizeTagToken(tagInput.value)
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
return filterTagSuggestionSource(availableAdminTags.value, keyword).slice(0, defaultTagSuggestionLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dedupeTagsBySlug([
|
||||||
|
...filterTagSuggestionSource(recentTagOptions.value),
|
||||||
|
...filterTagSuggestionSource(managedTagOptions.value)
|
||||||
|
]).slice(0, emptyTagSuggestionLimit)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasTagSuggestions = computed(() => isTagSuggestionsOpen.value && tagSuggestionOptions.value.length > 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태그 입력값을 배지 목록에 추가한다.
|
||||||
|
* @param {string} value - 입력값
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const addTagToken = (value) => {
|
||||||
|
const displayName = normalizeTagDisplayName(value)
|
||||||
|
const nextTag = normalizeTagToken(value)
|
||||||
|
|
||||||
|
if (!nextTag) {
|
||||||
|
tagInput.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedTagKeys.value.has(nextTag.toLowerCase()) && !canAddMoreTags.value) {
|
||||||
|
tagInput.value = ''
|
||||||
|
isTagSuggestionsOpen.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTags = [...selectedTags.value]
|
||||||
|
if (!selectedTagKeys.value.has(nextTag.toLowerCase())) {
|
||||||
|
nextTags.push(nextTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasExistingTag = availableAdminTags.value.some((tag) => tag.slug === nextTag)
|
||||||
|
if (!hasExistingTag && displayName) {
|
||||||
|
pendingTagNames.value = {
|
||||||
|
...pendingTagNames.value,
|
||||||
|
[nextTag]: displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = canAddMoreTags.value
|
||||||
|
nextTick(() => {
|
||||||
|
tagInputRef.value?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태그 배지를 삭제한다.
|
||||||
|
* @param {string} tag - 삭제할 태그
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
|| pendingTagNames.value[slug]
|
||||||
|
|| 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 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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태그 추천 목록을 열고 입력에 포커스한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openTagSuggestions = () => {
|
||||||
|
if (!canAddMoreTags.value) {
|
||||||
|
isTagSuggestionsOpen.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isTagSuggestionsOpen.value = true
|
||||||
|
activeTagSuggestionIndex.value = 0
|
||||||
|
tagInputRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태그 추천 목록을 토글한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleTagSuggestions = () => {
|
||||||
|
if (!canAddMoreTags.value) {
|
||||||
|
isTagSuggestionsOpen.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTagSuggestionsOpen.value) {
|
||||||
|
isTagSuggestionsOpen.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openTagSuggestions()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태그 입력 포커스 해제 처리.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const handleTagBlur = () => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
addTagFromInput()
|
||||||
|
isTagSuggestionsOpen.value = false
|
||||||
|
}, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태그 입력 키 처리.
|
||||||
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const handleTagKeydown = (event) => {
|
||||||
|
if (event.isComposing || isTagInputComposing.value || event.keyCode === 229) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Backspace' && !tagInput.value && selectedTags.value.length) {
|
||||||
|
event.preventDefault()
|
||||||
|
removeTag(selectedTags.value.at(-1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTagSuggestionIndex,
|
||||||
|
canAddMoreTags,
|
||||||
|
createTagBadgeStyle,
|
||||||
|
getTagColor,
|
||||||
|
getTagDisplayName,
|
||||||
|
getTagSuggestionMeta,
|
||||||
|
handleTagBlur,
|
||||||
|
handleTagKeydown,
|
||||||
|
hasTagSuggestions,
|
||||||
|
isTagInputComposing,
|
||||||
|
openTagSuggestions,
|
||||||
|
postTagLimit,
|
||||||
|
removeTag,
|
||||||
|
selectedTags,
|
||||||
|
selectTagSuggestion,
|
||||||
|
tagInput,
|
||||||
|
tagInputRef,
|
||||||
|
tagSuggestionOptions,
|
||||||
|
toggleTagSuggestions
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
| composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) |
|
| composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) |
|
||||||
| composables/useImeSearchInput.js | 한글 등 IME 조합 중(`compositionupdate`)에도 검색어가 즉시 반영되는 검색 입력 처리기(표시값·`onInput`·`onCompositionEnd`, NFC 정규화·선택적 디바운스) |
|
| composables/useImeSearchInput.js | 한글 등 IME 조합 중(`compositionupdate`)에도 검색어가 즉시 반영되는 검색 입력 처리기(표시값·`onInput`·`onCompositionEnd`, NFC 정규화·선택적 디바운스) |
|
||||||
| composables/useAdminSettingsNavigation.js | 관리자 설정 화면 내비게이션 검색·필터·활성 구역·스크롤 이동·스크롤 스파이 상태 |
|
| composables/useAdminSettingsNavigation.js | 관리자 설정 화면 내비게이션 검색·필터·활성 구역·스크롤 이동·스크롤 스파이 상태 |
|
||||||
|
| composables/useAdminPostTags.js | 관리자 게시물 폼 태그 파싱·추천·선택·삭제·키보드 처리 상태 |
|
||||||
|
|
||||||
## 공유 라이브러리(서버·클라이언트 공통)
|
## 공유 라이브러리(서버·클라이언트 공통)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- 거대 컴포넌트 책임 분리 진행.
|
- 거대 컴포넌트 책임 분리 진행.
|
||||||
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
||||||
- `components/content/ContentMarkdownRenderer.vue`: 파싱·블록 렌더링·라이브 편집 중개·라이트박스·광고 삽입 책임을 분리.
|
- `components/content/ContentMarkdownRenderer.vue`: 파싱·블록 렌더링·라이브 편집 중개·라이트박스·광고 삽입 책임을 분리.
|
||||||
- `components/admin/AdminPostForm.vue`: 태그 입력, 대표 이미지 선택, 발행/초안 저장 상태, 우측 설정 패널 책임을 분리.
|
- `components/admin/AdminPostForm.vue`: 대표 이미지 선택, 발행/초안 저장 상태, 우측 설정 패널 책임을 분리.
|
||||||
- `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리.
|
- `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리.
|
||||||
- 미디어 카탈로그 전환 코드 수명 정리.
|
- 미디어 카탈로그 전환 코드 수명 정리.
|
||||||
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.11.40
|
||||||
|
|
||||||
|
- 관리자 게시물 폼의 태그 파싱·추천·선택·삭제 로직을 `useAdminPostTags` composable로 분리.
|
||||||
|
- 태그 입력 상태·키보드 처리·추천 목록 메타 계산을 게시물 폼 외부에서 관리하도록 정리.
|
||||||
|
- 태그 입력 동작은 유지하면서 글쓰기 폼의 스크립트 책임을 축소.
|
||||||
|
|
||||||
## v1.11.39
|
## v1.11.39
|
||||||
|
|
||||||
- 어나운스 바 설정 정규화·스냅샷 복사·변경 비교 로직을 `lib/admin-announcement-settings-section.js`로 분리.
|
- 어나운스 바 설정 정규화·스냅샷 복사·변경 비교 로직을 `lib/admin-announcement-settings-section.js`로 분리.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.39",
|
"version": "1.11.40",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.39",
|
"version": "1.11.40",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.39",
|
"version": "1.11.40",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
Reference in New Issue
Block a user