diff --git a/docs/history.md b/docs/history.md index 1d4d0ec..9018eb0 100644 --- a/docs/history.md +++ b/docs/history.md @@ -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`에서 본문을 계속 조회한다. diff --git a/docs/map.md b/docs/map.md index a59ecb1..6b5030d 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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 | diff --git a/docs/spec.md b/docs/spec.md index 354141c..ffd679c 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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` - 글 수정 diff --git a/docs/todo.md b/docs/todo.md index af39f8f..226c976 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -5,10 +5,6 @@ ## 2차 관리자 개발 -- 관리자 글 목록 서버 페이지네이션·서버 필터링 전환 검토. - - `/admin/api/posts`는 v1.10.22에서 본문 없는 요약 응답으로 줄였지만, 아직 전체 글 목록을 한 번에 내려준 뒤 클라이언트에서 검색·필터링한다. - - 글이 수백~수천 개로 늘면 관리자 목록도 서버 페이지네이션, 서버 검색, 서버 태그 필터로 전환하는 편이 안전하다. - ## 프론트엔드 개발 - 업로드 총량 제한 및 메모리 사용량 개선. diff --git a/docs/update.md b/docs/update.md index 03f1d6b..dfe76b7 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,12 @@ # 업데이트 이력 +## v1.10.23 + +- 관리자 글 목록이 `/admin/api/posts?paged=true` 페이지 응답을 사용하도록 수정. +- 관리자 글 목록 검색·상태·태그·추천·정렬 필터를 서버 쿼리 기준으로 전환. +- 관리자 글 목록에 번호형 페이지네이션과 이전·다음 빠른 이동을 추가. +- 관리자 글 목록 태그 필터 후보를 현재 페이지가 아닌 전체 사용 태그 기준으로 표시하도록 수정. + ## v1.10.22 - 관리자 글 목록 API가 게시물 전체 본문을 내려주지 않고 목록 요약 필드만 반환하도록 수정. diff --git a/package-lock.json b/package-lock.json index fec2f56..c940165 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a07f3cc..1f99bfd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.10.22", + "version": "1.10.23", "private": true, "type": "module", "imports": { diff --git a/pages/admin/posts/index.vue b/pages/admin/posts/index.vue index 2b3774f..347315e 100644 --- a/pages/admin/posts/index.vue +++ b/pages/admin/posts/index.vue @@ -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 + } +}) @@ -819,9 +871,9 @@ watch(tagFilterSearch, () => { · 추천 {{ featuredPostCount }}개 - + · - 표시 {{ filteredPosts.length }}개 + 표시 {{ filteredPostCount }}개
@@ -978,7 +1030,7 @@ watch(tagFilterSearch, () => { -- {{ posts.length === 0 ? '아직 작성된 글이 없습니다.' : '조건에 맞는 글이 없습니다.' }} +
+ {{ totalPostCount === 0 ? '아직 작성된 글이 없습니다.' : '조건에 맞는 글이 없습니다.' }}
+ +