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

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

@@ -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,