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

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

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