페이지 섹션·공개 목록 설정 누적 변경 일괄 반영
페이지 섹션 관리와 홈 Pages 진열장 연동, 공개 목록 표시개수 설정 관련 누적 로컬 변경을 기능 단위로 정리해 반영한다. 문서 이력과 패키지 버전을 v1.9.23으로 맞춰 코드와 문서 상태를 동기화한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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을 현재 커서 위치에 삽입합니다. 예: <img src="여기">
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user