Files
sori.studio/components/admin/AdminPageForm.vue
zenn 00999404a4 페이지 섹션·공개 목록 설정 누적 변경 일괄 반영
페이지 섹션 관리와 홈 Pages 진열장 연동, 공개 목록 표시개수 설정 관련 누적 로컬 변경을 기능 단위로 정리해 반영한다.
문서 이력과 패키지 버전을 v1.9.23으로 맞춰 코드와 문서 상태를 동기화한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 18:48:42 +09:00

892 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
const props = defineProps({
initialPage: {
type: Object,
default: () => ({})
},
saving: {
type: Boolean,
default: false
},
deleting: {
type: Boolean,
default: false
},
canViewPage: {
type: Boolean,
default: false
},
publicUrl: {
type: String,
default: ''
},
showDelete: {
type: Boolean,
default: false
}
})
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
})
const defaultHtmlDocument = `<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Landing</title>
<style>
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
</style>
</head>
<body>
</body>
</html>`
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 || '',
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)
})
/**
* 한글 음절 1자를 영문 표기로 변환
* @param {string} char - 변환할 문자
* @returns {string} 영문 표기
*/
const romanizeHangulSyllable = (char) => {
const syllableCode = char.charCodeAt(0)
const hangulBase = 0xac00
const hangulLast = 0xd7a3
if (syllableCode < hangulBase || syllableCode > hangulLast) {
return char
}
const choseong = ['g', 'kk', 'n', 'd', 'tt', 'r', 'm', 'b', 'pp', 's', 'ss', '', 'j', 'jj', 'ch', 'k', 't', 'p', 'h']
const jungseong = ['a', 'ae', 'ya', 'yae', 'eo', 'e', 'yeo', 'ye', 'o', 'wa', 'wae', 'oe', 'yo', 'u', 'wo', 'we', 'wi', 'yu', 'eu', 'ui', 'i']
const jongseong = ['', 'k', 'k', 'ks', 'n', 'nj', 'nh', 't', 'l', 'lk', 'lm', 'lb', 'ls', 'lt', 'lp', 'lh', 'm', 'p', 'ps', 't', 't', 'ng', 't', 't', 'k', 't', 'p', 'h']
const offset = syllableCode - hangulBase
const choseongIndex = Math.floor(offset / 588)
const jungseongIndex = Math.floor((offset % 588) / 28)
const jongseongIndex = offset % 28
return `${choseong[choseongIndex]}${jungseong[jungseongIndex]}${jongseong[jongseongIndex]}`
}
/**
* 문자열을 URL 슬러그로 변환
* @param {string} value - 원본 문자열
* @returns {string} 영문 슬러그
*/
const toSlug = (value) => value
.normalize('NFC')
.split('')
.map((char) => romanizeHangulSyllable(char))
.join('')
.trim()
.toLowerCase()
.replace(/[^a-z0-9\s_-]/g, '')
.replace(/\s+/g, '-')
.replace(/[-_]+/g, (match) => match.includes('_') ? '_' : '-')
.replace(/^[-_]|[-_]$/g, '')
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<void>}
*/
const togglePageSectionSuggestions = async () => {
isPageSectionSuggestionsOpen.value = !isPageSectionSuggestionsOpen.value
activePageSectionSuggestionIndex.value = 0
await nextTick()
pageSectionInputRef.value?.focus()
}
/**
* 페이지 섹션 추천 항목을 선택한다.
* @param {Object} option - 섹션 추천 항목
* @returns {Promise<void>}
*/
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()
}
}
/**
* 페이지 폼의 저장 비교용 문자열을 생성한다.
* @returns {string} 직렬화된 폼 상태
*/
const serializePageForm = () => JSON.stringify({
title: form.title.trim(),
slug: pageSlug.value,
status: form.status,
renderMode: form.renderMode,
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)
const headerStatusText = computed(() => {
if (props.saving) {
return 'Saving...'
}
if (hasUnsavedPageChanges.value) {
return 'Unsaved changes'
}
if (props.initialPage.id) {
return 'Saved'
}
return 'New page'
})
watch(() => form.title, (title) => {
if (!slugTouched.value) {
form.slug = toSlug(title)
}
})
/**
* 슬러그 직접 입력 상태 표시
* @returns {void}
*/
const touchSlug = () => {
slugTouched.value = true
form.slug = toSlug(form.slug)
}
watch(() => form.isListed, (isListed) => {
if (!isListed) {
form.isFeatured = false
}
})
/**
* 페이지 설정 패널을 토글한다.
* @returns {void}
*/
const toggleSettingsPanel = () => {
isSettingsOpen.value = !isSettingsOpen.value
}
/**
* 제목 입력 후 본문 에디터로 이동
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const focusContentEditor = (event) => {
event?.preventDefault()
if (form.renderMode === 'html_document') {
htmlEditor.value?.focus()
return
}
blockEditor.value?.focusFirstBlock()
}
/**
* 페이지 작성 모드를 변경한다.
* @param {'markdown'|'html_document'} mode - 페이지 작성 모드
* @returns {void}
*/
const setRenderMode = (mode) => {
form.renderMode = mode
}
/**
* HTML textarea 커서 위치를 기억한다.
* @returns {void}
*/
const rememberHtmlCursor = () => {
if (!htmlEditor.value) {
return
}
htmlCursorRange.start = htmlEditor.value.selectionStart ?? form.content.length
htmlCursorRange.end = htmlEditor.value.selectionEnd ?? htmlCursorRange.start
}
/**
* HTML 본문 커서 위치에 텍스트를 삽입한다.
* @param {string} text - 삽입할 텍스트
* @returns {Promise<void>}
*/
const insertTextAtHtmlCursor = async (text) => {
const start = Math.max(0, htmlCursorRange.start)
const end = Math.max(start, htmlCursorRange.end)
form.content = `${form.content.slice(0, start)}${text}${form.content.slice(end)}`
await nextTick()
const nextCursor = start + text.length
htmlEditor.value?.focus()
htmlEditor.value?.setSelectionRange(nextCursor, nextCursor)
htmlCursorRange.start = nextCursor
htmlCursorRange.end = nextCursor
}
/**
* HTML 기본 문서 골격을 현재 본문에 채운다.
* @returns {Promise<void>}
*/
const completeHtmlDocumentSkeleton = async () => {
form.content = defaultHtmlDocument
await nextTick()
const bodyIndex = form.content.indexOf('</body>')
const nextCursor = bodyIndex > -1 ? bodyIndex : form.content.length
htmlEditor.value?.focus()
htmlEditor.value?.setSelectionRange(nextCursor, nextCursor)
htmlCursorRange.start = nextCursor
htmlCursorRange.end = nextCursor
}
/**
* HTML textarea에서 VS Code식 기본 골격 단축 입력을 처리한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {Promise<void>}
*/
const handleHtmlEditorKeydown = async (event) => {
if (event.key !== 'Tab' || form.renderMode !== 'html_document') {
return
}
const content = form.content.trim()
if (content !== '' && content !== '!') {
return
}
event.preventDefault()
await completeHtmlDocumentSkeleton()
}
/**
* 페이지 HTML 자산을 업로드하고 본문 커서 위치에 URL을 삽입한다.
* @param {Event} event - 파일 입력 이벤트
* @returns {Promise<void>}
*/
const uploadPageAsset = async (event) => {
const files = event.target.files
if (!files?.length) {
return
}
rememberHtmlCursor()
const formData = new FormData()
formData.append('files', files[0])
isUploadingPageAsset.value = true
try {
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: formData
})
const uploadedUrl = result.files?.[0]?.url || ''
if (uploadedUrl && form.renderMode === 'html_document') {
await insertTextAtHtmlCursor(uploadedUrl)
}
} finally {
event.target.value = ''
isUploadingPageAsset.value = false
}
}
/**
* 페이지 목록 카드 이미지를 업로드한다.
* @param {Event} event - 파일 입력 이벤트
* @returns {Promise<void>}
*/
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} 페이지 입력값
*/
const createPayload = () => ({
title: form.title.trim(),
slug: pageSlug.value,
status: form.status,
renderMode: form.renderMode,
content: form.content,
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)
})
/**
* 페이지 입력값 제출
* @returns {void}
*/
const submitPage = () => {
emit('submit', createPayload())
}
/**
* 현재 폼 상태를 저장 완료 기준점으로 표시한다.
* @returns {void}
*/
const markSaved = () => {
savedPageSnapshot.value = serializePageForm()
}
onMounted(markSaved)
defineExpose({
markSaved
})
</script>
<template>
<form class="admin-page-form flex h-screen min-h-screen overflow-hidden bg-white" @submit.prevent="submitPage">
<div class="admin-page-form__workspace flex min-w-0 flex-1 flex-col bg-white">
<header class="admin-page-form__toolbar flex h-[56px] shrink-0 items-center bg-white px-8">
<div class="admin-page-form__toolbar-inner flex h-[34px] min-w-0 flex-1 items-center justify-between">
<div class="admin-page-form__toolbar-left flex h-full min-w-0 flex-1 items-center gap-3">
<NuxtLink class="admin-page-form__toolbar-link inline-flex shrink-0 items-center gap-2 rounded px-2 py-1.5 text-sm font-medium text-[#394047] transition-colors hover:bg-[#f1f3f4] hover:text-black" to="/admin/pages">
<span class="admin-page-form__toolbar-back text-lg leading-none" aria-hidden="true">&lt;</span>
<span>Pages</span>
</NuxtLink>
<NuxtLink
v-if="canViewPage && viewPageUrl"
class="admin-page-form__toolbar-status-link inline-flex items-center gap-1 truncate rounded px-2 py-1.5 text-sm font-medium text-[#8E9CAC] transition-colors hover:bg-[#f1f3f4] hover:text-[#394047]"
:to="viewPageUrl"
target="_blank"
rel="noopener noreferrer"
>
<span>View page</span>
<span aria-hidden="true">↗</span>
</NuxtLink>
<span v-else class="admin-page-form__toolbar-status truncate rounded px-2 py-1.5 text-sm text-[#8E9CAC]">
{{ headerStatusText }}
</span>
</div>
<div class="admin-page-form__toolbar-actions flex h-full shrink-0 items-center gap-2">
<div
v-show="form.renderMode === 'markdown'"
id="admin-page-form-mode-toggle-host"
class="admin-page-form__mode-toggle-host flex shrink-0 items-center"
/>
<button
class="admin-page-form__toolbar-save rounded px-3 py-1.5 text-sm font-bold transition-colors disabled:cursor-default disabled:text-[#8E9CAC] disabled:hover:bg-transparent enabled:text-[#394047] enabled:hover:bg-[#f1f3f4]"
type="submit"
:disabled="saving || !form.title.trim() || !hasUnsavedPageChanges"
>
{{ saving ? 'Saving...' : 'Save' }}
</button>
<button
class="admin-page-form__settings-toggle grid size-[34px] place-items-center rounded text-[#394047] transition-colors hover:bg-[#f1f3f4] hover:text-black"
type="button"
:aria-pressed="isSettingsOpen"
aria-label="페이지 설정 패널 전환"
@click="toggleSettingsPanel"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2.16699C14.3242 2.16699 14.4998 2.39365 14.5 2.57129V13.4287C14.5 13.606 14.3242 13.834 14 13.834H11.5V2.16699H14ZM2 2.16699H10.5V13.834H2C1.6756 13.834 1.5 13.6064 1.5 13.4287V2.57129C1.50024 2.39409 1.67607 2.16699 2 2.16699Z" stroke="currentColor" />
</svg>
</button>
</div>
</div>
</header>
<main class="admin-page-form__editor-scroll min-h-0 flex-1 overflow-y-auto">
<section class="admin-page-form__content mx-auto w-full max-w-[804px] px-8 pb-16 pt-24">
<input
v-model="form.title"
class="admin-page-form__title-input mb-2 w-full border-0 bg-transparent px-0 py-0 text-3xl font-bold leading-tight text-ink outline-none placeholder:text-[#8e9cac]"
type="text"
placeholder="제목"
@keydown.enter="focusContentEditor"
>
<div v-if="form.renderMode === 'markdown'" class="admin-page-form__field admin-page-form__content-editor text-sm">
<AdminMarkdownEditor
ref="blockEditor"
v-model="form.content"
v-model:editor-mode="editorMode"
mode-toggle-teleport-to="#admin-page-form-mode-toggle-host"
/>
</div>
<label v-else class="admin-page-form__field admin-page-form__html-field grid gap-2 text-sm">
<span class="admin-page-form__label sr-only">HTML 문서</span>
<textarea
ref="htmlEditor"
v-model="form.content"
class="admin-page-form__html-editor min-h-[68vh] w-full resize-y rounded border border-[#e3e6e8] bg-white px-4 py-4 font-mono text-sm leading-6 text-[#15171a] outline-none placeholder:text-[#8e9cac] focus:border-[#8e9cac]"
spellcheck="false"
@blur="rememberHtmlCursor"
@click="rememberHtmlCursor"
@focus="rememberHtmlCursor"
@input="rememberHtmlCursor"
@keydown="handleHtmlEditorKeydown"
@keyup="rememberHtmlCursor"
@select="rememberHtmlCursor"
:placeholder="defaultHtmlDocument"
/>
</label>
</section>
</main>
</div>
<aside
class="admin-page-form__settings flex h-screen shrink-0 flex-col overflow-hidden border-[#e3e6e8] bg-white transition-[width,border-color] duration-300 ease-out"
:class="isSettingsOpen ? 'w-[420px] border-l' : 'w-0 border-l-0'"
:aria-hidden="!isSettingsOpen"
>
<div class="admin-page-form__settings-inner relative flex h-full w-[420px] flex-col">
<div class="admin-page-form__settings-header flex h-[56px] shrink-0 items-center justify-between px-6">
<h2 class="admin-page-form__settings-title text-xl font-bold text-black">
페이지 설정
</h2>
<button class="admin-page-form__settings-close grid size-8 place-items-center rounded text-neutral-900 transition-colors hover:bg-[#eff1f2] hover:text-neutral-500" type="button" aria-label="페이지 설정 닫기" @click="toggleSettingsPanel">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="16" width="16" class="h-[1.1rem] w-[1.1rem]" aria-hidden="true">
<path stroke-linecap="round" stroke-width="0.4" fill="currentColor" stroke="#000000" stroke-linejoin="round" d="M.44,21.44a1.49,1.49,0,0,0,0,2.12,1.5,1.5,0,0,0,2.12,0l9.26-9.26a.25.25,0,0,1,.36,0l9.26,9.26a1.5,1.5,0,0,0,2.12,0,1.49,1.49,0,0,0,0-2.12L14.3,12.18a.25.25,0,0,1,0-.36l9.26-9.26A1.5,1.5,0,0,0,21.44.44L12.18,9.7a.25.25,0,0,1-.36,0L2.56.44A1.5,1.5,0,0,0,.44,2.56L9.7,11.82a.25.25,0,0,1,0,.36Z" />
</svg>
</button>
</div>
<div class="admin-page-form__settings-body grid flex-1 content-start gap-4 overflow-y-auto px-6 pb-8 pt-8">
<div class="admin-page-form__field grid gap-1 text-sm">
<div class="admin-page-form__page-url-header flex h-[22px] items-center justify-between">
<span class="admin-page-form__label font-bold text-[#15171a]">Page URL</span>
<NuxtLink
v-if="canViewPage && viewPageUrl"
class="admin-page-form__view-page inline-flex items-center gap-1 rounded px-3 py-1.5 text-xs text-[#8e9cac] transition-colors hover:bg-[#eff1f2] hover:text-[#394047]"
:to="viewPageUrl"
target="_blank"
>
<span>View Page</span>
<span aria-hidden="true">↗</span>
</NuxtLink>
</div>
<label class="admin-page-form__page-url-input flex h-[38px] items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]">
<span class="admin-page-form__page-url-icon text-sm text-[#394047]" aria-hidden="true">⌘</span>
<input
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가-힣]+)*"
required
@input="touchSlug"
>
</label>
<p class="admin-page-form__page-url-hint text-xs text-[#7c8b9a]">
{{ pageUrlHint }}
</p>
</div>
<label class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">상태</span>
<span class="admin-page-form__select-wrap relative block">
<select v-model="form.status" class="admin-page-form__select h-[38px] w-full appearance-none rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-10 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none">
<option value="draft">초안</option>
<option value="published">공개</option>
<option value="private">비공개</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-[#15171a]" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<span v-if="form.status === 'draft'" class="admin-page-form__hint text-xs text-muted">
초안 페이지는 공개 URL에서 보이지 않습니다.
</span>
<span v-else-if="form.status === 'private'" class="admin-page-form__hint text-xs text-muted">
비공개 페이지는 공개 URL에서 보이지 않습니다.
</span>
</label>
<div class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">페이지 형식</span>
<div class="admin-page-form__mode-control grid grid-cols-2 rounded border border-[#e3e6e8] bg-[#eff1f2] p-1">
<button
class="admin-page-form__mode-button rounded px-3 py-2 text-sm font-semibold transition"
:class="form.renderMode === 'markdown' ? 'bg-[#15171a] text-white' : 'text-[#7c8b9a] hover:bg-white hover:text-[#15171a]'"
type="button"
@click="setRenderMode('markdown')"
>
일반 텍스트
</button>
<button
class="admin-page-form__mode-button rounded px-3 py-2 text-sm font-semibold transition"
:class="form.renderMode === 'html_document' ? 'bg-[#15171a] text-white' : 'text-[#7c8b9a] hover:bg-white hover:text-[#15171a]'"
type="button"
@click="setRenderMode('html_document')"
>
HTML
</button>
</div>
</div>
<div v-if="form.renderMode === 'html_document'" class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">HTML 자산</span>
<label
class="admin-page-form__asset-upload inline-flex h-10 cursor-pointer items-center justify-center rounded bg-[#15171a] px-3 text-sm font-semibold text-white transition-colors hover:bg-black"
:class="{ 'pointer-events-none opacity-50': isUploadingPageAsset }"
>
{{ isUploadingPageAsset ? '업로드 중' : '파일 업로드' }}
<input
class="sr-only"
type="file"
accept="image/*,video/*,audio/*,.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx"
@change="uploadPageAsset"
>
</label>
<p class="admin-page-form__asset-upload-hint text-xs leading-5 text-[#7c8b9a]">
HTML 모드에서는 업로드된 파일 URL을 현재 커서 위치에 삽입합니다. 예: &lt;img src=&quot;여기&quot;&gt;
</p>
</div>
<section class="admin-page-form__listing grid gap-4 border-t border-[#e3e6e8] pt-5">
<label class="admin-page-form__toggle-row flex items-center justify-between gap-4 text-sm">
<span class="admin-page-form__toggle-copy">
<span class="block font-medium text-[#15171a]">홈에 표시</span>
<span class="mt-1 block text-xs text-[#7c8b9a]">공개 상태일 때만 방문자에게 노출됩니다.</span>
</span>
<span class="admin-page-form__toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input v-model="form.isListed" class="peer sr-only" type="checkbox">
<span class="h-7 w-12 rounded-full bg-[#d7dde2] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="absolute left-1 size-5 rounded-full bg-white transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
<label class="admin-page-form__toggle-row flex items-center justify-between gap-4 text-sm" :class="{ 'opacity-45': !form.isListed }">
<span class="admin-page-form__toggle-copy">
<span class="block font-medium text-[#15171a]">추천으로 강조</span>
<span class="mt-1 block text-xs text-[#7c8b9a]">Pages 영역에서 먼저 정렬합니다.</span>
</span>
<span class="admin-page-form__toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input v-model="form.isFeatured" class="peer sr-only" type="checkbox" :disabled="!form.isListed">
<span class="h-7 w-12 rounded-full bg-[#d7dde2] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="absolute left-1 size-5 rounded-full bg-white transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
<div class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">섹션명</span>
<div
class="admin-page-form__section-combobox relative"
@focusin="openPageSectionSuggestions"
>
<div class="admin-page-form__section-editor flex min-h-[38px] items-center gap-1 rounded border border-[#e3e6e8] bg-[#eff1f2] px-2 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]">
<button
v-if="form.listingSection"
class="admin-page-form__section-token inline-flex max-w-[15rem] items-center gap-1 rounded bg-white px-2 py-1 text-xs font-semibold text-[#394047] ring-1 ring-[#e3e6e8]"
type="button"
title="섹션 선택 해제"
@click="clearPageSection"
>
<span class="truncate">{{ form.listingSection }}</span>
<span aria-hidden="true">×</span>
</button>
<input
ref="pageSectionInputRef"
v-model="pageSectionInput"
class="admin-page-form__section-input min-w-0 flex-1 bg-transparent px-1 text-sm text-black outline-none placeholder:text-[#8e9cac]"
type="text"
:placeholder="pageSectionOptions.length === 0 ? '등록된 섹션 없음' : form.listingSection ? '' : '섹션 검색'"
:disabled="pageSectionOptions.length === 0"
autocomplete="off"
@input="openPageSectionSuggestions"
@focus="openPageSectionSuggestions"
@blur="handlePageSectionBlur"
@keydown="handlePageSectionKeydown"
@compositionstart="isPageSectionInputComposing = true"
@compositionend="isPageSectionInputComposing = false"
>
<button
class="admin-page-form__section-dropdown-trigger grid size-7 shrink-0 place-items-center rounded text-[#394047] transition-colors hover:bg-white disabled:opacity-35"
type="button"
:disabled="pageSectionOptions.length === 0"
aria-label="섹션 목록 열기"
@mousedown.prevent
@click="togglePageSectionSuggestions"
>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down size-5" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
<div
v-if="isPageSectionSuggestionsOpen && hasPageSectionSuggestions"
class="admin-page-form__section-suggestions absolute left-0 right-0 top-[calc(100%+0.25rem)] z-20 max-h-48 overflow-y-auto rounded border border-[#e3e6e8] bg-white py-1 shadow-lg"
>
<button
v-for="(option, index) in pageSectionSuggestionOptions"
:key="option.name"
class="admin-page-form__section-suggestion flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm transition-colors"
:class="index === activePageSectionSuggestionIndex ? 'bg-[#f1f3f4] text-[#15171a]' : 'text-[#394047] hover:bg-[#f1f3f4]'"
type="button"
@mousedown.prevent
@click="selectPageSectionSuggestion(option)"
>
<span class="truncate">{{ option.name }}</span>
<span v-if="option.isSelected" class="text-xs text-[#8e9cac]">선택됨</span>
</button>
</div>
<p
v-else-if="isPageSectionSuggestionsOpen && pageSectionInput.trim()"
class="admin-page-form__section-empty absolute left-0 right-0 top-[calc(100%+0.25rem)] z-20 rounded border border-[#e3e6e8] bg-white px-3 py-2 text-xs text-[#7c8b9a] shadow-lg"
>
일치하는 섹션이 없습니다.
</p>
</div>
<span class="admin-page-form__help text-xs text-[#7c8b9a]">
섹션은 네비게이션의 페이지 섹션에서 추가합니다.
</span>
</div>
<label class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">목록 제목</span>
<input
v-model="form.listingTitle"
class="admin-page-form__input h-[38px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 text-sm text-black outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]"
type="text"
maxlength="160"
placeholder="비워두면 페이지 제목을 사용"
>
</label>
<label class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">목록 설명</span>
<textarea
v-model="form.listingDescription"
class="admin-page-form__textarea min-h-24 resize-y rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 text-sm leading-5 text-black outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]"
maxlength="500"
placeholder="검색 결과에 사용할 짧은 설명"
/>
</label>
<div class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">썸네일 등록</span>
<figure v-if="form.listingImage" class="admin-page-form__listing-image group relative max-w-[180px] overflow-hidden rounded border border-[#e3e6e8] bg-[#eff1f2]">
<img class="aspect-[3/4] w-full object-cover" :src="form.listingImage" alt="">
<button class="absolute right-2 top-2 rounded bg-white/95 px-2 py-1 text-xs font-semibold text-[#394047] shadow-sm transition-colors hover:bg-white" type="button" @click="clearListingImage">
제거
</button>
</figure>
<label
class="admin-page-form__listing-image-upload inline-flex h-10 cursor-pointer items-center justify-center rounded bg-[#15171a] px-3 text-sm font-semibold text-white transition-colors hover:bg-black"
:class="{ 'pointer-events-none opacity-50': isUploadingListingImage }"
>
{{ isUploadingListingImage ? '업로드 중' : form.listingImage ? '이미지 변경' : '이미지 업로드' }}
<input
class="sr-only"
type="file"
accept="image/*"
@change="uploadListingImage"
>
</label>
</div>
</section>
</div>
<div v-if="showDelete" class="admin-page-form__settings-footer border-t border-[#e3e6e8] p-6">
<button
class="admin-page-form__delete-button flex h-[44px] w-full items-center justify-center gap-2 rounded border border-[#d7dce0] bg-white text-sm font-bold text-[#394047] transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 disabled:opacity-50"
type="button"
:disabled="deleting"
@click="emit('delete')"
>
<svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 7h16M10 11v6M14 11v6M6 7l1 14h10l1-14M9 7V4h6v3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" />
</svg>
<span>{{ deleting ? 'Deleting page' : 'Delete page' }}</span>
</button>
</div>
</div>
</aside>
</form>
</template>