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

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

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-07-03 v1.10.23 — 관리자 글 목록은 서버에서 먼저 자른다
하루에 수십 개씩 이전 글을 추가하면 관리자 글 목록은 곧 수천 건 규모가 된다. 목록 화면이 전체 글을 한 번에 받은 뒤 브라우저에서 검색·필터링하면 응답 크기뿐 아니라 태그 필터와 정렬 계산도 글 수에 비례해 커진다. 따라서 글 목록 화면은 `/admin/api/posts?paged=true` 응답을 사용해 서버에서 검색·상태·태그·추천·정렬 필터와 페이지네이션을 먼저 적용한다.
대시보드와 미디어 사용처 계산은 아직 기존 배열 응답을 전제로 하므로 `/admin/api/posts` 기본 응답은 배열 호환을 유지한다. 새 페이지 응답은 명시적으로 `paged=true`일 때만 사용해 기존 화면의 변경 범위를 줄이고, 목록 전용 화면만 `items`, `pagination`, `counts`를 읽도록 분리한다.
## 2026-07-03 v1.10.22 — 관리자 글 목록은 본문 없는 요약 응답을 쓴다
관리자 글 목록은 제목, 상태, 태그, 조회수, 발행일, 썸네일만 빠르게 훑는 화면이다. 게시물 본문 전체를 함께 내려주면 글 수가 늘수록 목록 진입 payload가 본문 길이에 비례해 커지고, 이미지 마크다운이 많은 글에서는 관리 화면 로딩에도 영향을 준다. 따라서 `/admin/api/posts`는 목록 요약 필드만 반환하고, 상세 편집 화면은 기존 `/admin/api/posts/:id`에서 본문을 계속 조회한다.

View File

@@ -186,7 +186,7 @@
|------|------|
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 날짜·구간 상세 모달, 방문자 유입 정보·디바이스·유입 키워드, 접속자 목록, 인기 게시물 월간 조회수·작성일, 인기 페이지 참여 지표) |
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
| pages/admin/posts/index.vue | 글 목록, 본문 없는 `/admin/api/posts` 요약 응답 사용, 헤더 우측에 검색·필터(상태·검색형 태그 combobox·추천·정렬)와 «새 글»을 한 줄 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 `featuredImageThumbnail` 우선 대표 이미지 썸네일, 제목 옆 댓글 수, 제목 아래 주소·공개 보기 hover 아이콘, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(추천 전용 API·삭제) |
| pages/admin/posts/index.vue | 글 목록, 본문 없는 `/admin/api/posts?paged=true` 페이지 응답 사용, 서버 검색·필터(상태·검색형 태그 combobox·추천·정렬)와 번호형 페이지네이션, 헤더 우측 «새 글» 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 `featuredImageThumbnail` 우선 대표 이미지 썸네일, 제목 옆 댓글 수, 제목 아래 주소·공개 보기 hover 아이콘, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(추천 전용 API·삭제) |
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
| pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
| pages/admin/posts/[id]/analytics.vue | 게시물별 통계, 누적 조회수·기간별 조회/방문자 차트·유입/디바이스/키워드·읽음/체류/스크롤 지표 |
@@ -263,7 +263,7 @@
| server/routes/admin/api/auth/login.post.js | 관리자 로그인 API |
| server/routes/admin/api/auth/logout.post.js | 관리자 로그아웃 API |
| server/routes/admin/api/auth/me.get.js | 관리자 세션 조회 API |
| server/routes/admin/api/posts.get.js | 관리자 게시물 목록 API |
| server/routes/admin/api/posts.get.js | 관리자 게시물 목록 API, 기본 배열 응답과 `paged=true` 페이지 응답 지원 |
| server/routes/admin/api/posts.post.js | 관리자 게시물 생성 API |
| server/routes/admin/api/posts/[id].get.js | 관리자 게시물 상세 API |
| server/routes/admin/api/posts/[id].put.js | 관리자 게시물 수정 API |

View File

@@ -197,8 +197,8 @@ layouts/
- 대시보드 메뉴는 관리자 기본 페이지(`/admin`)로 이동하는 활성 링크로 표시한다.
- 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다.
- 관리자 글 목록의 태그 컬럼은 게시물 태그 배열의 첫 번째 항목만 대표 태그로 표시하며, 배지는 태그 고유 색상을 반영한다.
- 관리자 글 목록의 태그 필터는 태그 증가에 대비해 검색형 combobox로 표시한다. 후보는 현재 글 목록에서 사용 중인 태그만 보여 주며, 검색어가 없으면 `전체 태그`와 태그명을 정렬한 목록을, 입력 중에는 태그명·슬러그 부분 일치 결과를 표시한다. 방향키와 Enter로 선택할 수 있고, 선택된 태그는 토큰의 `×`로 전체 태그 상태로 되돌린다.
- 관리자 글 목록 API(`/admin/api/posts`)는 목록 최적화를 위해 게시물 본문 `content`를 반환하지 않는다. 목록 검색은 제목·슬러그·요약·태그 기준으로 수행하고, 본문이 필요한 편집 화면은 `/admin/api/posts/:id` 상세 API를 사용한다.
- 관리자 글 목록의 태그 필터는 태그 증가에 대비해 검색형 combobox로 표시한다. 후보는 전체 태그 API의 사용 중 태그 기준으로 보여 주며, 검색어가 없으면 `전체 태그`와 태그명을 정렬한 목록을, 입력 중에는 태그명·슬러그 부분 일치 결과를 표시한다. 방향키와 Enter로 선택할 수 있고, 선택된 태그는 토큰의 `×`로 전체 태그 상태로 되돌린다.
- 관리자 글 목록 API(`/admin/api/posts`)는 목록 최적화를 위해 게시물 본문 `content`를 반환하지 않는다. 기본 호출은 기존 호환용 배열을 반환하고, `paged=true` 쿼리를 보내면 `{ items, pagination, counts }` 구조를 반환한다. `page`, `pageSize`, `q`, `status`, `tag`, `featured`, `sort` 쿼리로 서버 검색·상태·태그·추천·정렬 필터를 적용한다. 목록 검색은 제목·슬러그·요약·태그 기준으로 수행하고, 본문이 필요한 편집 화면은 `/admin/api/posts/:id` 상세 API를 사용한다.
- 관리자 글 목록의 추천 토글은 전체 게시물 저장 API가 아니라 `/admin/api/posts/:id/featured` 전용 PUT API에 `{ isFeatured }`만 보내 수정한다.
- 관리자 글 목록의 대표 이미지 미리보기는 작은 카드 썸네일이 있으면 `featuredImageThumbnail`을 우선 사용하고, 없을 때만 원본 `featuredImage`를 fallback으로 사용한다.
- 관리자 글쓰기의 Tags 입력은 배지형 다중 입력을 유지하며, 오른쪽 트리거로 태그 추천 목록을 열 수 있다. 입력이 비어 있으면 공개 발행글 기준 최근 사용 태그 5개를 먼저 표시한 뒤 메인 태그를 이어서 보여 주고, 입력 중에는 기존 태그 이름·슬러그 부분 일치 결과를 추천한다. 추천 목록은 방향키와 Enter로 선택할 수 있다. 선택된 태그 배지는 태그 고유 색상을 반영한다. 게시물별 태그 최대 개수는 기본 5개이며, 사이트 설정 POST 설정의 `postTagLimit` 값으로 1~10개 범위에서 조절한다.
@@ -631,7 +631,7 @@ components/content/
- `GET /admin/api/analytics/range-detail?startDay=YYYY-MM-DD&endDay=YYYY-MM-DD&limit=5` - 통계 추이 막대 상세(요약·유입·디바이스·유입 키워드·인기 게시물·인기 페이지). 최대 31일 범위로 제한한다.
- `GET /admin/api/analytics/posts/:id?period=daily&days=14` - 게시물별 통계(누적·기간 요약, 일간/주간/월간 조회·방문자 추이, 게시물별 유입·디바이스·키워드)
- `GET /admin/api/analytics/pages/:id?period=daily&days=14` - 페이지별 통계(누적·기간 요약, 일간/주간/월간 조회·방문자 추이, 체류·스크롤 구간)
- `GET /admin/api/posts` - 글 목록
- `GET /admin/api/posts` - 글 목록. 기본은 본문 없는 배열 응답이며, `paged=true&page=1&pageSize=50&q=검색어&status=published&tag=slug&featured=featured&sort=newest|oldest` 쿼리를 보내면 서버 필터링된 `{ items, pagination, counts }` 페이지 응답을 반환한다.
- `POST /admin/api/posts` - 글 작성
- `GET /admin/api/posts/:id` - 글 상세
- `PUT /admin/api/posts/:id` - 글 수정

View File

@@ -5,10 +5,6 @@
## 2차 관리자 개발
- 관리자 글 목록 서버 페이지네이션·서버 필터링 전환 검토.
- `/admin/api/posts`는 v1.10.22에서 본문 없는 요약 응답으로 줄였지만, 아직 전체 글 목록을 한 번에 내려준 뒤 클라이언트에서 검색·필터링한다.
- 글이 수백~수천 개로 늘면 관리자 목록도 서버 페이지네이션, 서버 검색, 서버 태그 필터로 전환하는 편이 안전하다.
## 프론트엔드 개발
- 업로드 총량 제한 및 메모리 사용량 개선.

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v1.10.23
- 관리자 글 목록이 `/admin/api/posts?paged=true` 페이지 응답을 사용하도록 수정.
- 관리자 글 목록 검색·상태·태그·추천·정렬 필터를 서버 쿼리 기준으로 전환.
- 관리자 글 목록에 번호형 페이지네이션과 이전·다음 빠른 이동을 추가.
- 관리자 글 목록 태그 필터 후보를 현재 페이지가 아닌 전체 사용 태그 기준으로 표시하도록 수정.
## v1.10.22
- 관리자 글 목록 API가 게시물 전체 본문을 내려주지 않고 목록 요약 필드만 반환하도록 수정.

4
package-lock.json generated
View File

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

View File

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

View File

@@ -14,6 +14,8 @@ const tagFilterSearch = ref('')
const featuredFilter = ref('all')
const sortOrder = ref('newest')
const searchQuery = ref('')
const postPage = ref(1)
const postPageSize = 50
const isTagFilterOpen = ref(false)
const activeTagFilterOptionIndex = ref(0)
const analyticsModalPost = ref(null)
@@ -26,8 +28,32 @@ const analyticsModalError = ref('')
const postMenuTriggerRefs = new Map()
const tagFilterRef = ref(null)
const { data: posts, refresh } = await useFetch('/admin/api/posts', {
default: () => []
const postsQuery = computed(() => ({
paged: 'true',
page: postPage.value,
pageSize: postPageSize,
q: searchQuery.value.trim() || undefined,
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
tag: tagFilter.value === 'all' ? undefined : tagFilter.value,
featured: featuredFilter.value === 'all' ? undefined : featuredFilter.value,
sort: sortOrder.value
}))
const { data: postsResponse, refresh: refreshPosts } = await useFetch('/admin/api/posts', {
query: postsQuery,
default: () => ({
items: [],
pagination: {
page: 1,
pageSize: postPageSize,
total: 0,
pageCount: 1
},
counts: {
total: 0,
featured: 0
}
})
})
const { data: tags } = await useFetch('/admin/api/tags', {
@@ -173,17 +199,6 @@ const getUpdatedDateLabel = (post) => {
return `수정: ${formatPostDateTime(post.updatedAt)}`
}
/**
* 목록 정렬용 시각(ms). 발행일이 있으면 발행일, 없으면 수정일(초안 등).
* @param {Object} post - 게시물
* @returns {number} 정렬 기준 시각
*/
const getListSortTimestamp = (post) => {
const sortAt = post.publishedAt || post.updatedAt || post.createdAt
return new Date(sortAt || 0).getTime()
}
/**
* 게시물 공개 여부 확인
* @param {Object} post - 게시물
@@ -296,21 +311,32 @@ const createTagBadgeStyle = (color) => ({
color
})
const usedTagSlugs = computed(() => {
const slugs = new Set()
for (const post of posts.value) {
for (const tag of post.tags || []) {
slugs.add(tag)
}
}
return [...slugs].sort((a, b) => getTagName(a).localeCompare(getTagName(b), 'ko'))
/** 현재 페이지 게시물 목록 */
const postItems = computed(() => postsResponse.value?.items || [])
/** 관리자 글 목록 페이지 정보 */
const postPagination = computed(() => postsResponse.value?.pagination || {
page: 1,
pageSize: postPageSize,
total: 0,
pageCount: 1
})
const usedTagOptions = computed(() => usedTagSlugs.value.map((slug) => ({
slug,
name: getTagName(slug),
color: getTagColor(slug)
})))
/** 관리자 글 목록 통계 정보 */
const postCounts = computed(() => postsResponse.value?.counts || {
total: 0,
featured: 0
})
/** 게시물에 사용 중인 태그 옵션 */
const usedTagOptions = computed(() => tags.value
.filter((tag) => Number(tag.postCount || 0) > 0)
.map((tag) => ({
slug: tag.slug,
name: tag.name || tag.slug,
color: tag.color || defaultTagColor
}))
.sort((left, right) => left.name.localeCompare(right.name, 'ko')))
const tagFilterSuggestionOptions = computed(() => {
const keyword = tagFilterSearch.value.trim().toLowerCase()
@@ -339,10 +365,13 @@ const hasTagFilterSuggestions = computed(() => tagFilterSuggestionOptions.value.
const selectedTagFilterLabel = computed(() => tagFilter.value === 'all' ? '전체 태그' : getTagName(tagFilter.value))
/** 전체 게시물 수 */
const totalPostCount = computed(() => posts.value.length)
const totalPostCount = computed(() => Number(postCounts.value.total || 0))
/** 추천(`isFeatured`) 게시물 수 */
const featuredPostCount = computed(() => posts.value.filter((post) => post.isFeatured).length)
const featuredPostCount = computed(() => Number(postCounts.value.featured || 0))
/** 필터 적용 후 총 게시물 수 */
const filteredPostCount = computed(() => Number(postPagination.value.total || 0))
/** 필터가 적용됐는지 */
const hasActiveListFilters = computed(() => statusFilter.value !== 'all'
@@ -350,49 +379,45 @@ const hasActiveListFilters = computed(() => statusFilter.value !== 'all'
|| featuredFilter.value !== 'all'
|| Boolean(searchQuery.value.trim()))
/**
* 게시물이 검색어와 일치하는지 확인한다.
* @param {Object} post - 게시물
* @param {string} query - 검색어
* @returns {boolean} 일치 여부
*/
const doesPostMatchSearch = (post, query) => {
const keyword = query.trim().toLowerCase()
if (!keyword) {
return true
/** 글 목록 전체 페이지 수 */
const postPageCount = computed(() => Math.max(1, Number(postPagination.value.pageCount || 1)))
/** 글 목록 페이지 버튼 항목 */
const postPaginationItems = computed(() => {
const total = postPageCount.value
const current = postPage.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const tagText = (post.tags || [])
.map((tag) => `${tag} ${getTagName(tag)}`)
.join(' ')
const pages = new Set([1, total, current - 1, current, current + 1])
const haystack = [
post.title,
post.slug,
post.excerpt,
tagText
].join(' ').toLowerCase()
if (current <= 4) {
pages.add(2)
pages.add(3)
pages.add(4)
pages.add(5)
}
return haystack.includes(keyword)
}
if (current >= total - 3) {
pages.add(total - 4)
pages.add(total - 3)
pages.add(total - 2)
pages.add(total - 1)
}
const filteredPosts = computed(() => {
const filtered = posts.value.filter((post) => {
const matchesStatus = statusFilter.value === 'all' || getPostStatusKey(post) === statusFilter.value
const matchesTag = tagFilter.value === 'all' || (post.tags || []).includes(tagFilter.value)
const matchesFeatured = featuredFilter.value === 'all'
|| (featuredFilter.value === 'featured' && post.isFeatured)
const matchesSearch = doesPostMatchSearch(post, searchQuery.value)
const orderedPages = [...pages].filter((page) => page >= 1 && page <= total).sort((left, right) => left - right)
return orderedPages.reduce((items, page, index) => {
const previousPage = orderedPages[index - 1]
return matchesStatus && matchesTag && matchesFeatured && matchesSearch
})
if (previousPage && page - previousPage > 1) {
items.push(`ellipsis-${previousPage}-${page}`)
}
return [...filtered].sort((a, b) => {
const left = getListSortTimestamp(a)
const right = getListSortTimestamp(b)
return sortOrder.value === 'oldest' ? left - right : right - left
})
items.push(page)
return items
}, [])
})
/**
@@ -631,6 +656,23 @@ const handleTagFilterKeydown = (event) => {
}
}
/**
* 관리자 글 목록 페이지를 변경한다.
* @param {number} page - 이동할 페이지
* @returns {void}
*/
const setPostPage = (page) => {
postPage.value = Math.min(Math.max(Number(page) || 1, 1), postPageCount.value)
closePostMenu()
if (import.meta.client) {
window.scrollTo({
top: 0,
behavior: 'smooth'
})
}
}
/**
* 게시물 행 메뉴 트리거 엘리먼트를 저장한다.
* @param {string} postId - 게시물 ID
@@ -740,7 +782,7 @@ const togglePostFeatured = async (post) => {
isFeatured: !post.isFeatured
}
})
await refresh()
await refreshPosts()
} catch (error) {
errorMessage.value = error?.data?.message || '추천 설정을 변경하지 못했습니다.'
} finally {
@@ -767,7 +809,7 @@ const deletePost = async (post) => {
await $fetch(`/admin/api/posts/${post.id}`, {
method: 'DELETE'
})
await refresh()
await refreshPosts()
} catch (error) {
errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.'
} finally {
@@ -801,6 +843,16 @@ watch(openPostMenuId, async (postId) => {
watch(tagFilterSearch, () => {
activeTagFilterOptionIndex.value = 0
})
watch([searchQuery, statusFilter, tagFilter, featuredFilter, sortOrder], () => {
postPage.value = 1
})
watch(postPageCount, (pageCount) => {
if (postPage.value > pageCount) {
postPage.value = pageCount
}
})
</script>
<template>
@@ -819,9 +871,9 @@ watch(tagFilterSearch, () => {
<span class="admin-posts__count-separator text-[#c8ced3]"> · </span>
<span class="admin-posts__count-featured">추천 {{ featuredPostCount }}개</span>
</template>
<template v-if="hasActiveListFilters && filteredPosts.length !== totalPostCount">
<template v-if="hasActiveListFilters && filteredPostCount !== totalPostCount">
<span class="admin-posts__count-separator text-[#c8ced3]"> · </span>
<span class="admin-posts__count-filtered">표시 {{ filteredPosts.length }}개</span>
<span class="admin-posts__count-filtered">표시 {{ filteredPostCount }}개</span>
</template>
</p>
</div>
@@ -978,7 +1030,7 @@ watch(tagFilterSearch, () => {
</tr>
</thead>
<tbody class="admin-posts__table-body divide-y divide-line bg-white">
<tr v-for="post in filteredPosts" :key="post.id" class="admin-posts__row group">
<tr v-for="post in postItems" :key="post.id" class="admin-posts__row group">
<td class="admin-posts__cell admin-posts__cell-featured w-10 px-2 py-4 text-center">
<span
v-if="post.isFeatured"
@@ -1137,10 +1189,54 @@ watch(tagFilterSearch, () => {
</tbody>
</table>
</div>
<p v-if="filteredPosts.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
{{ posts.length === 0 ? '아직 작성된 글이 없습니다.' : '조건에 맞는 글이 없습니다.' }}
<p v-if="postItems.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
{{ totalPostCount === 0 ? '아직 작성된 글이 없습니다.' : '조건에 맞는 글이 없습니다.' }}
</p>
<nav
v-if="postPageCount > 1"
class="admin-posts__pagination mt-6 flex flex-wrap items-center justify-center gap-2"
aria-label="글 목록 페이지"
>
<button
class="admin-posts__pagination-arrow grid size-9 place-items-center rounded-full border border-line bg-white text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="postPage <= 1"
aria-label="이전 페이지"
@click="setPostPage(postPage - 1)"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<template v-for="item in postPaginationItems" :key="item">
<span v-if="typeof item === 'string'" class="admin-posts__pagination-ellipsis px-2 text-sm font-semibold text-muted">
...
</span>
<button
v-else
class="admin-posts__pagination-page grid size-9 place-items-center rounded-full text-sm font-semibold transition"
:class="item === postPage ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-white hover:text-ink'"
type="button"
:aria-current="item === postPage ? 'page' : undefined"
@click="setPostPage(item)"
>
{{ item }}
</button>
</template>
<button
class="admin-posts__pagination-arrow grid size-9 place-items-center rounded-full border border-line bg-white text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="postPage >= postPageCount"
aria-label="다음 페이지"
@click="setPostPage(postPage + 1)"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</nav>
<Teleport to="body">
<div
v-if="isAnalyticsModalOpen"

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
})
})