글쓰기 태그 제한과 표 기능 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 17:10:16 +09:00
parent ed30926250
commit 95d234a625
24 changed files with 560 additions and 54 deletions

View File

@@ -2747,6 +2747,10 @@ defineExpose({
* @returns {void}
*/
const closeMediaPicker = () => {
if (isUploading.value) {
return
}
isMediaPickerOpen.value = false
selectedMediaUrls.value = []
activeMediaPickerTab.value = 'library'
@@ -2999,7 +3003,7 @@ const handleFileInput = async (event, target) => {
}
/**
* 미디어 모달 업로드 탭에서 파일을 삽입한다.
* 미디어 모달 업로드 탭에서 파일을 업로드하고 라이브러리 목록을 갱신한다.
* @param {FileList|Array<File>} files - 업로드 파일 목록
* @returns {Promise<void>}
*/
@@ -3008,10 +3012,6 @@ const uploadFromMediaModal = async (files) => {
return
}
const target = mediaPickerTarget.value === 'gallery' || mediaPickerTarget.value === 'active-gallery'
? 'gallery'
: mediaPickerTarget.value
isUploading.value = true
try {
@@ -3020,8 +3020,10 @@ const uploadFromMediaModal = async (files) => {
...uploadedFiles,
...mediaItems.value
])
insertSelectedMediaItems(target === 'gallery' ? uploadedFiles : uploadedFiles.slice(0, 1))
closeMediaPicker()
selectedMediaUrls.value = []
activeMediaPickerTab.value = 'library'
mediaSearchQuery.value = ''
showToast('success', '업로드가 완료되었습니다. 목록에서 파일을 선택해 삽입해 주세요.')
} catch (error) {
showToast('error', resolveUploadFetchErrorMessage(error))
} finally {
@@ -3360,7 +3362,12 @@ const handleKeydown = (event) => {
{{ selectedMediaUrls.length }} 선택됨
</p>
</div>
<button class="admin-markdown-editor__media-close rounded px-3 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closeMediaPicker">
<button
class="admin-markdown-editor__media-close rounded px-3 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading"
@click="closeMediaPicker"
>
닫기
</button>
</header>
@@ -3483,13 +3490,18 @@ const handleKeydown = (event) => {
</div>
<footer v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-footer flex items-center justify-end gap-2 border-t border-[#e3e6e8] px-5 py-4">
<button class="admin-markdown-editor__media-cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closeMediaPicker">
<button
class="admin-markdown-editor__media-cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading"
@click="closeMediaPicker"
>
취소
</button>
<button
class="admin-markdown-editor__media-apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="selectedMediaUrls.length === 0"
:disabled="isUploading || selectedMediaUrls.length === 0"
@click="applyMediaSelection"
>
삽입

View File

@@ -5,6 +5,7 @@ import {
toAdminPostStoredTitle
} from '../../lib/admin-post-title.js'
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import { DEFAULT_POST_TAG_LIMIT, normalizePostTagLimit } from '../../lib/post-tag-limit.js'
const props = defineProps({
initialPost: {
@@ -83,6 +84,9 @@ const publishModalExpandedSection = ref(null)
const { data: adminTags } = useFetch('/admin/api/tags', {
default: () => []
})
const { data: siteSettings } = useFetch('/admin/api/settings', {
default: () => ({ postTagLimit: DEFAULT_POST_TAG_LIMIT })
})
const defaultTagColor = '#15171a'
/** @type {number} 한국어 본문 예상 읽기 속도(분당 공백 제외 문자 수) */
@@ -459,6 +463,10 @@ 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'))
@@ -469,6 +477,9 @@ const tagSuggestionOptions = computed(() => {
return sourceTags
.filter((tag) => {
if (!canAddMoreTags.value) {
return false
}
if (!tag?.slug || selectedTagKeys.value.has(tag.slug.toLowerCase())) {
return false
}
@@ -794,6 +805,12 @@ const addTagToken = (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)
@@ -872,6 +889,11 @@ const getTagSuggestionMeta = (tag) => {
* @returns {void}
*/
const openTagSuggestions = () => {
if (!canAddMoreTags.value) {
isTagSuggestionsOpen.value = false
return
}
isTagSuggestionsOpen.value = true
activeTagSuggestionIndex.value = 0
tagInputRef.value?.focus()
@@ -882,6 +904,11 @@ const openTagSuggestions = () => {
* @returns {void}
*/
const toggleTagSuggestions = () => {
if (!canAddMoreTags.value) {
isTagSuggestionsOpen.value = false
return
}
if (isTagSuggestionsOpen.value) {
isTagSuggestionsOpen.value = false
return
@@ -1829,7 +1856,10 @@ defineExpose({
<div class="admin-post-form__field grid gap-1 text-sm">
<span class="admin-post-form__label h-[22px] font-bold text-[#15171a]">Tags</span>
<div class="admin-post-form__tag-combobox relative">
<div class="admin-post-form__tag-editor flex min-h-[38px] w-full flex-wrap items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]">
<div
class="admin-post-form__tag-editor flex min-h-[38px] w-full flex-wrap items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]"
:class="!canAddMoreTags ? 'opacity-85' : ''"
>
<span
v-for="tag in selectedTags"
:key="tag"
@@ -1853,7 +1883,8 @@ defineExpose({
v-model="tagInput"
class="admin-post-form__tag-input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none placeholder:text-[#8e9cac]"
type="text"
placeholder="태그 입력"
:placeholder="canAddMoreTags ? '태그 입력' : `최대 ${postTagLimit}개까지 선택됨`"
:disabled="!canAddMoreTags"
role="combobox"
:aria-expanded="hasTagSuggestions"
aria-autocomplete="list"
@@ -1868,6 +1899,7 @@ defineExpose({
class="admin-post-form__tag-dropdown-trigger ml-auto inline-flex size-8 shrink-0 items-center justify-center rounded text-[#15171a] transition-colors hover:bg-[#e3e6e8] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac]"
type="button"
:aria-expanded="hasTagSuggestions"
:disabled="!canAddMoreTags"
aria-label="메인 태그 목록 열기"
@mousedown.prevent="toggleTagSuggestions"
>
@@ -1877,6 +1909,9 @@ defineExpose({
</svg>
</button>
</div>
<p class="mt-1 text-xs text-[#8e9cac]">
{{ selectedTags.length }} / {{ postTagLimit }} 선택됨
</p>
<div
v-if="hasTagSuggestions"
class="admin-post-form__tag-suggestions absolute left-0 right-0 top-full z-40 mt-1 max-h-64 overflow-y-auto rounded-lg border border-[#d7dce0] bg-white py-1 text-sm shadow-[0_18px_50px_rgba(15,23,42,0.14)]"

View File

@@ -87,6 +87,12 @@ const props = defineProps({
/>
</template>
<!-- table -->
<template v-else-if="commandId === 'table'">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M4 5.5h16M4 11.5h16M4 17.5h16M8.5 5.5v12M15.5 5.5v12" />
<rect x="3" y="4" width="18" height="15" rx="1.5" stroke="currentColor" stroke-width="1.8" />
</template>
<!-- code -->
<template v-else-if="commandId === 'code'">
<path

View File

@@ -12,6 +12,10 @@ const props = defineProps({
type: Boolean,
default: false
},
requireChanges: {
type: Boolean,
default: false
},
defaultTagType: {
type: String,
default: 'general'
@@ -29,6 +33,58 @@ const form = reactive({
color: props.initialTag.color || '#15171a'
})
/**
* 태그 입력값을 저장 비교용 형태로 정규화한다.
* @param {Object} tag - 태그 입력값
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 정규화된 태그 입력값
*/
const normalizeTagPayload = (tag) => ({
name: String(tag.name || '').trim(),
slug: toSlug(tag.slug || tag.name || ''),
description: String(tag.description || '').trim(),
sortOrder: Number(tag.sortOrder ?? 0),
color: String(tag.color || '#15171a'),
tagType: String(tag.tagType || props.defaultTagType)
})
/**
* 현재 폼 입력값
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 현재 저장 입력값
*/
const currentPayload = computed(() => normalizeTagPayload({
name: form.name,
slug: form.slug || form.name,
description: form.description,
sortOrder: props.initialTag.sortOrder ?? 0,
color: form.color,
tagType: props.initialTag.tagType || props.defaultTagType
}))
/**
* 최초 태그 입력값
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 최초 저장 입력값
*/
const initialPayload = computed(() => normalizeTagPayload({
name: props.initialTag.name || '',
slug: props.initialTag.slug || props.initialTag.name || '',
description: props.initialTag.description || '',
sortOrder: props.initialTag.sortOrder ?? 0,
color: props.initialTag.color || '#15171a',
tagType: props.initialTag.tagType || props.defaultTagType
}))
/**
* 태그 입력값 변경 여부
* @returns {boolean} 변경 여부
*/
const hasChanges = computed(() => JSON.stringify(currentPayload.value) !== JSON.stringify(initialPayload.value))
/**
* 태그 저장 가능 여부
* @returns {boolean} 저장 가능 여부
*/
const canSubmit = computed(() => !props.saving && (!props.requireChanges || hasChanges.value))
/**
* 문자열을 URL 슬러그로 변환
* @param {string} value - 원본 문자열
@@ -62,14 +118,11 @@ const touchSlug = () => {
* @returns {void}
*/
const submitTag = () => {
emit('submit', {
name: form.name.trim(),
slug: toSlug(form.slug || form.name),
description: form.description.trim(),
sortOrder: props.initialTag.sortOrder ?? 0,
color: form.color,
tagType: props.initialTag.tagType || props.defaultTagType
})
if (!canSubmit.value) {
return
}
emit('submit', currentPayload.value)
}
</script>
@@ -132,7 +185,7 @@ const submitTag = () => {
<button
class="admin-tag-form__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
type="submit"
:disabled="saving"
:disabled="!canSubmit"
>
{{ saving ? '저장 중' : submitLabel }}
</button>