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