diff --git a/components/admin/AdminPageForm.vue b/components/admin/AdminPageForm.vue index a503d8c..6a68ef5 100644 --- a/components/admin/AdminPageForm.vue +++ b/components/admin/AdminPageForm.vue @@ -31,10 +31,16 @@ const emit = defineEmits(['submit', 'delete']) const slugTouched = ref(Boolean(props.initialPage.slug)) const blockEditor = ref(null) const htmlEditor = ref(null) +const pageSectionInputRef = ref(null) const editorMode = ref('write') const isUploadingPageAsset = ref(false) +const isUploadingListingImage = ref(false) const isSettingsOpen = ref(true) const savedPageSnapshot = ref('') +const pageSectionInput = ref('') +const isPageSectionSuggestionsOpen = ref(false) +const activePageSectionSuggestionIndex = ref(0) +const isPageSectionInputComposing = ref(false) const htmlCursorRange = reactive({ start: 0, end: 0 @@ -57,12 +63,23 @@ const defaultHtmlDocument = ` ` +const { data: pageSections } = useFetch('/admin/api/page-sections', { + default: () => [] +}) + const form = reactive({ title: props.initialPage.title || '', slug: props.initialPage.slug || '', status: props.initialPage.status || 'published', renderMode: props.initialPage.renderMode || 'html_document', - content: props.initialPage.content || '' + content: props.initialPage.content || '', + isListed: Boolean(props.initialPage.isListed), + isFeatured: Boolean(props.initialPage.isFeatured), + listingSection: props.initialPage.listingSection || '', + listingTitle: props.initialPage.listingTitle || '', + listingDescription: props.initialPage.listingDescription || '', + listingImage: props.initialPage.featuredImage || '', + listingSortOrder: Number(props.initialPage.listingSortOrder || 0) }) /** @@ -111,6 +128,132 @@ const toSlug = (value) => value const pageSlug = computed(() => toSlug(form.slug || form.title)) const viewPageUrl = computed(() => props.publicUrl || (pageSlug.value ? `/pages/${pageSlug.value}` : '')) const pageUrlHint = computed(() => `/pages/${pageSlug.value || 'page-slug'}/`) +const pageSectionOptions = computed(() => { + const currentSection = form.listingSection.trim() + const options = pageSections.value.map((section) => section.name) + + if (currentSection && !options.includes(currentSection)) { + options.push(currentSection) + } + + return options +}) +const pageSectionSuggestionOptions = computed(() => { + const keyword = pageSectionInput.value.trim().toLowerCase() + const options = pageSectionOptions.value.map((sectionName) => ({ + name: sectionName, + isSelected: sectionName === form.listingSection.trim() + })) + + if (!keyword) { + return options + } + + return options.filter((option) => option.name.toLowerCase().includes(keyword)) +}) +const hasPageSectionSuggestions = computed(() => pageSectionSuggestionOptions.value.length > 0) + +/** + * 페이지 섹션 추천 목록을 연다. + * @returns {void} + */ +const openPageSectionSuggestions = () => { + activePageSectionSuggestionIndex.value = 0 + isPageSectionSuggestionsOpen.value = true +} + +/** + * 페이지 섹션 추천 목록을 토글한다. + * @returns {Promise} + */ +const togglePageSectionSuggestions = async () => { + isPageSectionSuggestionsOpen.value = !isPageSectionSuggestionsOpen.value + activePageSectionSuggestionIndex.value = 0 + await nextTick() + pageSectionInputRef.value?.focus() +} + +/** + * 페이지 섹션 추천 항목을 선택한다. + * @param {Object} option - 섹션 추천 항목 + * @returns {Promise} + */ +const selectPageSectionSuggestion = async (option) => { + form.listingSection = String(option?.name || '').trim() + pageSectionInput.value = '' + isPageSectionSuggestionsOpen.value = false + activePageSectionSuggestionIndex.value = 0 + await nextTick() + pageSectionInputRef.value?.focus() +} + +/** + * 페이지 섹션 선택을 제거한다. + * @returns {void} + */ +const clearPageSection = () => { + form.listingSection = '' + pageSectionInput.value = '' + activePageSectionSuggestionIndex.value = 0 +} + +/** + * 페이지 섹션 입력 blur를 처리한다. + * @returns {void} + */ +const handlePageSectionBlur = () => { + window.setTimeout(() => { + isPageSectionSuggestionsOpen.value = false + pageSectionInput.value = '' + }, 120) +} + +/** + * 페이지 섹션 입력 키보드 조작을 처리한다. + * @param {KeyboardEvent} event - 키보드 이벤트 + * @returns {void} + */ +const handlePageSectionKeydown = (event) => { + if (isPageSectionInputComposing.value) { + return + } + + if (event.key === 'ArrowDown') { + event.preventDefault() + openPageSectionSuggestions() + activePageSectionSuggestionIndex.value = Math.min( + activePageSectionSuggestionIndex.value + 1, + Math.max(0, pageSectionSuggestionOptions.value.length - 1) + ) + return + } + + if (event.key === 'ArrowUp') { + event.preventDefault() + openPageSectionSuggestions() + activePageSectionSuggestionIndex.value = Math.max(0, activePageSectionSuggestionIndex.value - 1) + return + } + + if (event.key === 'Enter') { + if (!isPageSectionSuggestionsOpen.value || !hasPageSectionSuggestions.value) { + return + } + event.preventDefault() + selectPageSectionSuggestion(pageSectionSuggestionOptions.value[activePageSectionSuggestionIndex.value]) + return + } + + if (event.key === 'Escape') { + isPageSectionSuggestionsOpen.value = false + pageSectionInput.value = '' + return + } + + if (event.key === 'Backspace' && !pageSectionInput.value && form.listingSection) { + clearPageSection() + } +} /** * 페이지 폼의 저장 비교용 문자열을 생성한다. @@ -121,7 +264,14 @@ const serializePageForm = () => JSON.stringify({ slug: pageSlug.value, status: form.status, renderMode: form.renderMode, - content: form.content + content: form.content, + isListed: form.isListed, + isFeatured: form.isFeatured, + listingSection: form.listingSection.trim(), + listingTitle: form.listingTitle.trim(), + listingDescription: form.listingDescription.trim(), + listingImage: form.listingImage.trim(), + listingSortOrder: Number(form.listingSortOrder || 0) }) const hasUnsavedPageChanges = computed(() => serializePageForm() !== savedPageSnapshot.value) @@ -154,6 +304,12 @@ const touchSlug = () => { form.slug = toSlug(form.slug) } +watch(() => form.isListed, (isListed) => { + if (!isListed) { + form.isFeatured = false + } +}) + /** * 페이지 설정 패널을 토글한다. * @returns {void} @@ -290,6 +446,42 @@ const uploadPageAsset = async (event) => { } } +/** + * 페이지 목록 카드 이미지를 업로드한다. + * @param {Event} event - 파일 입력 이벤트 + * @returns {Promise} + */ +const uploadListingImage = async (event) => { + const files = event.target.files + + if (!files?.length) { + return + } + + const formData = new FormData() + formData.append('files', files[0]) + isUploadingListingImage.value = true + + try { + const result = await $fetch('/admin/api/uploads', { + method: 'POST', + body: formData + }) + form.listingImage = result.files?.[0]?.url || form.listingImage + } finally { + event.target.value = '' + isUploadingListingImage.value = false + } +} + +/** + * 페이지 목록 카드 이미지를 제거한다. + * @returns {void} + */ +const clearListingImage = () => { + form.listingImage = '' +} + /** * 페이지 입력값을 생성한다. * @returns {Object} 페이지 입력값 @@ -300,7 +492,13 @@ const createPayload = () => ({ status: form.status, renderMode: form.renderMode, content: form.content, - featuredImage: null + featuredImage: form.listingImage.trim() || null, + isListed: Boolean(form.isListed), + isFeatured: Boolean(form.isListed && form.isFeatured), + listingSection: form.listingSection.trim(), + listingTitle: form.listingTitle.trim(), + listingDescription: form.listingDescription.trim(), + listingSortOrder: Number(form.listingSortOrder || 0) }) /** @@ -455,7 +653,7 @@ defineExpose({ v-model="form.slug" class="admin-page-form__input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none" type="text" - pattern="[a-z0-9]+(-[a-z0-9]+)*" + pattern="[a-z0-9가-힣]+([-_][a-z0-9가-힣]+)*" required @input="touchSlug" > @@ -526,6 +724,151 @@ defineExpose({ HTML 모드에서는 업로드된 파일 URL을 현재 커서 위치에 삽입합니다. 예: <img src="여기">

+ +
+ + + + +
+ 섹션명 +
+
+ + + +
+
+ +
+

+ 일치하는 섹션이 없습니다. +

+
+ + 섹션은 네비게이션의 페이지 섹션에서 추가합니다. + +
+ + + +