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