페이지 섹션·공개 목록 설정 누적 변경 일괄 반영

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 18:48:42 +09:00
parent 6a3cc5d7ec
commit 00999404a4
37 changed files with 2681 additions and 123 deletions

View File

@@ -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 = `<!doctype html>
</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 || ''
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<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()
}
}
/**
* 페이지 폼의 저장 비교용 문자열을 생성한다.
@@ -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<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} 페이지 입력값
@@ -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을 현재 커서 위치에 삽입합니다. 예: &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">