diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue index bd7bf91..a726d5f 100644 --- a/components/admin/AdminPostForm.vue +++ b/components/admin/AdminPostForm.vue @@ -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} 태그 토큰 목록 - */ -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} 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) +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} 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) - - 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 - 업로드 파일 diff --git a/composables/useAdminPostTags.js b/composables/useAdminPostTags.js new file mode 100644 index 0000000..60f2ea1 --- /dev/null +++ b/composables/useAdminPostTags.js @@ -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} 태그 토큰 목록 + */ +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} 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 +} + +/** + * 관리자 게시물 폼의 태그 입력 상태를 만든다. + * @param {Object} options - 태그 입력 옵션 + * @param {Object} options.form - 게시물 폼 reactive 객체 + * @param {import('vue').Ref>} options.adminTags - 관리자 태그 목록 + * @param {import('vue').Ref} 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} 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) + }) + + 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 + } +} diff --git a/docs/map.md b/docs/map.md index a999127..3945944 100644 --- a/docs/map.md +++ b/docs/map.md @@ -22,6 +22,7 @@ | composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) | | composables/useImeSearchInput.js | 한글 등 IME 조합 중(`compositionupdate`)에도 검색어가 즉시 반영되는 검색 입력 처리기(표시값·`onInput`·`onCompositionEnd`, NFC 정규화·선택적 디바운스) | | composables/useAdminSettingsNavigation.js | 관리자 설정 화면 내비게이션 검색·필터·활성 구역·스크롤 이동·스크롤 스파이 상태 | +| composables/useAdminPostTags.js | 관리자 게시물 폼 태그 파싱·추천·선택·삭제·키보드 처리 상태 | ## 공유 라이브러리(서버·클라이언트 공통) diff --git a/docs/todo.md b/docs/todo.md index b607cec..89e4a0c 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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` 런타임 분기 축소 가능 여부 검토. diff --git a/docs/update.md b/docs/update.md index c9e7269..58c996a 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.11.40 + +- 관리자 게시물 폼의 태그 파싱·추천·선택·삭제 로직을 `useAdminPostTags` composable로 분리. +- 태그 입력 상태·키보드 처리·추천 목록 메타 계산을 게시물 폼 외부에서 관리하도록 정리. +- 태그 입력 동작은 유지하면서 글쓰기 폼의 스크립트 책임을 축소. + ## v1.11.39 - 어나운스 바 설정 정규화·스냅샷 복사·변경 비교 로직을 `lib/admin-announcement-settings-section.js`로 분리. diff --git a/package-lock.json b/package-lock.json index 300daad..ba7cd5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b04e667..ffabb6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.39", + "version": "1.11.40", "private": true, "type": "module", "imports": {