게시물 태그 입력 로직 분리
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
||||
isPostCardThumbnailUrl,
|
||||
withGeneratedImageCacheVersion
|
||||
} 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({
|
||||
initialPost: {
|
||||
@@ -72,12 +72,6 @@ const mediaPickerTarget = ref('featuredImage')
|
||||
const isLoadingMedia = ref(false)
|
||||
const isUploadingFeaturedImage = ref(false)
|
||||
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 activeMediaPickerTab = ref('upload')
|
||||
const selectedMediaPickerUrl = ref('')
|
||||
@@ -112,10 +106,6 @@ const { data: siteSettings } = useFetch('/admin/api/settings', {
|
||||
default: () => ({ postTagLimit: DEFAULT_POST_TAG_LIMIT })
|
||||
})
|
||||
|
||||
const defaultTagColor = '#15171a'
|
||||
const recentTagSuggestionLimit = 5
|
||||
const defaultTagSuggestionLimit = 8
|
||||
const emptyTagSuggestionLimit = 13
|
||||
/** @type {number} 한국어 본문 예상 읽기 속도(분당 공백 제외 문자 수) */
|
||||
const READING_CHARS_PER_MINUTE = 600
|
||||
|
||||
@@ -437,36 +427,6 @@ const toSlug = (value) => value
|
||||
.replace(/[-_]+/g, (match) => match.includes('_') ? '_' : '-')
|
||||
.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}
|
||||
@@ -483,118 +443,32 @@ const commitSlugInput = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 쉼표 구분 태그 문자열을 토큰 배열로 변환
|
||||
* @param {string} value - 태그 입력 문자열
|
||||
* @returns {Array<string>} 태그 토큰 목록
|
||||
*/
|
||||
const parseTags = (value) => {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
|
||||
for (const part of 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
|
||||
}
|
||||
|
||||
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)
|
||||
const {
|
||||
activeTagSuggestionIndex,
|
||||
canAddMoreTags,
|
||||
createTagBadgeStyle,
|
||||
getTagColor,
|
||||
getTagDisplayName,
|
||||
getTagSuggestionMeta,
|
||||
handleTagBlur,
|
||||
handleTagKeydown,
|
||||
hasTagSuggestions,
|
||||
isTagInputComposing,
|
||||
openTagSuggestions,
|
||||
postTagLimit,
|
||||
removeTag,
|
||||
selectedTags,
|
||||
selectTagSuggestion,
|
||||
tagInput,
|
||||
tagInputRef,
|
||||
tagSuggestionOptions,
|
||||
toggleTagSuggestions
|
||||
} = useAdminPostTags({
|
||||
form,
|
||||
adminTags,
|
||||
siteSettings
|
||||
})
|
||||
|
||||
/**
|
||||
* 중복 태그를 제거한다.
|
||||
* @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} 예약 발행 여부
|
||||
@@ -1057,215 +931,6 @@ const removeFeaturedImage = () => {
|
||||
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 - 업로드 파일
|
||||
|
||||
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/useImeSearchInput.js | 한글 등 IME 조합 중(`compositionupdate`)에도 검색어가 즉시 반영되는 검색 입력 처리기(표시값·`onInput`·`onCompositionEnd`, NFC 정규화·선택적 디바운스) |
|
||||
| composables/useAdminSettingsNavigation.js | 관리자 설정 화면 내비게이션 검색·필터·활성 구역·스크롤 이동·스크롤 스파이 상태 |
|
||||
| composables/useAdminPostTags.js | 관리자 게시물 폼 태그 파싱·추천·선택·삭제·키보드 처리 상태 |
|
||||
|
||||
## 공유 라이브러리(서버·클라이언트 공통)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- 거대 컴포넌트 책임 분리 진행.
|
||||
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
||||
- `components/content/ContentMarkdownRenderer.vue`: 파싱·블록 렌더링·라이브 편집 중개·라이트박스·광고 삽입 책임을 분리.
|
||||
- `components/admin/AdminPostForm.vue`: 태그 입력, 대표 이미지 선택, 발행/초안 저장 상태, 우측 설정 패널 책임을 분리.
|
||||
- `components/admin/AdminPostForm.vue`: 대표 이미지 선택, 발행/초안 저장 상태, 우측 설정 패널 책임을 분리.
|
||||
- `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리.
|
||||
- 미디어 카탈로그 전환 코드 수명 정리.
|
||||
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.11.40
|
||||
|
||||
- 관리자 게시물 폼의 태그 파싱·추천·선택·삭제 로직을 `useAdminPostTags` composable로 분리.
|
||||
- 태그 입력 상태·키보드 처리·추천 목록 메타 계산을 게시물 폼 외부에서 관리하도록 정리.
|
||||
- 태그 입력 동작은 유지하면서 글쓰기 폼의 스크립트 책임을 축소.
|
||||
|
||||
## v1.11.39
|
||||
|
||||
- 어나운스 바 설정 정규화·스냅샷 복사·변경 비교 로직을 `lib/admin-announcement-settings-section.js`로 분리.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.39",
|
||||
"version": "1.11.40",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.39",
|
||||
"version": "1.11.40",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.39",
|
||||
"version": "1.11.40",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
Reference in New Issue
Block a user