관리자 글 목록 서버 페이지네이션 전환
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user