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

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

View File

@@ -0,0 +1,13 @@
ALTER TABLE pages
ADD COLUMN IF NOT EXISTS is_listed BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS is_featured BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS listing_section TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS listing_title TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS listing_description TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS listing_sort_order INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS pages_listing_public_idx
ON pages (is_listed, status, listing_section ASC, listing_sort_order ASC, updated_at DESC);
CREATE INDEX IF NOT EXISTS pages_featured_public_idx
ON pages (is_featured, status, listing_sort_order ASC, updated_at DESC);

View File

@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS page_listing_sections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO page_listing_sections (name, sort_order)
SELECT DISTINCT
trim(listing_section) AS name,
dense_rank() OVER (ORDER BY trim(listing_section)) * 10 AS sort_order
FROM pages
WHERE trim(COALESCE(listing_section, '')) <> ''
ON CONFLICT (name) DO NOTHING;
CREATE INDEX IF NOT EXISTS page_listing_sections_sort_order_name_idx
ON page_listing_sections (sort_order ASC, name ASC);

View File

@@ -0,0 +1,46 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS home_latest_post_limit INTEGER NOT NULL DEFAULT 20,
ADD COLUMN IF NOT EXISTS post_list_page_size INTEGER NOT NULL DEFAULT 20,
ADD COLUMN IF NOT EXISTS tag_post_list_page_size INTEGER NOT NULL DEFAULT 20,
ADD COLUMN IF NOT EXISTS featured_post_limit INTEGER NOT NULL DEFAULT 6,
ADD COLUMN IF NOT EXISTS page_section_item_limit INTEGER NOT NULL DEFAULT 12;
ALTER TABLE site_settings
ADD CONSTRAINT site_settings_home_latest_post_limit_range_check
CHECK (home_latest_post_limit BETWEEN 1 AND 50)
NOT VALID;
ALTER TABLE site_settings
ADD CONSTRAINT site_settings_post_list_page_size_range_check
CHECK (post_list_page_size BETWEEN 1 AND 50)
NOT VALID;
ALTER TABLE site_settings
ADD CONSTRAINT site_settings_tag_post_list_page_size_range_check
CHECK (tag_post_list_page_size BETWEEN 1 AND 50)
NOT VALID;
ALTER TABLE site_settings
ADD CONSTRAINT site_settings_featured_post_limit_range_check
CHECK (featured_post_limit BETWEEN 1 AND 50)
NOT VALID;
ALTER TABLE site_settings
ADD CONSTRAINT site_settings_page_section_item_limit_range_check
CHECK (page_section_item_limit BETWEEN 1 AND 50)
NOT VALID;
ALTER TABLE site_settings
VALIDATE CONSTRAINT site_settings_home_latest_post_limit_range_check;
ALTER TABLE site_settings
VALIDATE CONSTRAINT site_settings_post_list_page_size_range_check;
ALTER TABLE site_settings
VALIDATE CONSTRAINT site_settings_tag_post_list_page_size_range_check;
ALTER TABLE site_settings
VALIDATE CONSTRAINT site_settings_featured_post_limit_range_check;
ALTER TABLE site_settings
VALIDATE CONSTRAINT site_settings_page_section_item_limit_range_check;

View File

@@ -1,5 +1,9 @@
# 업데이트 요약
## v1.9.23
- 페이지 섹션 관리와 홈 Pages 진열장 연동, 공개 목록 표시개수 반영 변경사항을 일괄 적용했다.
## v1.9.22
- 홈 Pages 섹션의 좌우 화살표 버튼을 Featured 섹션과 같은 스타일로 통일했다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.9.10에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.9.21에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -68,6 +68,8 @@ docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
```
> v1.9.16부터 공개 목록 표시개수 설정 컬럼을 추가하는 `062_site_settings_display_counts.sql` 마이그레이션이 필요하다.
### 확인 주소
- 개발 서버: http://127.0.0.1:43080

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-06-30 v1.9.23 — 누적 로컬 변경은 기능 묶음 단위로 일괄 반영한다
페이지 섹션 관리, 홈 Pages 진열장 노출, 공개 목록 표시개수처럼 같은 주제의 변경이 여러 파일에 걸쳐 누적된 상태에서는 부분 커밋보다 기능 묶음 단위로 한 번에 반영하는 편이 추적이 쉽다. 이번 반영은 누적된 로컬 변경을 기능 맥락 그대로 커밋해 코드·문서·버전 상태를 동기화한다.
## 2026-06-30 v1.9.22 — 홈 선반 화살표 스타일은 섹션 간 일관성을 우선한다
같은 홈 진열장 패턴인 Featured와 Pages의 좌우 이동 버튼 모양이 다르면 기능은 같아도 다른 컴포넌트처럼 보인다. 따라서 Pages 선반 화살표를 Featured와 같은 텍스트형 버튼 스타일로 맞춰, 섹션 제목 오른쪽 네비게이션의 시각 규칙을 통일한다.

View File

@@ -68,6 +68,9 @@
| db/migrations/057_navigation_recommended_favicon_cache.sql | 추천 사이트 파비콘 캐시 URL·갱신 시각 컬럼 추가 |
| db/migrations/058_tag_header_cover.sql | 태그 헤더 이미지·오버레이 제목·본문 컬럼 추가 |
| db/migrations/059_post_analytics_traffic_daily.sql | 게시물별 유입원·디바이스·키워드 일별 축약 집계 테이블 추가 |
| db/migrations/060_page_listing_metadata.sql | 페이지 홈 진열장 노출 메타 컬럼 추가 |
| db/migrations/061_page_listing_sections.sql | 페이지 홈 진열장 섹션 관리 테이블 추가 |
| db/migrations/062_site_settings_display_counts.sql | 사이트 설정 공개 목록 표시개수 컬럼 추가 |
## 서버 미들웨어
@@ -102,7 +105,7 @@
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시하고 태그 페이지 링크 제공 |
| components/site/PostCardMedia.vue | 게시물 카드 썸네일(`featuredImageThumbnail` 우선, 없으면 대표 이미지 원본 사용. 대표 이미지 없으면 모바일 3줄·`sm` 이상 4줄 제목 placeholder 표시) |
| components/site/PostCardMedia.vue | 게시물 카드 썸네일(`featuredImageThumbnail` 우선, 없으면 대표 이미지 원본 사용. 대표 이미지 없으면 모바일 3줄·`sm` 이상 4줄 제목 placeholder 표시, 이미지 유무와 관계없이 썸네일 영역 크기 고정) |
| components/site/TagHeader.vue | 태그 페이지 헤더 |
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 입력값 기반 등록 버튼 활성화, 작성자 썸네일/좋아요/상대시간 표시 |
@@ -116,7 +119,7 @@
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 카드 썸네일을 숨기는 원본 전용 대표 이미지 선택, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 홈 Pages 진열장 노출 메타와 등록된 페이지 섹션 검색형 선택, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 카드 썸네일 파생 파일 숨김, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/HTML 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
@@ -174,11 +177,11 @@
| pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
| pages/admin/posts/[id]/analytics.vue | 게시물별 통계, 누적 조회수·기간별 조회/방문자 차트·유입/디바이스/키워드·읽음/체류/스크롤 지표 |
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
| pages/admin/pages/index.vue | 페이지 목록, 제목 아래 주소·공개 보기 hover 아이콘, 조회수·통계 모달, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) |
| pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 저장 토스트 |
| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 |
| pages/admin/pages/index.vue | 페이지 목록, 제목 아래 주소·공개 보기 hover 아이콘, 홈 표시·섹션·추천 배지, 조회수·통계 모달, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) |
| pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 홈 Pages 진열장 노출 메타, 저장 토스트 |
| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 홈 Pages 진열장 노출 메타, 설정 패널 삭제, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/카드 썸네일/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 모든 탭 공통 전체/미사용 배지형 필터, 목록 카드 오른쪽 위 사용 개수 배지, 현재 필터 결과의 삭제 가능 항목 전체 선택·선택 삭제, 이미지·비디오·오디오·파일 종류 필터, 원본 fallback·카드 썸네일 사용 배지(카드 썸네일 탭의 카드 배지는 숨김), 대표 이미지 원본 카드 썸네일 생성·재생성, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 추천 사이트는 공개와 같은 카드 목록+편집 모달(전체 URL·썸네일·파비콘 갱신), 행 more vert(삭제), 드래그 정렬, `useAdminToast` |
| pages/admin/navigation/index.vue | 메뉴 관리: 메인 네비게이션/페이지 섹션/하단 네비게이션/추천 사이트 탭, 각 탭 작업줄 왼쪽 추가·오른쪽 저장 버튼, 메인·하단 메뉴 추가 모달, 페이지 섹션 추가 모달·수정·삭제·섹션 드래그 정렬·섹션별 카드형 페이지 순서 변경 모달(추천 우선 표시·섹션에서 제거 포함), 추천 사이트는 공개와 같은 카드 목록+편집 모달(전체 URL·썸네일·파비콘 갱신), 행 more vert(삭제), 저장 대상별 버튼(`네비게이션 저장`/`페이지 섹션 저장`), `useAdminToast` |
| components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) |
| composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 |
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 |
@@ -194,14 +197,14 @@
| 파일 | 화면 |
|------|------|
| pages/index.vue | 홈, `site_settings` 커버가 있을 때만 라이트·다크 이미지 지원 `HomeHero`, Featured/Latest, Latest 피드 Compact 기본값·List·Cards 보기, Compact 모바일 썸네일 80px 정사각형·Compact/List 설명 2줄 제한, Latest 메타는 태그가 있을 때만 링크 배지 표시, 카드 오른쪽 아래 화살표는 게시물 상세 링크, Featured는 추천 글이 있을 때만 표시하며 이미지 없는 추천 글은 제목 placeholder 썸네일과 모바일 터치 가로 스크롤·스냅, 끝에서 화살표 비활성 |
| pages/posts/index.vue | 게시물 전체 목록, 태그는 있을 때만 카드 메타에 링크로 표시 |
| pages/index.vue | 홈, `site_settings` 커버가 있을 때만 라이트·다크 이미지 지원 `HomeHero`, Featured/Latest, 사이트 설정 표시개수 기준 Latest·Featured·Pages 데이터 제한, 홈 표시 페이지를 섹션별 도서 진열장형 Pages 선반으로 노출, Featured와 Pages 선반의 독립 이전·다음 버튼 및 문서 레벨 포인터 추적 기반 드래그 슬라이드·스냅, Pages 선반 ResizeObserver 기반 버튼 상태 갱신, Pages 카드 hover 상단 여백 보정, Latest 피드 Compact 기본값·List·Cards 보기, Compact 모바일 썸네일 80px 정사각형·Compact/List 설명 2줄 제한, Latest 메타는 태그가 있을 때만 링크 배지 표시, 카드 오른쪽 아래 화살표는 게시물 상세 링크, Featured는 추천 글이 있을 때만 표시하며 이미지 없는 추천 글은 제목 placeholder 썸네일과 모바일 터치 가로 스크롤·스냅, 끝에서 화살표 비활성 |
| pages/posts/index.vue | 게시물 전체 목록, 사이트 설정 Posts 표시개수 기준 서버 페이지네이션, 태그는 있을 때만 카드 메타에 링크로 표시 |
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, H1~H3 본문 제목을 오른쪽 TOC 상태로 제공, 공개 회원 로그인 세션의 회원이 글쓴이(`author_id`)이면 공유 버튼 옆 새 탭 편집 링크 표시, 게시물 SEO/OG 메타 출력(요약 없으면 본문 fallback), 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
| pages/tag/[slug].vue | 태그별 글 목록, 태그 헤더 이미지가 있으면 상단 이미지 헤더 표시, 없으면 기본 태그명·설명 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드, 카드 태그 배지와 오른쪽 아래 화살표 링크 |
| pages/pages/[slug].vue | 고정 페이지 상세, HTML 문서 모드는 직접 진입 시 서버 미들웨어 원문 응답·클라이언트 내부 이동 시 해당 URL 재진입 |
| pages/tag/[slug].vue | 태그별 글 목록, 사이트 설정 태그 게시물 표시개수 기준 서버 태그 필터·페이지네이션, 태그 헤더 이미지가 있으면 상단 이미지 헤더 표시, 없으면 기본 태그명·설명 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드, 카드 태그 배지와 오른쪽 아래 화살표 링크 |
| pages/pages/[slug].vue | 고정 페이지 상세, HTML 문서 모드는 직접 진입 시 서버 미들웨어 원문 응답·클라이언트 내부 이동 시 현재 URL reload로 서버 원문 응답 재진입 |
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, 모바일 OTP 입력창 높이 보정, `POST /api/auth/email-otp/request` |
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
| pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) |
@@ -211,9 +214,9 @@
| 파일 | 기능 |
|------|------|
| server/api/posts.get.js | 공개 게시물 목록 API(`published`, VIP 이상 등급 시 `members`) |
| server/api/posts.get.js | 공개 게시물 목록 API(`published`, VIP 이상 등급 시 `members`, `limit`·`tag`·`featured`·`paginated` 조회 옵션) |
| server/api/posts/[slug].get.js | 공개 게시물 상세 API(`published`, VIP 이상 등급 시 `members`) |
| server/api/pages.get.js | 공개 고정 페이지 목록 API(`published`만) |
| server/api/pages.get.js | 공개 고정 페이지 목록 API(`published`만, `listed=1`·섹션별 `limit` 조회 옵션) |
| server/api/pages/[slug].get.js | 공개 고정 페이지 상세 API(`published`만) |
| server/api/tags.get.js | 공개 태그 목록 API, 기본 메인 태그 반환·`tagType=all` 전체 태그 메타 반환 |
| server/routes/admin/api/tags/cover.post.js | 관리자 태그 헤더 이미지 업로드 API(`/uploads/system/tag-cover-*.webp`) |
@@ -256,11 +259,17 @@
| server/routes/admin/api/posts/export-jobs/[jobId]/retry.post.js | 관리자 게시물 Export 실패 작업 재시도 API |
| server/routes/admin/api/posts/export-jobs/[fileId]/download.get.js | 관리자 게시물 Export 분할 ZIP 다운로드 API |
| server/routes/admin/api/posts/import.post.js | 관리자 게시물 Export ZIP Import API |
| server/routes/admin/api/pages.get.js | 관리자 고정 페이지 목록 API |
| server/routes/admin/api/pages.get.js | 관리자 고정 페이지 목록 API(홈 표시·섹션·추천 메타 포함) |
| server/routes/admin/api/pages.post.js | 관리자 고정 페이지 생성 API |
| server/routes/admin/api/pages/[id].get.js | 관리자 고정 페이지 상세 API |
| server/routes/admin/api/pages/[id].put.js | 관리자 고정 페이지 수정 API |
| server/routes/admin/api/pages/[id].delete.js | 관리자 고정 페이지 삭제 API |
| server/routes/admin/api/page-sections.get.js | 관리자 페이지 섹션 목록 API |
| server/routes/admin/api/page-sections.post.js | 관리자 페이지 섹션 생성 API |
| server/routes/admin/api/page-sections/[id].put.js | 관리자 페이지 섹션 이름 수정 API |
| server/routes/admin/api/page-sections/[id].delete.js | 관리자 페이지 섹션 삭제 API |
| server/routes/admin/api/page-sections/reorder.put.js | 관리자 페이지 섹션 순서 저장 API |
| server/routes/admin/api/page-sections/[id]/pages/reorder.put.js | 관리자 페이지 섹션 내부 페이지 순서 저장 API |
| server/routes/admin/api/media.get.js | 관리자 미디어 목록 API |
| server/routes/admin/api/media.put.js | 관리자 미디어 파일명 변경 및 단일/복수 폴더 변경 API |
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |

View File

@@ -55,15 +55,20 @@
- 가로 카드 트랙은 `overflow-x-auto`와 `snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다.
- Featured 영역은 추천 글(`isFeatured=true`)이 1개 이상 있을 때만 표시한다.
- 표시 개수는 사이트 설정 POST 설정의 `featuredPostLimit` 값을 따른다. 기본값은 6개다.
- Featured 글에 대표 이미지가 없으면 목록 썸네일과 동일하게 카드 안에 게시물 제목을 표시하는 placeholder를 사용한다.
- Featured 대표 이미지가 게시물 업로드 이미지이고 카드 썸네일이 생성되어 있으면 원본 대신 `featuredImageThumbnail`을 우선 표시한다.
- 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화.
- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다.
- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다. Pages 선반은 각 선반의 스크롤 컨테이너와 카드 크기 변화를 감시해 이미지 로딩·반응형 폭 변화 후에도 버튼 상태를 다시 계산한다.
- 마우스/트랙패드 환경에서는 카드 트랙을 직접 드래그해 넘길 수 있고, 드래그 종료 시 가까운 카드 위치로 스냅한다. 트랙 안의 이미지 기본 드래그와 텍스트 선택은 막아 캐러셀 조작을 우선하며, 작은 드래그도 일정 거리 이상이면 다음·이전 카드 위치로 붙는다. 드래그 중 포인터가 카드 밖으로 이동해도 문서 레벨 포인터 추적으로 조작을 유지한다. 드래그 직후에는 카드 링크 클릭을 억제해 의도치 않은 이동을 막는다.
### 홈 Latest 피드
- 기본 보기 방식은 `compact`이며, Default 선택 시에도 `compact`로 복원한다.
- 홈 Latest는 사이트 설정 POST 설정의 `homeLatestPostLimit` 값만큼 서버에서 최신 글을 가져온다. 기본값은 20개다.
- 홈 Latest 하단에는 전체 게시물 목록(`/posts`)으로 이동하는 더보기 링크를 표시한다.
- `compact`는 썸네일을 포함한 짧은 행 형태이며 설명은 최대 2줄로 제한한다. 모바일 compact 썸네일은 오른쪽 제목·요약·메타 높이에 맞춘 80px 정사각형으로 표시하고, `sm` 이상에서는 기존 비율형 썸네일을 사용한다. 대표 이미지가 없는 placeholder 제목은 모바일 3줄, `sm` 이상 4줄로 제한하며 긴 단어는 썸네일 안에서 줄바꿈한다. `list`는 텍스트 중심 목록 형태로 설명은 최대 2줄, `cards`는 카드 그리드 형태로 표시한다.
- 홈 Latest·게시물 목록·태그 목록의 썸네일 영역은 대표 이미지 유무와 관계없이 같은 크기를 유지한다.
- 홈 Latest·게시물 목록·태그 목록의 카드 이미지는 `featuredImageThumbnail`을 우선 사용하고, 값이 없으면 기존 `featuredImage` 원본 URL로 대체한다.
- Latest 섹션은 게시물이 적어도 보기 방식 선택 메뉴가 아래쪽에서 잘리지 않도록 최소 높이를 둔다.
- 사이트 설정 Ads의 메인 피드 광고 코드가 있으면 Featured 영역과 Latest 목록 사이에 광고 슬롯을 표시한다. 메인 인피드 광고 코드가 있으면 Latest 게시물 목록 사이 한 곳에 브라우저 렌더 시점 기준으로 무작위 삽입한다. 비어 있으면 슬롯 자체를 렌더링하지 않는다.
@@ -112,20 +117,27 @@
- About, Projects, Links, Contact, 서비스 소개 페이지 등 고정 콘텐츠에 사용
- 기본 게시물 목록에는 노출하지 않음
- 페이지 설정의 `홈에 표시`가 켜진 공개 페이지는 홈 화면의 Pages 진열장 영역에 노출한다. 진열장은 `섹션명` 기준으로 여러 선반을 만들고, 각 페이지는 도서/게임 라이브러리처럼 세로형 커버 카드로 표시한다. 각 선반은 Featured와 같은 이전·다음 버튼과 드래그 슬라이드·스냅 이동을 지원하며, 선반별로 독립 동작한다. 카드 폭은 데스크톱 기준 약 164px로 여러 페이지가 있을 때 가로 탐색이 명확히 생기도록 한다. 섹션별 표시 개수는 사이트 설정 POST 설정의 `pageSectionItemLimit` 값을 따른다. 기본값은 12개다.
- Pages 진열장 커버는 3:4 세로 비율을 기준으로 표시한다. 제작 권장 크기는 `900×1200px`이며, 고해상도 원본을 준비할 때는 `1200×1600px`을 권장한다. 최소 권장 크기는 `600×800px`이다.
- 페이지 섹션 생성·이름 변경·삭제·표시 순서는 관리자 네비게이션 관리의 `페이지 섹션` 탭에서 다룬다. 섹션 추가는 모달에서 이름을 입력해 생성하고, 이름 변경과 섹션 표시 순서는 탭 안의 `페이지 섹션 저장`으로 한 번에 저장한다. 페이지 작성·수정 화면에서는 등록된 섹션을 검색형 입력으로 선택한다.
- 섹션 내부 페이지 표시 순서는 관리자 네비게이션 관리의 `페이지 섹션` 탭에서 섹션별 `페이지 순서` 모달로 조정하며, 페이지 작성·수정 화면에서는 직접 입력하지 않는다. 순서 모달은 페이지 썸네일 카드형 가로 목록으로 표시하고, 추천 페이지를 먼저 보여 주며, 추천 페이지가 비추천 페이지 뒤로 드롭되면 즉시 추천 우선 순서로 보정한다. 페이지를 해당 섹션에서 바로 제거할 수 있다.
- `추천으로 강조`된 페이지는 같은 섹션 안에서 먼저 정렬한다.
- 목록 설명은 홈 카드에는 표시하지 않고 공개 검색 결과와 관리용 메타로 사용한다.
- 헤더와 사이드바를 사용하지 않고 본문 중심 전체 화면으로 표시
- 페이지는 `renderMode`로 렌더링 방식을 구분한다. 기본값은 `html_document`이며 관리자에서 붙여넣은 전체 HTML 문서를 공개 `/pages/:slug` 요청에서 `text/html` 원문으로 응답한다. `markdown`은 관리자 UI에서 `일반 텍스트`로 표시하며 기존 Markdown 콘텐츠 렌더러를 사용한다.
- HTML 문서 모드는 관리자만 저장하는 신뢰 콘텐츠를 전제로 하며, `<head>`, `<style>`, `<body>`를 포함한 단일 랜딩 페이지 용도로 사용한다.
- HTML 문서 모드 페이지로 Nuxt 클라이언트 라우팅을 통해 진입한 경우 현재 URL을 `reload()`해 서버 미들웨어의 원문 HTML 응답으로 재진입한다. 브라우저 뒤로가기는 직전 Nuxt 화면을 복원해야 한다.
- 진입 경로는 추후 메뉴/링크 설정을 통해 연결
### 공개 URL 구조
- `/posts` - 게시물 전체 목록
- `/posts` - 게시물 전체 목록. 사이트 설정 POST 설정의 `postListPageSize` 값 기준으로 서버 페이지네이션을 사용하며, 페이지 번호가 바뀌면 화면을 상단으로 이동한다.
- `/post/:slug` - 개별 게시물 상세
- `/sitemap.xml` - 공개 sitemap XML. 홈, 게시물 목록, 태그 목록, 공개 발행글, 공개 고정 페이지, 글이 있는 태그 URL을 요청 시점 DB 기준으로 자동 생성한다. 멤버십·비공개·초안·예약 대기 글과 `noindex` 글은 포함하지 않는다.
- `/robots.txt` - 크롤러 안내 파일. 관리자·API·회원 설정·로그인 경로를 제외하고 `Sitemap: /sitemap.xml` 절대 URL을 제공한다.
- `/rss.xml`, `/feed.xml`, `/rss` - 최근 공개 발행글 RSS 2.0 피드. 멤버십·비공개·초안·예약 대기 글은 포함하지 않으며, 최신순 최대 50개를 XML로 응답한다. 게시물에 대표 이미지 또는 OG 이미지가 있으면 절대 URL로 변환해 `media:thumbnail`과 `media:content medium="image"`를 함께 포함한다.
- `/tags` - 태그 전체 목록
- `/tag/:slug` - 태그별 게시물 목록
- `/tag/:slug` - 태그별 게시물 목록. 사이트 설정 POST 설정의 `tagPostListPageSize` 값 기준으로 서버 태그 필터와 페이지네이션을 사용하며, 페이지 번호가 바뀌면 화면을 상단으로 이동한다.
- 태그명에 `/`를 입력하면 태그명은 그대로 저장하고 슬러그에서는 `/`를 `-`로 변환한다. 예: `Xenoblade3/Hero` 태그명은 `xenoblade3-hero` 슬러그로 저장한다.
- `/tag/:slug` 화면의 헤더와 게시물 목록은 공개 목록 공통 섹션 패딩(`site-section-header`, `site-section-body`)을 사용해 메인 컬럼 좌우 여백을 다른 목록 화면과 맞춘다.
- 태그에 `headerImageUrl`이 있으면 `/tag/:slug` 상단에는 기본 태그명·설명 대신 태그 헤더 이미지를 표시한다. `headerTitle`, `headerText`는 이미지 위 오버레이로 표시하며, 둘 다 비어 있으면 이미지 단독으로 표시한다.
@@ -397,11 +409,27 @@ components/content/
| slug | String | URL 슬러그 |
| content | Text | HTML 문서 원문 또는 일반 텍스트 콘텐츠 |
| render_mode | String | 렌더링 방식(`html_document`, `markdown`) |
| featured_image | String nullable | 레거시 컬럼, 관리자 페이지 작성 UI에서는 사용하지 않음 |
| featured_image | String nullable | 홈 Pages 진열장 커버 이미지 |
| is_listed | Boolean | 홈 Pages 진열장 노출 여부 |
| is_featured | Boolean | 홈 Pages 진열장 추천 표시 및 우선 정렬 |
| listing_section | String | 홈 Pages 진열장 섹션명 |
| listing_title | String | 홈 Pages 진열장·검색 결과용 제목(비어 있으면 페이지 제목 사용) |
| listing_description | String | 검색 결과·관리용 목록 설명 |
| listing_sort_order | Integer | 섹션 내 정렬 순서 |
| status | Enum | `published` / `draft` / `private` |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
### Page Listing Sections
| 필드 | 타입 | 설명 |
|------|------|------|
| id | UUID | 섹션 ID |
| name | String | 홈 Pages 진열장 섹션명. 페이지의 `listing_section`과 매칭 |
| sort_order | Integer | 홈 Pages 진열장 섹션 표시 순서 |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
### Tags
| 필드 | 타입 | 설명 |
@@ -533,15 +561,15 @@ components/content/
### 공개 API (`/api/`)
- `GET /api/posts` - 게시물 목록
- `GET /api/posts` - 게시물 목록. `limit`, `featured=1`, `tag=:slug` 쿼리로 서버 목록 범위를 제한할 수 있다. `paginated=1&page=N&limit=N`이면 `{ items, pagination }` 형태로 응답하고, 없으면 기존처럼 배열을 반환한다.
- `GET /api/posts/:slug` - 게시물 상세
- `GET /api/posts/:slug/comments` - 게시물 댓글 목록
- `POST /api/posts/:slug/comments` - 게시물 댓글 작성(회원 세션 필요, 대댓글 1단)
- `POST /api/posts/:slug/comments/:commentId/like` - 댓글 좋아요 토글(회원 세션 필요)
- `GET /api/pages` - 고정 페이지 목록
- `GET /api/pages` - 공개 고정 페이지 목록. `?listed=1`이면 홈 Pages 진열장에 노출할 공개 페이지만 반환하며, 홈 목록 성능을 위해 `content`는 빈 문자열로 내려준다. `listed=1&limit=N`이면 섹션별 최대 N개만 반환한다.
- `GET /api/pages/:slug` - 고정 페이지 상세
- `GET /api/tags` - 태그 목록. 기본값은 메인 태그(`managed`)만 반환하며, `?tagType=all`은 일반 태그를 포함한 전체 태그 메타를 반환한다.
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
- `GET /api/tags` - 태그 목록. 기본값은 메인 태그(`managed`)만 반환하며, `?tagType=all`은 일반 태그를 포함한 전체 태그 메타를 반환한다. `postCount`는 공개 발행 게시물 기준이다.
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content`, 홈 표시 페이지의 `listingSection`·`listingTitle`·`listingDescription`·`content` 부분 일치)
- `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함)
- `GET /api/homepage-widget` - gethomepage customapi용 공개 블로그 통계. 기본 필드 `title`, `updatedAt`, `todayVisitors`, `totalVisitors`, `todayPageViews`, `totalPageViews`, `visitorsLast7Days`, `visitorsLast30Days`, `visitorsLast180Days`, `visitorsLast365Days`, `pageViewsLast30Days`, `onlineNow`, `loggedInNow`, `avgEngagedSeconds`, `totalPosts`, `totalComments`, `totalPages`, `totalTags`, `totalMembers`, `items[]`를 반환한다. 기간별 방문자는 최근 7·30·180·365일의 일별 순방문자 합계다. `totalVisitors`는 날짜별 순방문자 수의 누적 합계라 동일 방문자가 다른 날짜에 다시 포함될 수 있다. 게시글·댓글·페이지 수는 현재 공개 가능한 항목만 집계한다.
- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `referrer`, `currentUrl`, `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. 응답 `{ ok: true }`. 관리자 세션 요청은 저장하지 않고 `{ ok: true }`만 반환한다. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록하되, 관리자 세션은 제외한다.
@@ -602,6 +630,12 @@ components/content/
- `GET /admin/api/pages/:id` - 고정 페이지 상세
- `PUT /admin/api/pages/:id` - 고정 페이지 수정
- `DELETE /admin/api/pages/:id` - 고정 페이지 삭제
- `GET /admin/api/page-sections` - 홈 Pages 진열장 페이지 섹션 목록
- `POST /admin/api/page-sections` - 페이지 섹션 생성
- `PUT /admin/api/page-sections/:id` - 페이지 섹션 이름 수정. 연결된 페이지의 `listing_section`도 함께 변경
- `DELETE /admin/api/page-sections/:id` - 미사용 페이지 섹션 삭제
- `PUT /admin/api/page-sections/reorder` - 페이지 섹션 표시 순서 저장
- `PUT /admin/api/page-sections/:id/pages/reorder` - 페이지 섹션 내부의 홈 표시 페이지 순서 저장. `removedPageIds`를 함께 보내면 해당 페이지의 섹션 연결을 해제
- `GET /admin/api/media` - 업로드 미디어 목록(게시물용 이미지와 회원 아바타 포함; 회원 아바타에는 `avatarOwner` 요약이 붙을 수 있음)
- `POST /admin/api/media/thumbnail` - 게시물 대표 이미지 원본 URL에서 목록 카드용 썸네일을 생성 또는 재생성
- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경(회원 아바타 URL은 논리 폴더 `썸네일`만 허용, 일반 미디어를 `썸네일`로 옮기는 것은 거부; 썸네일 파일명 변경은 `users.avatar_url`이 해당 URL을 참조할 때만 거부)
@@ -775,7 +809,7 @@ components/content/
- 페이지 상태, 페이지 형식, Page URL, HTML 자산 업로드, 삭제 액션은 오른쪽 설정 패널에서 관리한다.
- 페이지 형식 선택은 HTML 문서/일반 텍스트 모드와 무관하게 항상 표시한다. HTML 자산 업로드는 HTML 문서 모드에서만 표시한다.
- HTML 자산 업로드는 기존 관리자 업로드 API(`/admin/api/uploads`)를 사용하며, 성공한 파일 URL을 HTML textarea 현재 커서 위치에 삽입한다. 업로드 파일은 현재 에디터 업로드 정책에 따라 `/uploads/posts/YYYY/MM/` 아래 저장되고 미디어 라이브러리 논리 폴더는 `미분류`로 기록된다.
- 고정 페이지는 제목, 슬러그, 렌더링 방식, 본문을 저장한다. 대표 이미지는 페이지 작성 UI에서 사용하지 않는다.
- 고정 페이지는 제목, 슬러그, 렌더링 방식, 본문을 저장한다. 썸네일 등록 이미지는 홈 Pages 진열장 커버로 사용한다.
- 고정 페이지는 게시물 목록과 태그 목록에 노출하지 않는다.
- 고정 페이지 공개 보기 경로는 `/pages/:slug`를 사용한다.
- 고정 페이지 상태는 `published`, `draft`, `private`를 사용한다. 공개 목록·상세·HTML 문서 미들웨어는 `published` 상태만 응답하고, `draft`와 `private`는 공개 URL에서 찾을 수 없는 페이지로 처리한다.
@@ -788,7 +822,7 @@ components/content/
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
- **메인 화면**(`home_cover_image_url`, `home_cover_dark_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 라이트 이미지는 기본 커버이며, 다크 이미지가 있으면 시스템 다크모드 또는 `html[data-theme='dark']`에서 다크 이미지를 표시한다. 다크 이미지가 없으면 라이트 이미지를 그대로 사용한다. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 오버레이 본문은 textarea에서 입력한 줄바꿈(`\n`)을 저장·표시하며, `HomeHero` 본문은 `whitespace-pre-line`으로 여러 줄을 렌더링한다. 관리자 UI에서는 라이트모드와 다크모드 프리뷰를 상하로 모두 표시하고, 각 모드 제목 오른쪽의 이미지 변경·삭제 버튼으로 개별 이미지를 관리한다. 이미지가 비어 있는 모드는 점선 드롭존으로 표시하며 파일 선택과 드래그 앤 드롭 업로드를 지원한다. 커버 파일 업로드·제목·본문은 편집 뒤 **저장** 한 번에 `PUT /admin/api/settings`로 반영한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`).
- **POST 설정**(`show_post_updated_at`, `post_tag_limit`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록에 수정 시각 보조 줄을 표시할지 여부와 게시물별 태그 최대 개수를 관리한다. `post_tag_limit` 기본값은 5이며 1~10 범위로 제한한다. 공개 게시글 상세에는 수정 시각을 표시하지 않는다.
- **POST 설정**(`show_post_updated_at`, `post_tag_limit`, `home_latest_post_limit`, `post_list_page_size`, `tag_post_list_page_size`, `featured_post_limit`, `page_section_item_limit`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록에 수정 시각 보조 줄을 표시할지 여부와 게시물별 태그 최대 개수를 관리한다. `post_tag_limit` 기본값은 5이며 1~10 범위로 제한한다. 표시개수 설정은 Latest, Posts, 태그 게시물, Featured, Pages 섹션의 공개 목록 로드·표시 개수를 관리하며 1~50 범위의 셀렉트 값만 사용한다. 공개 게시글 상세에는 수정 시각을 표시하지 않는다.
- **가입 금지 닉네임**(`signup_blocked_usernames`, JSON 문자열): 회원가입·회원 프로필 닉네임 변경 시 닉네임에 목록 단어가 포함되면 거부한다(대소문자 무시, 부분 일치). 안내 문구는 `{단어}은 사용할 수 없는 단어입니다.` 형식이다. 기본값: `admin`, `master`, `zenn`, `sori`, `sori.studio`.
- **어나운스 바**(`announcement_enabled`, `announcement_text`, `announcement_url`, `announcement_background_color`): `announcement_enabled`가 true이고 문구가 비어 있지 않으면 공개 레이아웃(`default`·`post`) 헤더 위에 전체 너비 배너를 표시한다. `announcement_url`이 있으면 문구를 링크로 감싼다(내부 `/…` 또는 `http(s)://`). 배경색은 `#15171a`·`#ffffff`·`#ff4f2e` 중 하나. 방문자는 **이번 방문 동안 닫기**(X, `sessionStorage`) 또는 **7일간 보지 않기**(`localStorage`, 만료 시각 저장)를 선택할 수 있다. 공지 내용이 바뀌어 `updatedAt`이 달라지면 다시 노출된다.
- **사이트 코드**(`ads_txt`, `custom_head_code`, `custom_footer_code`): `ads_txt`는 루트 `/ads.txt`에서 `text/plain`으로 응답한다. `custom_head_code`는 공개 Nuxt HTML 응답의 `head` 끝에, `custom_footer_code`는 `body` 끝에 원문 HTML로 삽입한다. 관리자 페이지, `/api`, `/uploads`, `/_nuxt`, `/ads.txt` 응답에는 삽입하지 않는다.
@@ -805,7 +839,7 @@ components/content/
- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**. **상단은 루트 직속 자식만** 트리에 붙이며(부모의 부모가 있는 행은 자식으로 무시), `footer`·`recommended`는 **평면** 배열(`parent_id` 없음)이다. `recommended`는 `id`, `label`, `url`, `descriptionText`, `thumbnailUrl`, `cachedFaviconUrl`, `isVisible`을 내려준다. 관리자 `GET /admin/api/navigation`에는 추가로 `faviconCachedAt`을 포함한다.
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 `primary`·`footer`·`recommended` 각각 위치별 트리(또는 평면 루트) DFS 순으로 다시 부여한다. `parent_id`는 `primary`에서만 허용되며 **상단은 한 단계(루트→자식)만** 허용한다. `footer`·`recommended` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다.
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다. 저장 시 `uniare.sori.studio`처럼 프로토콜 없는 외부 호스트는 `https://`를 자동으로 붙인다. 관리자 추천 사이트 편집 모달은 URL 접두사(`https://`·`http://`·내부 경로) 선택과 본문 입력으로 나눈다.
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **상단은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. **추천 사이트** 탭 목록은 공개 Recommended와 같은 **카드**(아이콘·제목·설명 또는 URL) 형태이며, 캐시 갱신 시각은 카드 오른쪽 끝에 표시한다. 추천 사이트 드래그는 카드 **위·아래 절반**에 드롭하면 각각 대상 **앞·뒤**로 삽입되며, 파란 가로 삽입선·`앞에 삽입` / `뒤에 삽입` 안내를 표시한다. 추천 사이트는 공개 화면 **왼쪽 사이드바** Categories 아래 Recommended 영역에 카드로 노출되며, 대체 텍스트가 있으면 URL 대신 대체 텍스트를 표시한다. 아이콘 우선순위는 **수동 썸네일 URL > 서버 캐시 파비콘(`cached_favicon_url`) > 외부 faviconV2 프록시**이다. 서버는 외부 `https://` URL의 파비콘을 `/uploads/system/recommended-favicon-*.png`로 저장하고 `favicon_cached_at`을 기록한다. 자동 갱신은 **매월 1일 03:00(KST)** 에 전체 추천 사이트를 대상으로 수행하며, **관리자 추천 사이트 탭**은 공개와 같은 **카드 목록**으로 미리보기하고 **편집** 버튼으로 모달에서 제목·URL·대체 텍스트·썸네일 URL 전체를 확인·수정한다. 삭제·취소(배경 클릭 포함)는 브라우저 `confirm`으로 한 번 더 확인하며, 모달에서 **적용** 전 입력 변경이 있으면 닫기 전에 안내한다. 편집 모달에서 `POST /admin/api/navigation/:id/favicon-refresh`로 파비콘을 수동 갱신할 수 있다. 네비게이션 저장 시 캐시 없는 추천 사이트는 백그라운드로 최초 캐시를 시도한다. 운영 수동 일괄 갱신은 `npm run navigation:refresh-recommended-favicons`를 사용한다. 오른쪽 사이드바는 추천 사이트를 렌더하지 않고 광고와 게시물 목차 영역에 집중한다.
- 관리자 메뉴 화면은 **메인 네비게이션**·**페이지 섹션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 메인·하단·추천 사이트 추가는 모두 추가 버튼 클릭 후 모달에서 필요한 정보를 입력하고 적용해야 목록에 반영된다. 각 탭 작업줄은 왼쪽에 추가 버튼, 오른쪽에 저장 버튼을 두며, 메인·하단·추천 사이트 변경사항은 `네비게이션 저장`, 페이지 섹션 변경사항은 `페이지 섹션 저장`으로 저장한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 메인 네비게이션은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **메인 네비게이션은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 페이지 섹션·하단·추천 사이트는 평면 목록만 드래그 정렬한다. **추천 사이트** 탭 목록은 공개 Recommended와 같은 **카드**(아이콘·제목·설명 또는 URL) 형태이며, 캐시 갱신 시각은 카드 오른쪽 끝에 표시한다. 추천 사이트 드래그는 카드 **위·아래 절반**에 드롭하면 각각 대상 **앞·뒤**로 삽입되며, 파란 가로 삽입선·`앞에 삽입` / `뒤에 삽입` 안내를 표시한다. 추천 사이트는 공개 화면 **왼쪽 사이드바** Categories 아래 Recommended 영역에 카드로 노출되며, 대체 텍스트가 있으면 URL 대신 대체 텍스트를 표시한다. 아이콘 우선순위는 **수동 썸네일 URL > 서버 캐시 파비콘(`cached_favicon_url`) > 외부 faviconV2 프록시**이다. 서버는 외부 `https://` URL의 파비콘을 `/uploads/system/recommended-favicon-*.png`로 저장하고 `favicon_cached_at`을 기록한다. 자동 갱신은 **매월 1일 03:00(KST)** 에 전체 추천 사이트를 대상으로 수행하며, **관리자 추천 사이트 탭**은 공개와 같은 **카드 목록**으로 미리보기하고 **편집** 버튼으로 모달에서 제목·URL·대체 텍스트·썸네일 URL 전체를 확인·수정한다. 삭제·취소(배경 클릭 포함)는 브라우저 `confirm`으로 한 번 더 확인하며, 모달에서 **적용** 전 입력 변경이 있으면 닫기 전에 안내한다. 편집 모달에서 `POST /admin/api/navigation/:id/favicon-refresh`로 파비콘을 수동 갱신할 수 있다. 네비게이션 저장 시 캐시 없는 추천 사이트는 백그라운드로 최초 캐시를 시도한다. 운영 수동 일괄 갱신은 `npm run navigation:refresh-recommended-favicons`를 사용한다. 오른쪽 사이드바는 추천 사이트를 렌더하지 않고 광고와 게시물 목차 영역에 집중한다.
- `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev`로 `017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage` 키 `sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-sidebar-nav-row` 호버 배경이 가로 전체를 쓴다(라이트 `#F7F4EF`, 다크는 `site-panel-hover`와 동일한 `color-mix` 패턴).
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.

View File

@@ -2,41 +2,17 @@
## 1차 관리자 개발
- [ ] 게시물 Import 후속 검증: Export ZIP 샘플을 실제 Obsidian vault에서 열어 경로·frontmatter 호환성 확인
- [ ] Markdown-first 에디터 3차 개선: 미리보기 인라인 편집 확대(코드 블록·콜아웃·이미지 캡션·새 블록 추가), 옵시디언식 토큰 숨김 Live Preview, 표준 마크다운 파서 도입 검토
## 2차 관리자 개발
- [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결
## 프론트엔드 개발
- [ ] Thred 참고 화면 기준 시각 QA
- [ ] 사이드바 토글 애니메이션 세부 조정
- [ ] 사용자 화면 테마 전환 초기 로드 깜빡임(FART) 최소화
- [ ] 회원 설정 썸네일 수동 크롭 UI(선택 영역 지정) 도입 검토
## 콘텐츠 스타일 구현
- [ ] ProseHeading 실제 스타일 세부 조정
- [ ] ProseList 실제 스타일 세부 조정
- [ ] ProseBlockquote 실제 스타일 세부 조정
- [ ] ProseImage Regular/Wide/Full-width 동작 검증
- [ ] ProseImage Wide/Full-width 화면 이탈과 스킨별 최대 폭 기준 재정리
- [ ] ProseButton Left/Center 정렬 검증
- [ ] ProseCallout 실제 스타일 세부 조정
- [ ] ProseToggle 실제 스타일 세부 조정
- [ ] ProseProduct 실제 상품 카드 데이터 연결
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
## 데이터베이스
- [ ] NAS 운영 DB 연결 설정 실제 값 작성
- [ ] 이전에 원격에 올라간 관리자 비밀번호가 실제 사용 값이면 즉시 폐기 및 변경
## 배포
- [ ] `npm run preview`가 Nuxt 3.21에서 `127.0.0.1`을 빌드 경로로 해석하는 문제 확인 및 preview 스크립트 옵션 정리
- [ ] NAS 운영 환경 변수 최종 점검
- [ ] NAS 실제 컨테이너 기동 및 도메인/프록시 접속 QA
- [ ] 백업/복구 시스템 설계 및 구현(다음달 작업): PostgreSQL `pg_dump` 자동 백업, `public/uploads/` 압축 백업, 보관 주기, 복구 절차, 실제 복구 테스트 포함

View File

@@ -1,5 +1,10 @@
# 업데이트 이력
## v1.9.23
- 페이지 섹션 관리 API·관리자 화면·홈 Pages 진열장 연동 변경분 일괄 반영.
- 공개 목록 표시개수 설정 및 관련 서버 조회 로직 변경분 일괄 반영.
## v1.9.22
- 홈 Pages 섹션 슬라이드 화살표를 Featured와 동일한 텍스트형 버튼 스타일로 통일.

View File

@@ -0,0 +1,48 @@
export const DISPLAY_COUNT_OPTIONS = [6, 10, 12, 20, 30, 50]
export const POST_LIST_COUNT_OPTIONS = [10, 20, 30, 50]
export const DEFAULT_HOME_LATEST_POST_LIMIT = 20
export const DEFAULT_POST_LIST_PAGE_SIZE = 20
export const DEFAULT_TAG_POST_LIST_PAGE_SIZE = 20
export const DEFAULT_FEATURED_POST_LIMIT = 6
export const DEFAULT_PAGE_SECTION_ITEM_LIMIT = 12
export const MIN_DISPLAY_COUNT = 1
export const MAX_DISPLAY_COUNT = 50
/**
* 공개 목록 표시 개수를 정규화한다.
* @param {unknown} value - 원본 값
* @param {number} fallback - 기본값
* @returns {number} 표시 개수
*/
export const normalizeDisplayCount = (value, fallback) => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return fallback
}
const rounded = Math.round(parsed)
if (rounded < MIN_DISPLAY_COUNT) {
return MIN_DISPLAY_COUNT
}
if (rounded > MAX_DISPLAY_COUNT) {
return MAX_DISPLAY_COUNT
}
return rounded
}
/**
* 페이지 번호를 정규화한다.
* @param {unknown} value - 원본 값
* @returns {number} 페이지 번호
*/
export const normalizeListPage = (value) => {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed < 1) {
return 1
}
return Math.floor(parsed)
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.9.22",
"version": "1.9.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.9.22",
"version": "1.9.23",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.9.22",
"version": "1.9.23",
"private": true,
"type": "module",
"imports": {

File diff suppressed because it is too large Load Diff

View File

@@ -314,6 +314,15 @@ const deletePage = async (page) => {
<NuxtLink class="admin-pages__title-link font-semibold hover:opacity-70" :to="`/admin/pages/${page.id}`">
{{ page.title }}
</NuxtLink>
<span v-if="page.isListed" class="admin-pages__listed-badge ml-2 inline-flex h-5 items-center rounded bg-[#eff1f2] px-2 text-[11px] font-semibold text-[#7c8b9a]">
홈 표시
</span>
<span v-if="page.isListed && page.listingSection" class="admin-pages__section-badge ml-1 inline-flex h-5 items-center rounded bg-white px-2 text-[11px] font-semibold text-[#7c8b9a] ring-1 ring-[#e3e6e8]">
{{ page.listingSection }}
</span>
<span v-if="page.isFeatured" class="admin-pages__featured-badge ml-1 inline-flex h-5 items-center rounded bg-[#15171a] px-2 text-[11px] font-semibold text-white">
추천
</span>
<div class="admin-pages__slug-row mt-1 flex items-center gap-1 text-xs text-muted">
<span class="admin-pages__slug truncate">/pages/{{ page.slug }}</span>
<NuxtLink

View File

@@ -23,6 +23,16 @@ import {
MIN_POST_TAG_LIMIT,
normalizePostTagLimit
} from '~/lib/post-tag-limit.js'
import {
DEFAULT_FEATURED_POST_LIMIT,
DEFAULT_HOME_LATEST_POST_LIMIT,
DEFAULT_PAGE_SECTION_ITEM_LIMIT,
DEFAULT_POST_LIST_PAGE_SIZE,
DEFAULT_TAG_POST_LIST_PAGE_SIZE,
DISPLAY_COUNT_OPTIONS,
POST_LIST_COUNT_OPTIONS,
normalizeDisplayCount
} from '~/lib/display-count-settings.js'
definePageMeta({
layout: 'admin'
@@ -110,7 +120,12 @@ const socialSnapshot = reactive({
/** 편집 시작 시점의 POST 설정(취소 시 복원용) */
const postSnapshot = reactive({
showPostUpdatedAt: false,
postTagLimit: DEFAULT_POST_TAG_LIMIT
postTagLimit: DEFAULT_POST_TAG_LIMIT,
homeLatestPostLimit: DEFAULT_HOME_LATEST_POST_LIMIT,
postListPageSize: DEFAULT_POST_LIST_PAGE_SIZE,
tagPostListPageSize: DEFAULT_TAG_POST_LIST_PAGE_SIZE,
featuredPostLimit: DEFAULT_FEATURED_POST_LIMIT,
pageSectionItemLimit: DEFAULT_PAGE_SECTION_ITEM_LIMIT
})
/** 편집 시작 시점의 메인 화면 커버(취소 시 복원용) */
const homeCoverSnapshot = reactive({
@@ -174,6 +189,11 @@ const form = reactive({
socialLinks: normalizeSocialLinks(settings.value?.socialLinks || []),
showPostUpdatedAt: Boolean(settings.value?.showPostUpdatedAt),
postTagLimit: normalizePostTagLimit(settings.value?.postTagLimit),
homeLatestPostLimit: normalizeDisplayCount(settings.value?.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT),
postListPageSize: normalizeDisplayCount(settings.value?.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE),
tagPostListPageSize: normalizeDisplayCount(settings.value?.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE),
featuredPostLimit: normalizeDisplayCount(settings.value?.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT),
pageSectionItemLimit: normalizeDisplayCount(settings.value?.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT),
homeCoverImageUrl: settings.value?.homeCoverImageUrl || '',
homeCoverDarkImageUrl: settings.value?.homeCoverDarkImageUrl || '',
homeCoverTitle: settings.value?.homeCoverTitle || '',
@@ -233,6 +253,11 @@ const hasPostChanges = computed(() => editPost.value
&& (
form.showPostUpdatedAt !== postSnapshot.showPostUpdatedAt
|| normalizePostTagLimit(form.postTagLimit) !== normalizePostTagLimit(postSnapshot.postTagLimit)
|| normalizeDisplayCount(form.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT) !== normalizeDisplayCount(postSnapshot.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT)
|| normalizeDisplayCount(form.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE) !== normalizeDisplayCount(postSnapshot.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE)
|| normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE) !== normalizeDisplayCount(postSnapshot.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
|| normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT) !== normalizeDisplayCount(postSnapshot.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
|| normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT) !== normalizeDisplayCount(postSnapshot.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
))
/**
@@ -1134,6 +1159,11 @@ const buildSiteSettingsPayload = () => ({
socialLinks: normalizeSocialLinks(form.socialLinks),
showPostUpdatedAt: Boolean(form.showPostUpdatedAt),
postTagLimit: normalizePostTagLimit(form.postTagLimit),
homeLatestPostLimit: normalizeDisplayCount(form.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT),
postListPageSize: normalizeDisplayCount(form.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE),
tagPostListPageSize: normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE),
featuredPostLimit: normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT),
pageSectionItemLimit: normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT),
homeCoverImageUrl: form.homeCoverImageUrl || '',
homeCoverDarkImageUrl: form.homeCoverDarkImageUrl || '',
homeCoverTitle: form.homeCoverTitle || '',
@@ -1364,7 +1394,17 @@ const saveSocialSection = async () => {
const beginEditPost = () => {
postSnapshot.showPostUpdatedAt = form.showPostUpdatedAt
postSnapshot.postTagLimit = normalizePostTagLimit(form.postTagLimit)
postSnapshot.homeLatestPostLimit = normalizeDisplayCount(form.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT)
postSnapshot.postListPageSize = normalizeDisplayCount(form.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE)
postSnapshot.tagPostListPageSize = normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
postSnapshot.featuredPostLimit = normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
postSnapshot.pageSectionItemLimit = normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
form.postTagLimit = normalizePostTagLimit(form.postTagLimit)
form.homeLatestPostLimit = postSnapshot.homeLatestPostLimit
form.postListPageSize = postSnapshot.postListPageSize
form.tagPostListPageSize = postSnapshot.tagPostListPageSize
form.featuredPostLimit = postSnapshot.featuredPostLimit
form.pageSectionItemLimit = postSnapshot.pageSectionItemLimit
editPost.value = true
}
@@ -1375,6 +1415,11 @@ const beginEditPost = () => {
const cancelEditPost = () => {
form.showPostUpdatedAt = postSnapshot.showPostUpdatedAt
form.postTagLimit = normalizePostTagLimit(postSnapshot.postTagLimit)
form.homeLatestPostLimit = normalizeDisplayCount(postSnapshot.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT)
form.postListPageSize = normalizeDisplayCount(postSnapshot.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE)
form.tagPostListPageSize = normalizeDisplayCount(postSnapshot.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
form.featuredPostLimit = normalizeDisplayCount(postSnapshot.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
form.pageSectionItemLimit = normalizeDisplayCount(postSnapshot.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
editPost.value = false
}
@@ -1395,7 +1440,17 @@ const savePostSection = async () => {
if (ok) {
postSnapshot.showPostUpdatedAt = form.showPostUpdatedAt
postSnapshot.postTagLimit = normalizePostTagLimit(form.postTagLimit)
postSnapshot.homeLatestPostLimit = normalizeDisplayCount(form.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT)
postSnapshot.postListPageSize = normalizeDisplayCount(form.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE)
postSnapshot.tagPostListPageSize = normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
postSnapshot.featuredPostLimit = normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
postSnapshot.pageSectionItemLimit = normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
form.postTagLimit = postSnapshot.postTagLimit
form.homeLatestPostLimit = postSnapshot.homeLatestPostLimit
form.postListPageSize = postSnapshot.postListPageSize
form.tagPostListPageSize = postSnapshot.tagPostListPageSize
form.featuredPostLimit = postSnapshot.featuredPostLimit
form.pageSectionItemLimit = postSnapshot.pageSectionItemLimit
editPost.value = false
}
}
@@ -2399,7 +2454,7 @@ onBeforeUnmount(() => {
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
>
공개 글 상세·관리자 글 목록에서 발행 후 수정이 있었을 때 수정일을 함께 표시합니다.
글쓰기에서 선택할 수 있는 태그 최대 개수도 함께 관리합니다.
글쓰기 태그 제한과 공개 목록 표시개수도 함께 관리합니다.
</p>
</div>
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
@@ -2457,6 +2512,34 @@ onBeforeUnmount(() => {
{{ normalizePostTagLimit(form.postTagLimit) }}개
</span>
</div>
<div class="mt-5 grid gap-3 border-t border-[#eceff2] pt-5">
<div class="flex items-center justify-between gap-4">
<span class="font-bold text-[#15171a]">표시개수 설정</span>
<span class="text-xs text-[#657080]">공개 목록 기준</span>
</div>
<div class="grid gap-2 text-xs text-[#657080] sm:grid-cols-2">
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
<span>Latest</span>
<strong class="font-mono text-[#15171a]">{{ normalizeDisplayCount(form.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT) }}개</strong>
</div>
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
<span>Posts</span>
<strong class="font-mono text-[#15171a]">{{ normalizeDisplayCount(form.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE) }}개</strong>
</div>
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
<span>태그 게시물</span>
<strong class="font-mono text-[#15171a]">{{ normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE) }}개</strong>
</div>
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
<span>Featured</span>
<strong class="font-mono text-[#15171a]">{{ normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT) }}개</strong>
</div>
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2 sm:col-span-2">
<span>Pages 섹션</span>
<strong class="font-mono text-[#15171a]">{{ normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT) }}개</strong>
</div>
</div>
</div>
</div>
<div
@@ -2491,6 +2574,76 @@ onBeforeUnmount(() => {
최소 {{ MIN_POST_TAG_LIMIT }}개, 최대 {{ MAX_POST_TAG_LIMIT }}개까지 설정할 수 있습니다. 기본값은 {{ DEFAULT_POST_TAG_LIMIT }}개입니다.
</span>
</label>
<div class="admin-settings-screen__field grid gap-3 border-t border-[#eceff2] pt-5">
<div>
<span class="font-bold text-[#15171a]">표시개수 설정</span>
<p class="mt-1 text-xs leading-relaxed text-[#657080]">
공개 목록에서 한 번에 불러오고 보여줄 개수를 선택합니다.
</p>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<label class="grid gap-1.5">
<span class="text-xs font-semibold text-[#657080]">Latest</span>
<span class="relative">
<select
v-model.number="form.homeLatestPostLimit"
class="h-10 w-full appearance-none rounded-md border border-[#dce0e5] bg-white px-3 pr-10 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
>
<option v-for="option in POST_LIST_COUNT_OPTIONS" :key="`latest-${option}`" :value="option">{{ option }}개</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</span>
</label>
<label class="grid gap-1.5">
<span class="text-xs font-semibold text-[#657080]">Posts</span>
<span class="relative">
<select
v-model.number="form.postListPageSize"
class="h-10 w-full appearance-none rounded-md border border-[#dce0e5] bg-white px-3 pr-10 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
>
<option v-for="option in POST_LIST_COUNT_OPTIONS" :key="`posts-${option}`" :value="option">{{ option }}개</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</span>
</label>
<label class="grid gap-1.5">
<span class="text-xs font-semibold text-[#657080]">태그 게시물</span>
<span class="relative">
<select
v-model.number="form.tagPostListPageSize"
class="h-10 w-full appearance-none rounded-md border border-[#dce0e5] bg-white px-3 pr-10 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
>
<option v-for="option in POST_LIST_COUNT_OPTIONS" :key="`tag-posts-${option}`" :value="option">{{ option }}개</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</span>
</label>
<label class="grid gap-1.5">
<span class="text-xs font-semibold text-[#657080]">Featured</span>
<span class="relative">
<select
v-model.number="form.featuredPostLimit"
class="h-10 w-full appearance-none rounded-md border border-[#dce0e5] bg-white px-3 pr-10 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
>
<option v-for="option in DISPLAY_COUNT_OPTIONS" :key="`featured-${option}`" :value="option">{{ option }}개</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</span>
</label>
<label class="grid gap-1.5 sm:col-span-2">
<span class="text-xs font-semibold text-[#657080]">Pages 섹션</span>
<span class="relative">
<select
v-model.number="form.pageSectionItemLimit"
class="h-10 w-full appearance-none rounded-md border border-[#dce0e5] bg-white px-3 pr-10 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
>
<option v-for="option in DISPLAY_COUNT_OPTIONS" :key="`pages-${option}`" :value="option">{{ option }}개</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</span>
</label>
</div>
</div>
</div>
</section>

View File

@@ -17,7 +17,7 @@ if (!page.value) {
onMounted(() => {
if (page.value?.renderMode === 'html_document') {
window.location.replace(`/pages/${page.value.slug}`)
window.location.reload()
}
})
</script>

View File

@@ -1,12 +1,51 @@
<script setup>
const { data: posts } = await useFetch('/api/posts', {
default: () => []
import {
DEFAULT_POST_LIST_PAGE_SIZE,
normalizeDisplayCount,
normalizeListPage
} from '~/lib/display-count-settings.js'
const route = useRoute()
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({})
})
const { data: tags } = await useFetch('/api/tags?tagType=all', {
default: () => []
})
const currentPage = computed(() => normalizeListPage(route.query.page))
const postListPageSize = computed(() => normalizeDisplayCount(siteSettings.value?.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE))
watch(currentPage, () => {
if (!import.meta.client) {
return
}
window.scrollTo({
top: 0,
behavior: 'smooth'
})
})
const { data: postsResponse } = await useFetch('/api/posts', {
query: computed(() => ({
paginated: '1',
page: currentPage.value,
limit: postListPageSize.value
})),
default: () => ({
items: [],
pagination: {
page: 1,
limit: DEFAULT_POST_LIST_PAGE_SIZE,
total: 0,
totalPages: 1
}
})
})
/**
* 태그 슬러그로 공개 태그 정보를 찾는다.
* @param {string | undefined} slug - 태그 슬러그
@@ -28,7 +67,14 @@ const getTagMeta = (slug) => {
}
}
const postCards = computed(() => posts.value.map((post) => {
const pagination = computed(() => postsResponse.value?.pagination || {
page: 1,
limit: postListPageSize.value,
total: 0,
totalPages: 1
})
const postCards = computed(() => (postsResponse.value?.items || []).map((post) => {
const primaryTag = getTagMeta(post.tags?.[0])
return {
@@ -45,6 +91,16 @@ const postCards = computed(() => posts.value.map((post) => {
to: `/post/${post.slug}`
}
}))
/**
* 게시물 목록 페이지 링크를 만든다.
* @param {number} page - 페이지 번호
* @returns {Object} Nuxt 링크 대상
*/
const getPostsPageTo = (page) => ({
path: '/posts',
query: page > 1 ? { page } : {}
})
</script>
<template>
@@ -61,5 +117,33 @@ const postCards = computed(() => posts.value.map((post) => {
</section>
<PostCard v-for="post in postCards" :key="post.to" :post="post" />
<nav
v-if="pagination.totalPages > 1"
class="posts-index__pagination site-section"
aria-label="게시물 페이지"
>
<div class="site-section-body mx-auto flex max-w-[720px] items-center justify-between gap-3 text-sm">
<NuxtLink
class="rounded border border-[var(--site-line)] px-3 py-2 font-semibold transition hover:bg-[var(--site-panel)]"
:class="pagination.page <= 1 ? 'pointer-events-none opacity-35' : ''"
:to="getPostsPageTo(Math.max(1, pagination.page - 1))"
:aria-disabled="pagination.page <= 1"
>
이전
</NuxtLink>
<span class="site-muted">
{{ pagination.page }} / {{ pagination.totalPages }}
</span>
<NuxtLink
class="rounded border border-[var(--site-line)] px-3 py-2 font-semibold transition hover:bg-[var(--site-panel)]"
:class="pagination.page >= pagination.totalPages ? 'pointer-events-none opacity-35' : ''"
:to="getPostsPageTo(Math.min(pagination.totalPages, pagination.page + 1))"
:aria-disabled="pagination.page >= pagination.totalPages"
>
다음
</NuxtLink>
</div>
</nav>
</MainColumn>
</template>

View File

@@ -1,4 +1,10 @@
<script setup>
import {
DEFAULT_TAG_POST_LIST_PAGE_SIZE,
normalizeDisplayCount,
normalizeListPage
} from '~/lib/display-count-settings.js'
const route = useRoute()
const slug = computed(() => String(route.params.slug || ''))
@@ -6,8 +12,40 @@ const { data: tags } = await useFetch('/api/tags?tagType=all', {
default: () => []
})
const { data: posts } = await useFetch('/api/posts', {
default: () => []
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({})
})
const currentPage = computed(() => normalizeListPage(route.query.page))
const tagPostListPageSize = computed(() => normalizeDisplayCount(siteSettings.value?.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE))
watch(currentPage, () => {
if (!import.meta.client) {
return
}
window.scrollTo({
top: 0,
behavior: 'smooth'
})
})
const { data: postsResponse } = await useFetch('/api/posts', {
query: computed(() => ({
paginated: '1',
tag: slug.value,
page: currentPage.value,
limit: tagPostListPageSize.value
})),
default: () => ({
items: [],
pagination: {
page: 1,
limit: DEFAULT_TAG_POST_LIST_PAGE_SIZE,
total: 0,
totalPages: 1
}
})
})
const tag = computed(() => tags.value.find((item) => item.slug === slug.value))
@@ -20,8 +58,14 @@ const hasTagHeaderImage = computed(() => Boolean(tag.value?.headerImageUrl))
*/
const findTagMeta = (tagSlug) => tags.value.find((item) => item.slug === tagSlug) || null
const tagPosts = computed(() => posts.value
.filter((post) => post.tags.includes(slug.value))
const pagination = computed(() => postsResponse.value?.pagination || {
page: 1,
limit: tagPostListPageSize.value,
total: 0,
totalPages: 1
})
const tagPosts = computed(() => (postsResponse.value?.items || [])
.map((post) => {
const primaryTagSlug = post.tags?.[0] || slug.value
const primaryTag = findTagMeta(primaryTagSlug)
@@ -44,6 +88,16 @@ const tagPosts = computed(() => posts.value
to: `/post/${post.slug}`
}
}))
/**
* 태그 게시물 목록 페이지 링크를 만든다.
* @param {number} page - 페이지 번호
* @returns {Object} Nuxt 링크 대상
*/
const getTagPageTo = (page) => ({
path: `/tag/${slug.value}`,
query: page > 1 ? { page } : {}
})
</script>
<template>
@@ -148,6 +202,34 @@ const tagPosts = computed(() => posts.value
</div>
</section>
<nav
v-if="pagination.totalPages > 1"
class="tag-posts-pagination site-section"
aria-label="태그 게시물 페이지"
>
<div class="site-section-body mx-auto flex max-w-[720px] items-center justify-between gap-3 text-sm">
<NuxtLink
class="rounded border border-[var(--site-line)] px-3 py-2 font-semibold transition hover:bg-[var(--site-panel)]"
:class="pagination.page <= 1 ? 'pointer-events-none opacity-35' : ''"
:to="getTagPageTo(Math.max(1, pagination.page - 1))"
:aria-disabled="pagination.page <= 1"
>
이전
</NuxtLink>
<span class="site-muted">
{{ pagination.page }} / {{ pagination.totalPages }}
</span>
<NuxtLink
class="rounded border border-[var(--site-line)] px-3 py-2 font-semibold transition hover:bg-[var(--site-panel)]"
:class="pagination.page >= pagination.totalPages ? 'pointer-events-none opacity-35' : ''"
:to="getTagPageTo(Math.min(pagination.totalPages, pagination.page + 1))"
:aria-disabled="pagination.page >= pagination.totalPages"
>
다음
</NuxtLink>
</div>
</nav>
<section v-if="tagPosts.length === 0" class="tag-posts-empty site-section">
<div class="tag-posts-empty__body site-section-body mx-auto max-w-[720px] text-sm site-muted">
이 태그에 연결된 글이 없습니다.

View File

@@ -3,16 +3,12 @@ const { data: tags } = await useFetch('/api/tags', {
default: () => []
})
const { data: posts } = await useFetch('/api/posts', {
default: () => []
})
/**
* 태그에 연결된 게시물 수 조회
* @param {string} slug - 태그 슬러그
* @param {Object} tag - 태그
* @returns {number} 게시물 수
*/
const getPostCount = (slug) => posts.value.filter((post) => post.tags.includes(slug)).length
const getPostCount = (tag) => Number(tag?.postCount || 0)
</script>
<template>
@@ -65,7 +61,7 @@ const getPostCount = (slug) => posts.value.filter((post) => post.tags.includes(s
{{ tag.description || `${tag.name} 관련 주제를 확인하세요.` }}
</p>
<span class="text-[0.8rem] font-medium leading-tight site-muted">
{{ getPostCount(tag.slug) }} posts
{{ getPostCount(tag) }} posts
</span>
<span class="pointer-events-none absolute inset-0 rounded-[12px] rounded-l-none border border-transparent transition-colors duration-200 group-hover:border-[color:var(--tag-color)] group-hover:opacity-25" />
<span class="pointer-events-none absolute inset-0 rounded-[12px] rounded-l-none opacity-0 transition-opacity duration-200 group-hover:opacity-[0.06]" :style="{ backgroundColor: tag.color }" />

View File

@@ -1,7 +1,18 @@
import { getQuery } from 'h3'
import { listPages } from '../repositories/content-repository'
import {
DEFAULT_PAGE_SECTION_ITEM_LIMIT,
normalizeDisplayCount
} from '../../lib/display-count-settings.js'
/**
* 공개 고정 페이지 목록 API
* @returns {Array} 고정 페이지 목록
*/
export default defineEventHandler(() => listPages())
export default defineEventHandler((event) => {
const query = getQuery(event)
return listPages({
listedOnly: query.listed === '1',
limitPerSection: query.limit ? normalizeDisplayCount(query.limit, DEFAULT_PAGE_SECTION_ITEM_LIMIT) : null
})
})

View File

@@ -1,6 +1,12 @@
import { getQuery } from 'h3'
import { listPosts } from '../repositories/content-repository'
import { canAccessMembershipContent } from '../repositories/member-repository'
import { getMemberSession } from '../utils/member-auth'
import {
DEFAULT_POST_LIST_PAGE_SIZE,
normalizeDisplayCount,
normalizeListPage
} from '../../lib/display-count-settings.js'
/**
* 공개 게시물 목록 API
@@ -10,6 +16,37 @@ import { getMemberSession } from '../utils/member-auth'
export default defineEventHandler(async (event) => {
const session = getMemberSession(event)
const includeMembership = await canAccessMembershipContent(session?.userId)
const query = getQuery(event)
const page = normalizeListPage(query.page)
const limit = query.limit
? normalizeDisplayCount(query.limit, DEFAULT_POST_LIST_PAGE_SIZE)
: null
const offset = limit ? (page - 1) * limit : 0
const paginated = query.paginated === '1'
return listPosts({ includeMembership })
const result = await listPosts({
includeMembership,
limit,
offset,
tagSlug: String(query.tag || '').trim(),
featuredOnly: query.featured === '1',
includeTotal: paginated
})
if (!paginated) {
return result
}
const total = Number(result.total || 0)
const pageSize = limit || DEFAULT_POST_LIST_PAGE_SIZE
return {
items: result.items,
pagination: {
page,
limit: pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize))
}
}
})

View File

@@ -21,6 +21,14 @@ import {
parseSignupBlockedUsernamesFromDb
} from '../../lib/signup-blocked-usernames.js'
import { normalizePostTagLimit } from '../../lib/post-tag-limit.js'
import {
DEFAULT_FEATURED_POST_LIMIT,
DEFAULT_HOME_LATEST_POST_LIMIT,
DEFAULT_PAGE_SECTION_ITEM_LIMIT,
DEFAULT_POST_LIST_PAGE_SIZE,
DEFAULT_TAG_POST_LIST_PAGE_SIZE,
normalizeDisplayCount
} from '../../lib/display-count-settings.js'
import { normalizeSocialLinks } from '../../lib/social-links.js'
import {
createPostThumbnailForImageUrl,
@@ -81,11 +89,31 @@ const mapPageRow = (row) => ({
renderMode: row.render_mode || 'markdown',
status: row.status || 'published',
featuredImage: row.featured_image,
isListed: Boolean(row.is_listed),
isFeatured: Boolean(row.is_featured),
listingSection: row.listing_section || '',
listingTitle: row.listing_title || '',
listingDescription: row.listing_description || '',
listingSortOrder: Number(row.listing_sort_order || 0),
totalViews: Number(row.total_views || 0),
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString()
})
/**
* 페이지 섹션 행을 API 응답 구조로 변환
* @param {Object} row - 페이지 섹션 행
* @returns {Object} 페이지 섹션 응답
*/
const mapPageSectionRow = (row) => ({
id: row.id,
name: row.name,
sortOrder: Number(row.sort_order || 0),
pageCount: Number(row.page_count || 0),
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString()
})
/**
* 태그 행을 API 응답 구조로 변환
* @param {Object} row - 태그 행
@@ -123,6 +151,11 @@ const mapSiteSettingsRow = (row) => ({
socialLinks: normalizeSocialLinks(row.social_links),
showPostUpdatedAt: Boolean(row.show_post_updated_at),
postTagLimit: normalizePostTagLimit(row.post_tag_limit),
homeLatestPostLimit: normalizeDisplayCount(row.home_latest_post_limit, DEFAULT_HOME_LATEST_POST_LIMIT),
postListPageSize: normalizeDisplayCount(row.post_list_page_size, DEFAULT_POST_LIST_PAGE_SIZE),
tagPostListPageSize: normalizeDisplayCount(row.tag_post_list_page_size, DEFAULT_TAG_POST_LIST_PAGE_SIZE),
featuredPostLimit: normalizeDisplayCount(row.featured_post_limit, DEFAULT_FEATURED_POST_LIMIT),
pageSectionItemLimit: normalizeDisplayCount(row.page_section_item_limit, DEFAULT_PAGE_SECTION_ITEM_LIMIT),
homeCoverImageUrl: row.home_cover_image_url || '',
homeCoverDarkImageUrl: row.home_cover_dark_image_url || '',
homeCoverTitle: row.home_cover_title || '',
@@ -281,14 +314,36 @@ const syncPostTags = async (sql, postId, tags, tagNames = {}) => {
/**
* 공개 게시물 목록 조회
* @param {{ includeMembership?: boolean }} [options] - VIP 전용 글 포함 여부
* @returns {Promise<Array>} 게시물 목록
* @param {{ includeMembership?: boolean, limit?: number|null, offset?: number, tagSlug?: string, featuredOnly?: boolean, includeTotal?: boolean }} [options] - 조회 옵션
* @returns {Promise<Array|{ items: Array, total: number }>} 게시물 목록
*/
export const listPosts = async ({ includeMembership = false } = {}) => {
export const listPosts = async ({
includeMembership = false,
limit = null,
offset = 0,
tagSlug = '',
featuredOnly = false,
includeTotal = false
} = {}) => {
const sql = getPostgresClient()
const resolvedLimit = Number.isInteger(limit) && limit > 0 ? limit : null
const resolvedOffset = Number.isInteger(offset) && offset > 0 ? offset : 0
const normalizedTagSlug = String(tagSlug || '').trim()
if (!sql) {
return getSamplePosts()
let samplePosts = getSamplePosts()
if (featuredOnly) {
samplePosts = samplePosts.filter((post) => Boolean(post.isFeatured))
}
if (normalizedTagSlug) {
samplePosts = samplePosts.filter((post) => post.tags.includes(normalizedTagSlug))
}
const total = samplePosts.length
const items = resolvedLimit
? samplePosts.slice(resolvedOffset, resolvedOffset + resolvedLimit)
: samplePosts
return includeTotal ? { items, total } : items
}
const rows = await sql`
@@ -318,11 +373,58 @@ export const listPosts = async ({ includeMembership = false } = {}) => {
OR posts.published_at IS NULL
OR posts.published_at <= now()
)
AND (${featuredOnly} = false OR posts.is_featured = true)
AND (
${normalizedTagSlug || null}::text IS NULL
OR EXISTS (
SELECT 1
FROM post_tags tagged_post_tags
INNER JOIN tags tagged_tags ON tagged_tags.id = tagged_post_tags.tag_id
WHERE tagged_post_tags.post_id = posts.id
AND tagged_tags.slug = ${normalizedTagSlug || null}
)
)
GROUP BY posts.id
ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC
LIMIT ${resolvedLimit || 2147483647}
OFFSET ${resolvedOffset}
`
return rows.map(mapPostRow)
const items = rows.map(mapPostRow)
if (!includeTotal) {
return items
}
const totalRows = await sql`
SELECT COUNT(*)::int AS total
FROM posts
WHERE (
posts.status = 'published'
OR (${includeMembership} = true AND posts.status = 'members')
)
AND (
posts.status = 'members'
OR posts.published_at IS NULL
OR posts.published_at <= now()
)
AND (${featuredOnly} = false OR posts.is_featured = true)
AND (
${normalizedTagSlug || null}::text IS NULL
OR EXISTS (
SELECT 1
FROM post_tags tagged_post_tags
INNER JOIN tags tagged_tags ON tagged_tags.id = tagged_post_tags.tag_id
WHERE tagged_post_tags.post_id = posts.id
AND tagged_tags.slug = ${normalizedTagSlug || null}
)
)
`
return {
items,
total: Number(totalRows[0]?.total || 0)
}
}
/**
@@ -576,20 +678,99 @@ export const getPostBySlug = async (slug, { includeMembership = false } = {}) =>
/**
* 공개 고정 페이지 목록 조회
* @param {{ listedOnly?: boolean, limitPerSection?: number|null }} [options] - 조회 옵션
* @returns {Promise<Array>} 고정 페이지 목록
*/
export const listPages = async () => {
export const listPages = async (options = {}) => {
const sql = getPostgresClient()
const listedOnly = Boolean(options.listedOnly)
const limitPerSection = Number.isInteger(options.limitPerSection) && options.limitPerSection > 0
? options.limitPerSection
: null
if (!sql) {
return getSamplePages()
const pages = getSamplePages().filter((page) => page.status === 'published')
if (!listedOnly) {
return pages
}
const sectionCounts = new Map()
return pages
.filter((page) => page.isListed)
.filter((page) => {
if (!limitPerSection) {
return true
}
const sectionName = String(page.listingSection || '').trim() || 'Pages'
const count = Number(sectionCounts.get(sectionName) || 0) + 1
sectionCounts.set(sectionName, count)
return count <= limitPerSection
})
.map((page) => ({ ...page, content: '' }))
}
if (listedOnly) {
const rows = await sql`
SELECT *
FROM (
SELECT
pages.id,
pages.title,
pages.slug,
'' AS content,
pages.render_mode,
pages.status,
pages.featured_image,
pages.is_listed,
pages.is_featured,
pages.listing_section,
pages.listing_title,
pages.listing_description,
pages.listing_sort_order,
pages.created_at,
pages.updated_at,
COALESCE(page_listing_sections.sort_order, 9999) AS section_sort_order,
row_number() OVER (
PARTITION BY COALESCE(NULLIF(pages.listing_section, ''), 'Pages')
ORDER BY
pages.is_featured DESC,
pages.listing_sort_order ASC,
pages.updated_at DESC,
pages.created_at DESC
) AS section_row_number
FROM pages
LEFT JOIN page_listing_sections
ON page_listing_sections.name = pages.listing_section
WHERE pages.status = 'published'
AND pages.is_listed = true
) listed_pages
WHERE (${limitPerSection || null}::int IS NULL OR listed_pages.section_row_number <= ${limitPerSection || null})
ORDER BY
listed_pages.section_sort_order ASC,
listed_pages.listing_section ASC,
listed_pages.is_featured DESC,
listed_pages.listing_sort_order ASC,
listed_pages.updated_at DESC,
listed_pages.created_at DESC
`
return rows.map(mapPageRow)
}
const rows = await sql`
SELECT *
SELECT pages.*
FROM pages
LEFT JOIN page_listing_sections
ON page_listing_sections.name = pages.listing_section
WHERE status = 'published'
ORDER BY created_at DESC
AND (${listedOnly} = false OR is_listed = true)
ORDER BY
COALESCE(page_listing_sections.sort_order, 9999) ASC,
listing_section ASC,
is_featured DESC,
listing_sort_order ASC,
updated_at DESC,
created_at DESC
`
return rows.map(mapPageRow)
@@ -643,6 +824,246 @@ export const getAdminPageById = async (id) => {
return rows[0] ? mapPageRow(rows[0]) : null
}
/**
* 관리자 페이지 섹션 목록 조회
* @returns {Promise<Array>} 페이지 섹션 목록
*/
export const listAdminPageSections = async () => {
const sql = getPostgresClient()
if (!sql) {
const sections = new Map()
for (const page of getSamplePages()) {
const name = String(page.listingSection || '').trim()
if (!name) {
continue
}
sections.set(name, {
id: `sample-page-section-${sections.size + 1}`,
name,
sortOrder: sections.size * 10,
pageCount: (sections.get(name)?.pageCount || 0) + 1,
createdAt: page.createdAt,
updatedAt: page.updatedAt
})
}
return [...sections.values()]
}
const rows = await sql`
SELECT
page_listing_sections.*,
(
SELECT COUNT(*)::int
FROM pages
WHERE pages.listing_section = page_listing_sections.name
) AS page_count
FROM page_listing_sections
ORDER BY sort_order ASC, name ASC
`
return rows.map(mapPageSectionRow)
}
/**
* 관리자 페이지 섹션 생성
* @param {string} name - 섹션명
* @returns {Promise<Object>} 생성된 페이지 섹션
*/
export const createAdminPageSection = async (name) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
INSERT INTO page_listing_sections (name, sort_order)
VALUES (
${name},
COALESCE((SELECT MAX(sort_order) + 10 FROM page_listing_sections), 0)
)
RETURNING *, 0::int AS page_count
`
return mapPageSectionRow(rows[0])
}
/**
* 관리자 페이지 섹션 수정
* @param {string} id - 섹션 ID
* @param {{ name: string }} input - 수정 입력값
* @returns {Promise<Object | null>} 수정된 페이지 섹션
*/
export const updateAdminPageSection = async (id, input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const currentRows = await sql`
SELECT *
FROM page_listing_sections
WHERE id = ${id}
LIMIT 1
`
if (!currentRows[0]) {
return null
}
await sql.begin(async (transaction) => {
await transaction`
UPDATE page_listing_sections
SET name = ${input.name}, updated_at = now()
WHERE id = ${id}
`
await transaction`
UPDATE pages
SET listing_section = ${input.name}, updated_at = now()
WHERE listing_section = ${currentRows[0].name}
`
})
const rows = await sql`
SELECT
page_listing_sections.*,
(
SELECT COUNT(*)::int
FROM pages
WHERE pages.listing_section = page_listing_sections.name
) AS page_count
FROM page_listing_sections
WHERE id = ${id}
LIMIT 1
`
return rows[0] ? mapPageSectionRow(rows[0]) : null
}
/**
* 관리자 페이지 섹션 삭제
* @param {string} id - 섹션 ID
* @returns {Promise<boolean>} 삭제 여부
*/
export const deleteAdminPageSection = async (id) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
DELETE FROM page_listing_sections
WHERE id = ${id}
AND NOT EXISTS (
SELECT 1
FROM pages
WHERE pages.listing_section = page_listing_sections.name
)
RETURNING id
`
return Boolean(rows[0])
}
/**
* 관리자 페이지 섹션 정렬 저장
* @param {Array<string>} sectionIds - 섹션 ID 목록
* @returns {Promise<Array>} 정렬 저장 후 섹션 목록
*/
export const reorderAdminPageSections = async (sectionIds) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
await sql.begin(async (transaction) => {
for (const [index, sectionId] of sectionIds.entries()) {
await transaction`
UPDATE page_listing_sections
SET sort_order = ${index * 10}, updated_at = now()
WHERE id = ${sectionId}
`
}
})
return listAdminPageSections()
}
/**
* 관리자 페이지 섹션 내부 페이지 정렬 저장
* @param {string} sectionId - 페이지 섹션 ID
* @param {Array<string>} pageIds - 정렬할 페이지 ID 목록
* @param {Array<string>} removedPageIds - 섹션에서 제거할 페이지 ID 목록
* @returns {Promise<Array>} 정렬 저장 후 페이지 목록
*/
export const reorderAdminPageSectionPages = async (sectionId, pageIds, removedPageIds = []) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const sectionRows = await sql`
SELECT *
FROM page_listing_sections
WHERE id = ${sectionId}
LIMIT 1
`
if (!sectionRows[0]) {
return null
}
const removedIdSet = new Set(removedPageIds.map((id) => String(id)))
const nextPageIds = [...new Set(pageIds.map((id) => String(id)).filter((id) => !removedIdSet.has(id)))]
const nextRemovedPageIds = [...removedIdSet]
const targetPageIds = [...new Set([...nextPageIds, ...nextRemovedPageIds])]
if (targetPageIds.length === 0) {
return listAdminPages()
}
const pageRows = await sql`
SELECT id
FROM pages
WHERE listing_section = ${sectionRows[0].name}
AND is_listed = true
AND id = ANY(${targetPageIds})
`
if (pageRows.length !== targetPageIds.length) {
throw new Error('PAGE_SECTION_PAGE_MISMATCH')
}
await sql.begin(async (transaction) => {
if (nextRemovedPageIds.length > 0) {
await transaction`
UPDATE pages
SET listing_section = '', listing_sort_order = 0, updated_at = now()
WHERE listing_section = ${sectionRows[0].name}
AND is_listed = true
AND id = ANY(${nextRemovedPageIds})
`
}
for (const [index, pageId] of nextPageIds.entries()) {
await transaction`
UPDATE pages
SET listing_sort_order = ${index * 10}, updated_at = now()
WHERE id = ${pageId}
AND listing_section = ${sectionRows[0].name}
AND is_listed = true
`
}
})
return listAdminPages()
}
/**
* 관리자 고정 페이지 생성
* @param {Object} input - 페이지 입력값
@@ -662,6 +1083,12 @@ export const createAdminPage = async (input) => {
content,
render_mode,
featured_image,
is_listed,
is_featured,
listing_section,
listing_title,
listing_description,
listing_sort_order,
status
)
VALUES (
@@ -670,6 +1097,12 @@ export const createAdminPage = async (input) => {
${input.content},
${input.renderMode},
${input.featuredImage},
${input.isListed},
${input.isFeatured},
${input.listingSection},
${input.listingTitle},
${input.listingDescription},
${input.listingSortOrder},
${input.status}
)
RETURNING *
@@ -699,6 +1132,12 @@ export const updateAdminPage = async (id, input) => {
content = ${input.content},
render_mode = ${input.renderMode},
featured_image = ${input.featuredImage},
is_listed = ${input.isListed},
is_featured = ${input.isFeatured},
listing_section = ${input.listingSection},
listing_title = ${input.listingTitle},
listing_description = ${input.listingDescription},
listing_sort_order = ${input.listingSortOrder},
status = ${input.status},
updated_at = now()
WHERE id = ${id}
@@ -788,11 +1227,16 @@ export const listTags = async ({ tagType, searchQuery = '', limit } = {}) => {
const rows = await sql`
SELECT
tags.*,
COUNT(post_tags.post_id)::int AS post_count,
COUNT(posts.id)::int AS post_count,
MAX(posts.updated_at) AS last_used_at
FROM tags
LEFT JOIN post_tags ON post_tags.tag_id = tags.id
LEFT JOIN posts ON posts.id = post_tags.post_id
AND posts.status = 'published'
AND (
posts.published_at IS NULL
OR posts.published_at <= now()
)
WHERE (${tagType || null}::text IS NULL OR tag_type = ${tagType || null})
AND (
${trimmedSearchQuery || null}::text IS NULL
@@ -821,6 +1265,7 @@ export const listAdminTags = async (options = {}) => listTags(options)
const SEARCH_TAG_LIMIT = 12
const SEARCH_POST_LIMIT = 12
const SEARCH_PAGE_LIMIT = 8
const SEARCH_POST_CANDIDATE_LIMIT = 48
/**
@@ -839,12 +1284,12 @@ const normalizeSearchContent = (value) => String(value || '')
/**
* 공개 검색: 태그·게시물 제목·요약·본문에서 부분 일치(대소문자 무시)
* @param {string} rawQuery - 검색어
* @returns {Promise<{ tags: Array<{ name: string, slug: string }>, posts: Array<{ slug: string, title: string, excerpt: string }> }>} 태그·게시물 요약 결과
* @returns {Promise<{ tags: Array<{ name: string, slug: string }>, posts: Array<{ slug: string, title: string, excerpt: string }>, pages: Array<{ slug: string, title: string, excerpt: string }> }>} 검색 요약 결과
*/
export const searchPublicContent = async (rawQuery) => {
const q = String(rawQuery || '').trim()
if (!q) {
return { tags: [], posts: [] }
return { tags: [], posts: [], pages: [] }
}
const sql = getPostgresClient()
@@ -872,7 +1317,19 @@ export const searchPublicContent = async (rawQuery) => {
name: tag.name,
slug: tag.slug
}))
return { tags, posts }
const pages = getSamplePages()
.filter((page) => {
const hay = normalizeSearchContent(`${page.listingSection || ''}\n${page.listingTitle || page.title}\n${page.listingDescription || ''}\n${page.content || ''}`).toLowerCase()
return page.isListed && hay.includes(needle)
})
.slice(0, SEARCH_PAGE_LIMIT)
.map((page) => ({
slug: page.slug,
title: page.listingTitle || page.title,
excerpt: page.listingDescription || ''
}))
return { tags, posts, pages }
}
const tagRows = await sql`
@@ -899,6 +1356,22 @@ export const searchPublicContent = async (rawQuery) => {
LIMIT ${SEARCH_POST_CANDIDATE_LIMIT}
`
const pageRows = await sql`
SELECT slug, title, content, listing_section, listing_title, listing_description
FROM pages
WHERE status = 'published'
AND is_listed = true
AND (
position(lower(${q}) in lower(title)) > 0
OR position(lower(${q}) in lower(coalesce(listing_section, ''))) > 0
OR position(lower(${q}) in lower(coalesce(listing_title, ''))) > 0
OR position(lower(${q}) in lower(coalesce(listing_description, ''))) > 0
OR position(lower(${q}) in lower(content)) > 0
)
ORDER BY is_featured DESC, listing_section ASC, listing_sort_order ASC, updated_at DESC
LIMIT ${SEARCH_PAGE_LIMIT}
`
const needle = q.toLowerCase()
const posts = postRows
.filter((row) => {
@@ -913,6 +1386,11 @@ export const searchPublicContent = async (rawQuery) => {
slug: row.slug,
title: row.title,
excerpt: row.excerpt || ''
})),
pages: pageRows.map((row) => ({
slug: row.slug,
title: row.listing_title || row.title,
excerpt: row.listing_description || ''
}))
}
}
@@ -963,6 +1441,11 @@ export const updateSiteSettings = async (input) => {
social_links,
show_post_updated_at,
post_tag_limit,
home_latest_post_limit,
post_list_page_size,
tag_post_list_page_size,
featured_post_limit,
page_section_item_limit,
home_cover_image_url,
home_cover_dark_image_url,
home_cover_title,
@@ -998,6 +1481,11 @@ export const updateSiteSettings = async (input) => {
${JSON.stringify(normalizeSocialLinks(input.socialLinks))}::jsonb,
${input.showPostUpdatedAt ? true : false},
${normalizePostTagLimit(input.postTagLimit)},
${normalizeDisplayCount(input.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT)},
${normalizeDisplayCount(input.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE)},
${normalizeDisplayCount(input.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)},
${normalizeDisplayCount(input.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)},
${normalizeDisplayCount(input.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)},
${input.homeCoverImageUrl || ''},
${input.homeCoverDarkImageUrl || ''},
${input.homeCoverTitle || ''},
@@ -1033,6 +1521,11 @@ export const updateSiteSettings = async (input) => {
social_links = EXCLUDED.social_links,
show_post_updated_at = EXCLUDED.show_post_updated_at,
post_tag_limit = EXCLUDED.post_tag_limit,
home_latest_post_limit = EXCLUDED.home_latest_post_limit,
post_list_page_size = EXCLUDED.post_list_page_size,
tag_post_list_page_size = EXCLUDED.tag_post_list_page_size,
featured_post_limit = EXCLUDED.featured_post_limit,
page_section_item_limit = EXCLUDED.page_section_item_limit,
home_cover_image_url = EXCLUDED.home_cover_image_url,
home_cover_dark_image_url = EXCLUDED.home_cover_dark_image_url,
home_cover_title = EXCLUDED.home_cover_title,

View File

@@ -0,0 +1,13 @@
import { requireAdminSession } from '../../../utils/admin-auth'
import { listAdminPageSections } from '../../../repositories/content-repository'
/**
* 관리자 페이지 섹션 목록 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Array>} 페이지 섹션 목록
*/
export default defineEventHandler((event) => {
requireAdminSession(event)
return listAdminPageSections()
})

View File

@@ -0,0 +1,38 @@
import { createError, readBody } from 'h3'
import { z } from 'zod'
import { requireAdminSession } from '../../../utils/admin-auth'
import { createAdminPageSection } from '../../../repositories/content-repository'
const pageSectionInputSchema = z.object({
name: z.string().trim().min(1).max(80)
})
/**
* 관리자 페이지 섹션 생성 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 생성된 페이지 섹션
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const parsedBody = pageSectionInputSchema.safeParse(await readBody(event))
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '페이지 섹션 입력 형식이 올바르지 않습니다.'
})
}
try {
return await createAdminPageSection(parsedBody.data.name)
} catch (error) {
if (error?.code === '23505') {
throw createError({
statusCode: 409,
message: '이미 사용 중인 페이지 섹션입니다.'
})
}
throw error
}
})

View File

@@ -0,0 +1,23 @@
import { createError } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { deleteAdminPageSection } from '../../../../repositories/content-repository'
/**
* 관리자 페이지 섹션 삭제 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ ok: true }} 삭제 결과
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const id = String(event.context.params?.id || '')
const deleted = await deleteAdminPageSection(id)
if (!deleted) {
throw createError({
statusCode: 409,
message: '사용 중인 페이지 섹션은 삭제할 수 없습니다.'
})
}
return { ok: true }
})

View File

@@ -0,0 +1,48 @@
import { createError, readBody } from 'h3'
import { z } from 'zod'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { updateAdminPageSection } from '../../../../repositories/content-repository'
const pageSectionInputSchema = z.object({
name: z.string().trim().min(1).max(80)
})
/**
* 관리자 페이지 섹션 수정 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 수정된 페이지 섹션
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const id = String(event.context.params?.id || '')
const parsedBody = pageSectionInputSchema.safeParse(await readBody(event))
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '페이지 섹션 입력 형식이 올바르지 않습니다.'
})
}
try {
const section = await updateAdminPageSection(id, parsedBody.data)
if (!section) {
throw createError({
statusCode: 404,
message: '페이지 섹션을 찾을 수 없습니다.'
})
}
return section
} catch (error) {
if (error?.code === '23505') {
throw createError({
statusCode: 409,
message: '이미 사용 중인 페이지 섹션입니다.'
})
}
throw error
}
})

View File

@@ -0,0 +1,55 @@
import { createError, readBody } from 'h3'
import { z } from 'zod'
import { reorderAdminPageSectionPages } from '../../../../../../repositories/content-repository'
import { requireAdminSession } from '../../../../../../utils/admin-auth'
const reorderPageSectionPagesSchema = z.object({
pageIds: z.array(z.string().uuid()),
removedPageIds: z.array(z.string().uuid()).default([])
})
/**
* 관리자 페이지 섹션 내부 페이지 정렬 저장 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Array>} 정렬 저장 후 페이지 목록
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const sectionId = String(event.context.params?.id || '').trim()
const body = await readBody(event)
const parsed = reorderPageSectionPagesSchema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 400,
message: '페이지 순서 요청 형식이 올바르지 않습니다.'
})
}
try {
const pages = await reorderAdminPageSectionPages(sectionId, parsed.data.pageIds, parsed.data.removedPageIds)
if (!pages) {
throw createError({
statusCode: 404,
message: '페이지 섹션을 찾을 수 없습니다.'
})
}
return pages
} catch (error) {
if (error?.statusCode) {
throw error
}
if (error?.message === 'PAGE_SECTION_PAGE_MISMATCH') {
throw createError({
statusCode: 400,
message: '해당 섹션에 속한 페이지 목록만 정렬할 수 있습니다.'
})
}
throw error
}
})

View File

@@ -0,0 +1,27 @@
import { createError, readBody } from 'h3'
import { z } from 'zod'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { reorderAdminPageSections } from '../../../../repositories/content-repository'
const reorderPageSectionsSchema = z.object({
sectionIds: z.array(z.string().uuid()).min(1)
})
/**
* 관리자 페이지 섹션 정렬 저장 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Array>} 정렬 저장 후 페이지 섹션 목록
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const parsedBody = reorderPageSectionsSchema.safeParse(await readBody(event))
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '페이지 섹션 정렬 요청 형식이 올바르지 않습니다.'
})
}
return reorderAdminPageSections(parsedBody.data.sectionIds)
})

View File

@@ -8,9 +8,16 @@ export const adminPageInputSchema = z.object({
renderMode: z.enum(['markdown', 'html_document']).default('html_document'),
status: pageStatusSchema.default('published'),
content: z.string().default(''),
featuredImage: z.string().trim().nullable().default(null)
featuredImage: z.string().trim().nullable().default(null),
isListed: z.boolean().optional().default(false),
isFeatured: z.boolean().optional().default(false),
listingSection: z.string().trim().max(80).optional().default(''),
listingTitle: z.string().trim().max(160).optional().default(''),
listingDescription: z.string().trim().max(500).optional().default(''),
listingSortOrder: z.number().int().min(0).max(9999).optional().default(0)
}).transform((input) => ({
...input,
isFeatured: input.isListed ? input.isFeatured : false,
content: input.renderMode === 'html_document'
? input.content
: normalizeMarkdownContent(input.content)

View File

@@ -25,6 +25,16 @@ import {
MIN_POST_TAG_LIMIT,
normalizePostTagLimit
} from '../../lib/post-tag-limit.js'
import {
DEFAULT_FEATURED_POST_LIMIT,
DEFAULT_HOME_LATEST_POST_LIMIT,
DEFAULT_PAGE_SECTION_ITEM_LIMIT,
DEFAULT_POST_LIST_PAGE_SIZE,
DEFAULT_TAG_POST_LIST_PAGE_SIZE,
MAX_DISPLAY_COUNT,
MIN_DISPLAY_COUNT,
normalizeDisplayCount
} from '../../lib/display-count-settings.js'
export const adminSiteSettingsInputSchema = z.object({
title: z.string().trim().min(1),
@@ -37,6 +47,11 @@ export const adminSiteSettingsInputSchema = z.object({
socialLinks: z.unknown().optional().default([]),
showPostUpdatedAt: z.boolean().optional().default(false),
postTagLimit: z.coerce.number().int().min(MIN_POST_TAG_LIMIT).max(MAX_POST_TAG_LIMIT).optional().default(DEFAULT_POST_TAG_LIMIT),
homeLatestPostLimit: z.coerce.number().int().min(MIN_DISPLAY_COUNT).max(MAX_DISPLAY_COUNT).optional().default(DEFAULT_HOME_LATEST_POST_LIMIT),
postListPageSize: z.coerce.number().int().min(MIN_DISPLAY_COUNT).max(MAX_DISPLAY_COUNT).optional().default(DEFAULT_POST_LIST_PAGE_SIZE),
tagPostListPageSize: z.coerce.number().int().min(MIN_DISPLAY_COUNT).max(MAX_DISPLAY_COUNT).optional().default(DEFAULT_TAG_POST_LIST_PAGE_SIZE),
featuredPostLimit: z.coerce.number().int().min(MIN_DISPLAY_COUNT).max(MAX_DISPLAY_COUNT).optional().default(DEFAULT_FEATURED_POST_LIMIT),
pageSectionItemLimit: z.coerce.number().int().min(MIN_DISPLAY_COUNT).max(MAX_DISPLAY_COUNT).optional().default(DEFAULT_PAGE_SECTION_ITEM_LIMIT),
homeCoverImageUrl: z.string().trim().max(500).optional().default(''),
homeCoverDarkImageUrl: z.string().trim().max(500).optional().default(''),
homeCoverTitle: z.string().trim().max(120).optional().default(''),
@@ -89,6 +104,11 @@ export const adminSiteSettingsInputSchema = z.object({
brandColor: normalizeBrandColor(data.brandColor),
socialLinks: normalizeSocialLinks(data.socialLinks),
postTagLimit: normalizePostTagLimit(data.postTagLimit),
homeLatestPostLimit: normalizeDisplayCount(data.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT),
postListPageSize: normalizeDisplayCount(data.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE),
tagPostListPageSize: normalizeDisplayCount(data.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE),
featuredPostLimit: normalizeDisplayCount(data.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT),
pageSectionItemLimit: normalizeDisplayCount(data.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT),
announcementUrl: normalizeAnnouncementUrl(data.announcementUrl),
announcementBackgroundColor: normalizeAnnouncementBackgroundColor(data.announcementBackgroundColor),
announcementAlignment: normalizeAnnouncementAlignment(data.announcementAlignment),

View File

@@ -32,6 +32,21 @@ export const pageSchema = z.object({
renderMode: z.enum(['markdown', 'html_document']).default('markdown'),
status: pageStatusSchema.default('published'),
featuredImage: z.string().nullable().default(null),
isListed: z.boolean().default(false),
isFeatured: z.boolean().default(false),
listingSection: z.string().default(''),
listingTitle: z.string().default(''),
listingDescription: z.string().default(''),
listingSortOrder: z.number().int().default(0),
createdAt: z.string(),
updatedAt: z.string()
})
export const pageSectionSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
sortOrder: z.number().int().default(0),
pageCount: z.number().int().default(0),
createdAt: z.string(),
updatedAt: z.string()
})

View File

@@ -39,6 +39,12 @@ const samplePages = [
content: 'sori.studio 소개 페이지입니다.',
renderMode: 'markdown',
featuredImage: null,
isListed: true,
isFeatured: true,
listingSection: '소개',
listingTitle: 'About',
listingDescription: 'sori.studio 소개와 운영 방향을 정리한 페이지입니다.',
listingSortOrder: 10,
createdAt: now,
updatedAt: now
}

View File

@@ -5,6 +5,13 @@ import {
} from '../../lib/announcement-bar.js'
import { DEFAULT_SIGNUP_BLOCKED_USERNAMES } from '../../lib/signup-blocked-usernames.js'
import { DEFAULT_POST_TAG_LIMIT } from '../../lib/post-tag-limit.js'
import {
DEFAULT_FEATURED_POST_LIMIT,
DEFAULT_HOME_LATEST_POST_LIMIT,
DEFAULT_PAGE_SECTION_ITEM_LIMIT,
DEFAULT_POST_LIST_PAGE_SIZE,
DEFAULT_TAG_POST_LIST_PAGE_SIZE
} from '../../lib/display-count-settings.js'
/**
* 기본 사이트 설정 반환
@@ -25,6 +32,11 @@ export const getDefaultSiteSettings = () => {
socialLinks: [],
showPostUpdatedAt: false,
postTagLimit: DEFAULT_POST_TAG_LIMIT,
homeLatestPostLimit: DEFAULT_HOME_LATEST_POST_LIMIT,
postListPageSize: DEFAULT_POST_LIST_PAGE_SIZE,
tagPostListPageSize: DEFAULT_TAG_POST_LIST_PAGE_SIZE,
featuredPostLimit: DEFAULT_FEATURED_POST_LIMIT,
pageSectionItemLimit: DEFAULT_PAGE_SECTION_ITEM_LIMIT,
homeCoverImageUrl: '',
homeCoverDarkImageUrl: '',
homeCoverTitle: '',