diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index b6b0aef..58427c6 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -10,16 +10,24 @@ const props = defineProps({ toolbarTeleportTo: { type: String, default: '#admin-post-form-editor-toolbar-host' + }, + /** 작성/미리보기 토글 버튼 Teleport 대상(AdminPostForm 헤더) */ + modeToggleTeleportTo: { + type: String, + default: '' } }) const emit = defineEmits(['update:modelValue']) +const activeMode = defineModel('editorMode', { + type: String, + default: 'write' +}) const editorRootRef = ref(null) const textareaRef = ref(null) const previewRef = ref(null) const gutterRef = ref(null) -const activeMode = ref('write') /** 커서가 있는 논리 줄(0-based, `\\n` 기준) */ const activeLogicalLineIndex = ref(0) const mediaItems = ref([]) @@ -28,6 +36,7 @@ const isLoadingMedia = ref(false) const isUploading = ref(false) const mediaPickerTarget = ref('image') const activeMediaPickerTab = ref('library') +const mediaSearchQuery = ref('') const selectedMediaUrls = ref([]) const lastSelectionState = ref({ start: 0, @@ -323,6 +332,10 @@ onMounted(() => { * @returns {void} */ const focusFirstBlock = () => { + if (activeMode.value !== 'write') { + activeMode.value = 'write' + } + nextTick(() => { textareaRef.value?.focus() refreshCaretLogicalLine() @@ -330,7 +343,9 @@ const focusFirstBlock = () => { } defineExpose({ - focusFirstBlock + focusFirstBlock, + toggleEditorMode, + activeMode }) /** @@ -675,6 +690,7 @@ const fetchMediaItems = async () => { const openMediaPicker = async (target) => { mediaPickerTarget.value = target activeMediaPickerTab.value = 'library' + mediaSearchQuery.value = '' selectedMediaUrls.value = [] isMediaPickerOpen.value = true await fetchMediaItems() @@ -688,8 +704,38 @@ const closeMediaPicker = () => { isMediaPickerOpen.value = false selectedMediaUrls.value = [] activeMediaPickerTab.value = 'library' + mediaSearchQuery.value = '' } +/** + * 미디어 항목이 검색어와 일치하는지 확인한다. + * @param {Object} item - 미디어 항목 + * @param {string} query - 소문자 검색어 + * @returns {boolean} 일치 여부 + */ +const mediaItemMatchesQuery = (item, query) => { + if (!query) { + return true + } + + return [item.name, item.title, item.url] + .some((value) => String(value || '').toLowerCase().includes(query)) +} + +/** + * 검색어로 필터링된 미디어 목록 + * @returns {Array} 필터링된 미디어 목록 + */ +const filteredMediaItems = computed(() => { + const query = mediaSearchQuery.value.trim().toLowerCase() + + if (!query) { + return mediaItems.value + } + + return mediaItems.value.filter((item) => mediaItemMatchesQuery(item, query)) +}) + /** * 미디어 선택 상태를 토글한다. * @param {Object} mediaItem - 미디어 항목 @@ -1049,12 +1095,9 @@ const handleKeydown = (event) => {
`${autosaveStoragePrefix}:${props.initialPost const postUrlLabel = computed(() => form.slug || toSlug(form.title) || '') const postUrlHint = computed(() => props.publicUrl || (postUrlLabel.value ? `/post/${postUrlLabel.value}/` : '/post/')) +/** + * 검색엔진 노출 여부(`noindex` 반전, 기본 노출) + */ +const searchEngineVisible = computed({ + get: () => !form.noindex, + set: (value) => { + form.noindex = !value + } +}) + /** * 한글 음절 1자를 영문 표기로 변환 * @param {string} char - 변환할 문자 @@ -293,10 +305,16 @@ const getPersistedPublishKind = (post) => { /** 마지막으로 서버와 맞춘 게시 형태 */ const persistedPublishKind = ref(getPersistedPublishKind(props.initialPost)) +/** 서버에 반영된 발행 시각(본문 Update 시 유지) */ +const persistedPublishedAtIso = ref(props.initialPost.publishedAt || null) + watch( () => props.initialPost, (post) => { persistedPublishKind.value = getPersistedPublishKind(post) + if (post?.publishedAt) { + persistedPublishedAtIso.value = post.publishedAt + } }, { deep: true } ) @@ -350,14 +368,44 @@ const publishTimingSummaryLabel = computed(() => { }).format(date) }) +/** + * 저장 요청용 발행 시각을 결정한다. + * @param {{ allowNewPublishTimestamp?: boolean }} [options] - 신규 발행 시각 허용 여부 + * @returns {string|null} ISO 발행 시각 또는 null + */ +const resolvePayloadPublishedAt = (options = {}) => { + if (form.status !== 'published') { + return null + } + + const fromForm = toIsoDateTime(form.publishedAt) + + if (fromForm) { + return fromForm + } + + if (persistedPublishedAtIso.value) { + return persistedPublishedAtIso.value + } + + if (props.initialPost.publishedAt) { + return props.initialPost.publishedAt + } + + if (options.allowNewPublishTimestamp) { + return new Date().toISOString() + } + + return null +} + /** * 게시물 입력값 생성 + * @param {{ allowNewPublishTimestamp?: boolean }} [options] - 신규 발행 시각 허용 여부 * @returns {Object} 게시물 입력값 */ -const createPostPayload = () => { - const publishedAt = form.status === 'published' - ? toIsoDateTime(form.publishedAt) || props.initialPost.publishedAt || new Date().toISOString() - : null +const createPostPayload = (options = {}) => { + const publishedAt = resolvePayloadPublishedAt(options) return { title: toAdminPostStoredTitle(form.title), @@ -671,7 +719,7 @@ const focusContentEditor = (event) => { const submitPost = () => { isPublishModalOpen.value = false isUnpublishModalOpen.value = false - emit('submit', createPostPayload()) + emit('submit', createPostPayload({ allowNewPublishTimestamp: true })) } /** @@ -900,6 +948,11 @@ const unpublishModalKind = computed(() => ( */ const markSaved = () => { savedPostSnapshot.value = serializePostPayload() + const publishedAt = resolvePayloadPublishedAt() + + if (publishedAt) { + persistedPublishedAtIso.value = publishedAt + } } /** @@ -1035,6 +1088,10 @@ defineExpose({
+