관리자 글 목록 서버 페이지네이션 전환

This commit is contained in:
2026-07-03 11:10:44 +09:00
parent 94782d5873
commit 955ae90697
10 changed files with 446 additions and 90 deletions

View File

@@ -107,6 +107,62 @@ const mapAdminPostSummaryRow = (row) => ({
tags: row.tags || []
})
/**
* 관리자 글 목록 페이지 표시 개수를 정규화한다.
* @param {unknown} value - 요청 표시 개수
* @returns {number} 표시 개수
*/
const normalizeAdminPostPageSize = (value) => {
const pageSize = Number(value) || 50
if ([20, 50, 100].includes(pageSize)) {
return pageSize
}
return 50
}
/**
* 관리자 글 목록 페이지 번호를 정규화한다.
* @param {unknown} value - 요청 페이지
* @returns {number} 페이지 번호
*/
const normalizeAdminPostPage = (value) => Math.max(1, Number(value) || 1)
/**
* 관리자 글 목록 상태 필터를 정규화한다.
* @param {unknown} value - 요청 상태 필터
* @returns {'all'|'published'|'draft'|'scheduled'|'members'|'private'} 상태 필터
*/
const normalizeAdminPostStatusFilter = (value) => {
const status = String(value || 'all')
return ['all', 'published', 'draft', 'scheduled', 'members', 'private'].includes(status)
? status
: 'all'
}
/**
* 관리자 글 목록 추천 필터를 정규화한다.
* @param {unknown} value - 요청 추천 필터
* @returns {'all'|'featured'} 추천 필터
*/
const normalizeAdminPostFeaturedFilter = (value) => String(value || 'all') === 'featured' ? 'featured' : 'all'
/**
* 관리자 글 목록 정렬을 정규화한다.
* @param {unknown} value - 요청 정렬
* @returns {'newest'|'oldest'} 정렬
*/
const normalizeAdminPostSortOrder = (value) => String(value || 'newest') === 'oldest' ? 'oldest' : 'newest'
/**
* 관리자 글 목록 검색 문자열을 정규화한다.
* @param {unknown} value - 요청 검색어
* @returns {string} 검색어
*/
const normalizeAdminPostSearchQuery = (value) => String(value || '').trim()
/**
* 고정 페이지 행을 API 응답 구조로 변환
* @param {Object} row - 고정 페이지 행
@@ -463,16 +519,87 @@ export const listPosts = async ({
/**
* 관리자 게시물 목록 요약 조회
* @returns {Promise<Array>} 관리자 게시물 목록 요약
* @param {Object} [options] - 조회 옵션
* @param {boolean} [options.paged] - 페이지 응답 사용 여부
* @param {number} [options.page] - 페이지 번호
* @param {number} [options.pageSize] - 페이지당 개수
* @param {string} [options.searchQuery] - 검색어
* @param {string} [options.status] - 상태 필터
* @param {string} [options.tagSlug] - 태그 슬러그
* @param {string} [options.featured] - 추천 필터
* @param {string} [options.sortOrder] - 정렬
* @returns {Promise<Array|Object>} 관리자 게시물 목록 요약
*/
export const listAdminPosts = async () => {
export const listAdminPosts = async (options = {}) => {
const sql = getPostgresClient()
const paged = Boolean(options.paged)
const page = normalizeAdminPostPage(options.page)
const pageSize = normalizeAdminPostPageSize(options.pageSize)
const offset = (page - 1) * pageSize
const normalizedSearchQuery = normalizeAdminPostSearchQuery(options.searchQuery)
const searchPattern = normalizedSearchQuery ? `%${normalizedSearchQuery}%` : null
const normalizedStatus = normalizeAdminPostStatusFilter(options.status)
const normalizedTagSlug = String(options.tagSlug || '').trim()
const normalizedFeatured = normalizeAdminPostFeaturedFilter(options.featured)
const normalizedSortOrder = normalizeAdminPostSortOrder(options.sortOrder)
if (!sql) {
return getSamplePosts().map((post) => {
let samplePosts = getSamplePosts().map((post) => {
const { content, ...summary } = post
return summary
})
samplePosts = samplePosts.filter((post) => {
const sortAt = post.publishedAt || post.updatedAt || post.createdAt
const isPublic = post.status === 'published' && (!post.publishedAt || new Date(post.publishedAt) <= new Date())
const statusKey = post.status === 'members' || post.status === 'private'
? post.status
: post.status === 'published' && !isPublic
? 'scheduled'
: post.status === 'published'
? 'published'
: 'draft'
const matchesStatus = normalizedStatus === 'all' || statusKey === normalizedStatus
const matchesTag = !normalizedTagSlug || (post.tags || []).includes(normalizedTagSlug)
const matchesFeatured = normalizedFeatured !== 'featured' || post.isFeatured
const searchText = [
post.title,
post.slug,
post.excerpt,
(post.tags || []).join(' ')
].join(' ').toLowerCase()
const matchesSearch = !normalizedSearchQuery || searchText.includes(normalizedSearchQuery.toLowerCase())
post.sortAt = sortAt
return matchesStatus && matchesTag && matchesFeatured && matchesSearch
})
samplePosts.sort((left, right) => {
const leftTime = new Date(left.sortAt || 0).getTime()
const rightTime = new Date(right.sortAt || 0).getTime()
return normalizedSortOrder === 'oldest' ? leftTime - rightTime : rightTime - leftTime
})
if (!paged) {
return samplePosts.map(({ sortAt, ...post }) => post)
}
const total = samplePosts.length
const items = samplePosts.slice(offset, offset + pageSize).map(({ sortAt, ...post }) => post)
return {
items,
pagination: {
page,
pageSize,
total,
pageCount: Math.max(1, Math.ceil(total / pageSize))
},
counts: {
total: getSamplePosts().length,
featured: getSamplePosts().filter((post) => post.isFeatured).length
}
}
}
const rows = await sql`
@@ -504,6 +631,46 @@ export const listAdminPosts = async () => {
FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id
LEFT JOIN tags ON tags.id = post_tags.tag_id
WHERE (
${normalizedStatus} = 'all'
OR (
${normalizedStatus} = 'published'
AND posts.status = 'published'
AND (posts.published_at IS NULL OR posts.published_at <= now())
)
OR (
${normalizedStatus} = 'scheduled'
AND posts.status = 'published'
AND posts.published_at > now()
)
OR (${normalizedStatus} = 'draft' AND posts.status = 'draft')
OR (${normalizedStatus} = 'members' AND posts.status = 'members')
OR (${normalizedStatus} = 'private' AND posts.status = 'private')
)
AND (${normalizedFeatured} <> 'featured' OR posts.is_featured = true)
AND (
${normalizedTagSlug || null}::text IS NULL
OR EXISTS (
SELECT 1
FROM post_tags filtered_post_tags
INNER JOIN tags filtered_tags ON filtered_tags.id = filtered_post_tags.tag_id
WHERE filtered_post_tags.post_id = posts.id
AND filtered_tags.slug = ${normalizedTagSlug || null}
)
)
AND (
${searchPattern}::text IS NULL
OR posts.title ILIKE ${searchPattern}
OR posts.slug ILIKE ${searchPattern}
OR posts.excerpt ILIKE ${searchPattern}
OR EXISTS (
SELECT 1
FROM post_tags search_post_tags
INNER JOIN tags search_tags ON search_tags.id = search_post_tags.tag_id
WHERE search_post_tags.post_id = posts.id
AND (search_tags.name ILIKE ${searchPattern} OR search_tags.slug ILIKE ${searchPattern})
)
)
GROUP BY
posts.id,
posts.title,
@@ -517,10 +684,84 @@ export const listAdminPosts = async () => {
posts.published_at,
posts.created_at,
posts.updated_at
ORDER BY COALESCE(posts.published_at, posts.updated_at) DESC
ORDER BY
CASE WHEN ${normalizedSortOrder} = 'oldest' THEN COALESCE(posts.published_at, posts.updated_at) END ASC,
CASE WHEN ${normalizedSortOrder} <> 'oldest' THEN COALESCE(posts.published_at, posts.updated_at) END DESC,
posts.id DESC
LIMIT ${paged ? pageSize : 100000}
OFFSET ${paged ? offset : 0}
`
return rows.map(mapAdminPostSummaryRow)
if (!paged) {
return rows.map(mapAdminPostSummaryRow)
}
const countsRows = await sql`
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE posts.is_featured = true)::int AS featured
FROM posts
`
const totalRows = await sql`
SELECT COUNT(*)::int AS total
FROM posts
WHERE (
${normalizedStatus} = 'all'
OR (
${normalizedStatus} = 'published'
AND posts.status = 'published'
AND (posts.published_at IS NULL OR posts.published_at <= now())
)
OR (
${normalizedStatus} = 'scheduled'
AND posts.status = 'published'
AND posts.published_at > now()
)
OR (${normalizedStatus} = 'draft' AND posts.status = 'draft')
OR (${normalizedStatus} = 'members' AND posts.status = 'members')
OR (${normalizedStatus} = 'private' AND posts.status = 'private')
)
AND (${normalizedFeatured} <> 'featured' OR posts.is_featured = true)
AND (
${normalizedTagSlug || null}::text IS NULL
OR EXISTS (
SELECT 1
FROM post_tags filtered_post_tags
INNER JOIN tags filtered_tags ON filtered_tags.id = filtered_post_tags.tag_id
WHERE filtered_post_tags.post_id = posts.id
AND filtered_tags.slug = ${normalizedTagSlug || null}
)
)
AND (
${searchPattern}::text IS NULL
OR posts.title ILIKE ${searchPattern}
OR posts.slug ILIKE ${searchPattern}
OR posts.excerpt ILIKE ${searchPattern}
OR EXISTS (
SELECT 1
FROM post_tags search_post_tags
INNER JOIN tags search_tags ON search_tags.id = search_post_tags.tag_id
WHERE search_post_tags.post_id = posts.id
AND (search_tags.name ILIKE ${searchPattern} OR search_tags.slug ILIKE ${searchPattern})
)
)
`
const total = Number(totalRows[0]?.total || 0)
return {
items: rows.map(mapAdminPostSummaryRow),
pagination: {
page,
pageSize,
total,
pageCount: Math.max(1, Math.ceil(total / pageSize))
},
counts: {
total: Number(countsRows[0]?.total || 0),
featured: Number(countsRows[0]?.featured || 0)
}
}
}
/**

View File

@@ -4,10 +4,20 @@ import { listAdminPosts } from '../../../repositories/content-repository'
/**
* 관리자 게시물 목록 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Array>} 게시물 목록
* @returns {Promise<Array|Object>} 게시물 목록
*/
export default defineEventHandler((event) => {
requireAdminSession(event)
const query = getQuery(event)
return listAdminPosts()
return listAdminPosts({
paged: query.paged === 'true',
page: query.page,
pageSize: query.pageSize,
searchQuery: query.q,
status: query.status,
tagSlug: query.tag,
featured: query.featured,
sortOrder: query.sort
})
})