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

페이지 섹션 관리와 홈 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">

View File

@@ -47,19 +47,21 @@ const displayImage = computed(() => props.thumbnailImage || props.featuredImage)
:class="linkClass"
data-post-card-media
>
<figure class="post-card-media__figure overflow-hidden rounded-[10px]">
<figure
class="post-card-media__figure overflow-hidden rounded-[10px]"
:class="aspectClass"
>
<img
v-if="displayImage"
class="post-card-media__image w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90"
:class="[aspectClass, imageClass]"
class="post-card-media__image h-full w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90"
:class="imageClass"
:src="displayImage"
:alt="title"
loading="lazy"
>
<span
v-else
class="post-card-media__placeholder flex w-full items-center justify-center rounded-[inherit] bg-[#F7F4EF] p-4 text-center text-xs leading-snug text-[var(--site-muted)] transition-opacity duration-200 group-hover:opacity-90"
:class="aspectClass"
class="post-card-media__placeholder flex h-full w-full items-center justify-center rounded-[inherit] bg-[#F7F4EF] p-4 text-center text-xs leading-snug text-[var(--site-muted)] transition-opacity duration-200 group-hover:opacity-90"
:aria-label="title"
>
<span class="post-card-media__placeholder-text max-w-full break-words line-clamp-3 sm:line-clamp-4">{{ title }}</span>

View File

@@ -218,7 +218,7 @@ onBeforeUnmount(() => {
<div class="site-search-modal__body min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3 sm:px-5 sm:py-4">
<p v-if="!query.trim()" class="site-search-modal__hint text-sm text-[var(--site-soft)]">
검색어를 입력하면 태그와 게시물이 섹션별로 표시됩니다.
검색어를 입력하면 태그, 게시물, 페이지가 섹션별로 표시됩니다.
</p>
<template v-else>
@@ -244,7 +244,7 @@ onBeforeUnmount(() => {
</ul>
</section>
<section v-if="(data?.posts?.length ?? 0) > 0" class="site-search-modal__section">
<section v-if="(data?.posts?.length ?? 0) > 0" class="site-search-modal__section mb-6">
<h2 class="site-search-modal__section-title mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--site-soft)]">
Posts
</h2>
@@ -272,7 +272,35 @@ onBeforeUnmount(() => {
</ul>
</section>
<p v-if="!pending && (data?.tags?.length ?? 0) === 0 && (data?.posts?.length ?? 0) === 0" class="text-sm text-[var(--site-soft)]">
<section v-if="(data?.pages?.length ?? 0) > 0" class="site-search-modal__section">
<h2 class="site-search-modal__section-title mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--site-soft)]">
Pages
</h2>
<ul class="space-y-1">
<li v-for="page in data.pages" :key="page.slug">
<NuxtLink
class="site-search-modal__page-link block rounded-md px-2 py-2 transition-colors hover:bg-[var(--site-panel)]"
:to="`/pages/${page.slug}/`"
@click="open = false"
>
<div class="text-[15px] font-semibold leading-snug text-[var(--site-text)]">
<template v-for="(seg, i) in highlightParts(page.title, query)" :key="`pt-${page.slug}-${i}`">
<mark v-if="seg.hit" class="bg-transparent font-semibold text-[var(--site-text)]">{{ seg.text }}</mark>
<span v-else>{{ seg.text }}</span>
</template>
</div>
<div v-if="clipExcerpt(page.excerpt)" class="mt-0.5 line-clamp-2 text-sm leading-relaxed text-[var(--site-soft)]">
<template v-for="(seg, i) in highlightParts(clipExcerpt(page.excerpt), query)" :key="`pe-${page.slug}-${i}`">
<mark v-if="seg.hit" class="bg-transparent font-semibold text-[var(--site-muted)]">{{ seg.text }}</mark>
<span v-else>{{ seg.text }}</span>
</template>
</div>
</NuxtLink>
</li>
</ul>
</section>
<p v-if="!pending && (data?.tags?.length ?? 0) === 0 && (data?.posts?.length ?? 0) === 0 && (data?.pages?.length ?? 0) === 0" class="text-sm text-[var(--site-soft)]">
일치하는 결과가 없습니다.
</p>
</template>