게시물 태그 입력 로직 분리

This commit is contained in:
2026-07-24 12:20:04 +09:00
parent 0a6a69a843
commit 3c60ed48b9
7 changed files with 434 additions and 364 deletions

View File

@@ -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 - 업로드 파일

View 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
}
}

View File

@@ -22,6 +22,7 @@
| composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) |
| composables/useImeSearchInput.js | 한글 등 IME 조합 중(`compositionupdate`)에도 검색어가 즉시 반영되는 검색 입력 처리기(표시값·`onInput`·`onCompositionEnd`, NFC 정규화·선택적 디바운스) |
| composables/useAdminSettingsNavigation.js | 관리자 설정 화면 내비게이션 검색·필터·활성 구역·스크롤 이동·스크롤 스파이 상태 |
| composables/useAdminPostTags.js | 관리자 게시물 폼 태그 파싱·추천·선택·삭제·키보드 처리 상태 |
## 공유 라이브러리(서버·클라이언트 공통)

View File

@@ -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` 런타임 분기 축소 가능 여부 검토.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.11.40
- 관리자 게시물 폼의 태그 파싱·추천·선택·삭제 로직을 `useAdminPostTags` composable로 분리.
- 태그 입력 상태·키보드 처리·추천 목록 메타 계산을 게시물 폼 외부에서 관리하도록 정리.
- 태그 입력 동작은 유지하면서 글쓰기 폼의 스크립트 책임을 축소.
## v1.11.39
- 어나운스 바 설정 정규화·스냅샷 복사·변경 비교 로직을 `lib/admin-announcement-settings-section.js`로 분리.

4
package-lock.json generated
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.11.39",
"version": "1.11.40",
"private": true,
"type": "module",
"imports": {