관리자 글 목록 API 응답 경량화
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.10.22
|
||||
|
||||
- 관리자 글 목록이 게시물 본문 전체를 받지 않도록 줄여 목록 진입 부담을 낮췄다.
|
||||
- 글 목록의 추천 토글을 전용 API로 분리해 본문 없는 목록 응답에서도 안전하게 동작하도록 했다.
|
||||
|
||||
## v1.10.21
|
||||
|
||||
- 관리자 글 목록의 태그 필터를 검색해서 고르는 방식으로 바꿨다.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 v1.10.21에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.10.22에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-07-03 v1.10.22 — 관리자 글 목록은 본문 없는 요약 응답을 쓴다
|
||||
|
||||
관리자 글 목록은 제목, 상태, 태그, 조회수, 발행일, 썸네일만 빠르게 훑는 화면이다. 게시물 본문 전체를 함께 내려주면 글 수가 늘수록 목록 진입 payload가 본문 길이에 비례해 커지고, 이미지 마크다운이 많은 글에서는 관리 화면 로딩에도 영향을 준다. 따라서 `/admin/api/posts`는 목록 요약 필드만 반환하고, 상세 편집 화면은 기존 `/admin/api/posts/:id`에서 본문을 계속 조회한다.
|
||||
|
||||
기존 목록의 추천 토글은 전체 게시물 수정 API에 목록 데이터를 다시 조립해 보내는 구조였다. 목록 응답에서 본문을 제외하면 이 방식은 본문을 빈 값으로 덮어쓸 위험이 있으므로, 추천 상태만 바꾸는 `/admin/api/posts/:id/featured` 전용 API로 분리한다. 목록 검색은 더 이상 본문 전문 검색이 아니라 제목·슬러그·요약·태그 기준으로 유지하고, 본문 검색 고도화는 공개 통합 검색 인덱스 개선 과제와 별도로 다룬다.
|
||||
|
||||
## 2026-07-03 v1.10.21 — 관리자 글 목록 필터는 태그 증가를 전제로 한다
|
||||
|
||||
관리자 글 목록의 상태·추천·정렬 필터는 선택지가 고정되어 있어 기본 select로 충분하지만, 태그는 운영이 길어질수록 계속 늘어난다. 태그 전체를 네이티브 select에 한 줄로 누적하면 탐색성과 키보드 검색성이 떨어지므로, 글 목록 태그 필터는 검색형 combobox로 바꾸고 현재 사용 중인 태그만 후보로 보여 준다. 검색어가 없으면 전체 태그 옵션과 기존 정렬된 태그 목록을 보여 주고, 입력 중에는 태그명·슬러그 기준으로 좁힌다.
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
|------|------|
|
||||
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 날짜·구간 상세 모달, 방문자 유입 정보·디바이스·유입 키워드, 접속자 목록, 인기 게시물 월간 조회수·작성일, 인기 페이지 참여 지표) |
|
||||
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
|
||||
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 검색·필터(상태·검색형 태그 combobox·추천·정렬)와 «새 글»을 한 줄 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 `featuredImageThumbnail` 우선 대표 이미지 썸네일, 제목 옆 댓글 수, 제목 아래 주소·공개 보기 hover 아이콘, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(추천·삭제) |
|
||||
| pages/admin/posts/index.vue | 글 목록, 본문 없는 `/admin/api/posts` 요약 응답 사용, 헤더 우측에 검색·필터(상태·검색형 태그 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 | 게시물별 통계, 누적 조회수·기간별 조회/방문자 차트·유입/디바이스/키워드·읽음/체류/스크롤 지표 |
|
||||
@@ -238,6 +238,7 @@
|
||||
| server/api/site-settings.get.js | 공개 사이트 설정 API |
|
||||
| server/api/homepage-widget.get.js | gethomepage customapi용 당일·7일·30일·180일·365일·누적·콘텐츠 통계 위젯 API |
|
||||
| server/repositories/blog-statistics-repository.js | 공개 게시글·댓글·페이지·태그·회원·누적 방문 통계 집계 |
|
||||
| server/routes/admin/api/posts/[id]/featured.put.js | 관리자 글 목록 추천 상태 전용 수정 API |
|
||||
| server/api/navigation.get.js | 공개 네비게이션 API |
|
||||
| server/api/auth/signup.post.js | 회원 가입 API(선택 `emailOtp`, Resend 설정 시 일반 가입에 필수) |
|
||||
| server/api/auth/bootstrap-status.get.js | 최초 관리자 여부 + `emailOtpConfigured` |
|
||||
|
||||
@@ -198,6 +198,8 @@ layouts/
|
||||
- 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다.
|
||||
- 관리자 글 목록의 태그 컬럼은 게시물 태그 배열의 첫 번째 항목만 대표 태그로 표시하며, 배지는 태그 고유 색상을 반영한다.
|
||||
- 관리자 글 목록의 태그 필터는 태그 증가에 대비해 검색형 combobox로 표시한다. 후보는 현재 글 목록에서 사용 중인 태그만 보여 주며, 검색어가 없으면 `전체 태그`와 태그명을 정렬한 목록을, 입력 중에는 태그명·슬러그 부분 일치 결과를 표시한다. 방향키와 Enter로 선택할 수 있고, 선택된 태그는 토큰의 `×`로 전체 태그 상태로 되돌린다.
|
||||
- 관리자 글 목록 API(`/admin/api/posts`)는 목록 최적화를 위해 게시물 본문 `content`를 반환하지 않는다. 목록 검색은 제목·슬러그·요약·태그 기준으로 수행하고, 본문이 필요한 편집 화면은 `/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개 범위에서 조절한다.
|
||||
- 관리자 글쓰기 상단 왼쪽 상태 영역은 `Published`, `Scheduled`, `Members`, `Private`, 초안 저장 상태 등 현재 상태 텍스트만 표시하며, 공개 게시물 이동은 오른쪽 설정 패널의 `View Post` 링크에서만 제공한다. 오른쪽 설정 패널 하단에는 본문 기준 단어 수, 공백 제외 문자 수와 공백 수, 예상 읽기 시간, 블록 수, 이미지 수를 작은 통계로 표시한다.
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
## 2차 관리자 개발
|
||||
|
||||
- 관리자 글 목록 API 응답 경량화.
|
||||
- 현재 `/admin/api/posts`는 목록 화면에서도 게시물 전체 본문을 함께 내려준다.
|
||||
- 글 수와 본문 이미지/마크다운이 늘면 목록 진입 payload가 커질 수 있으므로, 목록 전용 응답에서 `content`를 제외하고 서버 페이지네이션·서버 필터링으로 전환 검토.
|
||||
- 관리자 글 목록 서버 페이지네이션·서버 필터링 전환 검토.
|
||||
- `/admin/api/posts`는 v1.10.22에서 본문 없는 요약 응답으로 줄였지만, 아직 전체 글 목록을 한 번에 내려준 뒤 클라이언트에서 검색·필터링한다.
|
||||
- 글이 수백~수천 개로 늘면 관리자 목록도 서버 페이지네이션, 서버 검색, 서버 태그 필터로 전환하는 편이 안전하다.
|
||||
|
||||
## 프론트엔드 개발
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.10.22
|
||||
|
||||
- 관리자 글 목록 API가 게시물 전체 본문을 내려주지 않고 목록 요약 필드만 반환하도록 수정.
|
||||
- 관리자 글 목록 검색 범위를 제목·슬러그·요약·태그 기준으로 정리.
|
||||
- 관리자 글 목록 추천 토글을 전체 게시물 저장 API가 아닌 전용 `/admin/api/posts/:id/featured` API로 분리.
|
||||
|
||||
## v1.10.21
|
||||
|
||||
- 관리자 글 목록 태그 필터를 전체 태그 select에서 검색형 combobox로 변경.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.21",
|
||||
"version": "1.10.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.21",
|
||||
"version": "1.10.22",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.21",
|
||||
"version": "1.10.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup>
|
||||
import { toAdminPostStoredTitle } from '~/lib/admin-post-title.js'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
@@ -372,7 +370,6 @@ const doesPostMatchSearch = (post, query) => {
|
||||
post.title,
|
||||
post.slug,
|
||||
post.excerpt,
|
||||
post.content,
|
||||
tagText
|
||||
].join(' ').toLowerCase()
|
||||
|
||||
@@ -541,29 +538,6 @@ const selectAnalyticsModalPeriod = async (period) => {
|
||||
await fetchAnalyticsModalData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 수정 API 본문을 구성한다.
|
||||
* @param {Object} post - 게시물
|
||||
* @param {Object} [overrides] - 덮어쓸 필드
|
||||
* @returns {Object} PUT 본문
|
||||
*/
|
||||
const buildPostUpdateBody = (post, overrides = {}) => ({
|
||||
title: toAdminPostStoredTitle(post.title),
|
||||
slug: post.slug,
|
||||
content: post.content ?? '',
|
||||
excerpt: post.excerpt ?? '',
|
||||
featuredImage: post.featuredImage ?? null,
|
||||
isFeatured: overrides.isFeatured ?? Boolean(post.isFeatured),
|
||||
seoTitle: post.seoTitle ?? '',
|
||||
seoDescription: post.seoDescription ?? '',
|
||||
canonicalUrl: post.canonicalUrl ?? '',
|
||||
noindex: Boolean(post.noindex),
|
||||
ogImage: post.ogImage ?? null,
|
||||
status: post.status,
|
||||
publishedAt: post.publishedAt,
|
||||
tags: post.tags ?? []
|
||||
})
|
||||
|
||||
/**
|
||||
* 열린 행 메뉴를 닫는다.
|
||||
* @returns {void}
|
||||
@@ -760,9 +734,11 @@ const togglePostFeatured = async (post) => {
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/posts/${post.id}`, {
|
||||
await $fetch(`/admin/api/posts/${post.id}/featured`, {
|
||||
method: 'PUT',
|
||||
body: buildPostUpdateBody(post, { isFeatured: !post.isFeatured })
|
||||
body: {
|
||||
isFeatured: !post.isFeatured
|
||||
}
|
||||
})
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
|
||||
@@ -83,6 +83,30 @@ const mapAdminPostRow = (row) => ({
|
||||
title: toAdminPostFormTitle(row.title)
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 목록 API용 게시물 요약 행 변환
|
||||
* @param {Object} row - 게시물 행
|
||||
* @returns {Object} 게시물 요약 응답
|
||||
*/
|
||||
const mapAdminPostSummaryRow = (row) => ({
|
||||
id: row.id,
|
||||
title: toAdminPostFormTitle(row.title),
|
||||
slug: row.slug,
|
||||
authorId: row.author_id || null,
|
||||
excerpt: row.excerpt,
|
||||
featuredImage: row.featured_image,
|
||||
featuredImageThumbnail: getExistingPostThumbnailUrl(row.featured_image),
|
||||
showFeaturedImage: Boolean(row.show_featured_image),
|
||||
isFeatured: Boolean(row.is_featured),
|
||||
commentCount: Number(row.comment_count || 0),
|
||||
totalViews: Number(row.total_views || 0),
|
||||
status: row.status,
|
||||
publishedAt: row.published_at ? row.published_at.toISOString() : null,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString(),
|
||||
tags: row.tags || []
|
||||
})
|
||||
|
||||
/**
|
||||
* 고정 페이지 행을 API 응답 구조로 변환
|
||||
* @param {Object} row - 고정 페이지 행
|
||||
@@ -438,19 +462,33 @@ export const listPosts = async ({
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 게시물 목록 조회
|
||||
* @returns {Promise<Array>} 관리자 게시물 목록
|
||||
* 관리자 게시물 목록 요약 조회
|
||||
* @returns {Promise<Array>} 관리자 게시물 목록 요약
|
||||
*/
|
||||
export const listAdminPosts = async () => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return getSamplePosts()
|
||||
return getSamplePosts().map((post) => {
|
||||
const { content, ...summary } = post
|
||||
return summary
|
||||
})
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
posts.*,
|
||||
posts.id,
|
||||
posts.title,
|
||||
posts.slug,
|
||||
posts.author_id,
|
||||
posts.excerpt,
|
||||
posts.featured_image,
|
||||
posts.show_featured_image,
|
||||
posts.is_featured,
|
||||
posts.status,
|
||||
posts.published_at,
|
||||
posts.created_at,
|
||||
posts.updated_at,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM comments
|
||||
@@ -466,11 +504,23 @@ 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
|
||||
GROUP BY posts.id
|
||||
GROUP BY
|
||||
posts.id,
|
||||
posts.title,
|
||||
posts.slug,
|
||||
posts.author_id,
|
||||
posts.excerpt,
|
||||
posts.featured_image,
|
||||
posts.show_featured_image,
|
||||
posts.is_featured,
|
||||
posts.status,
|
||||
posts.published_at,
|
||||
posts.created_at,
|
||||
posts.updated_at
|
||||
ORDER BY COALESCE(posts.published_at, posts.updated_at) DESC
|
||||
`
|
||||
|
||||
return rows.map(mapAdminPostRow)
|
||||
return rows.map(mapAdminPostSummaryRow)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -628,6 +678,31 @@ export const updateAdminPost = async (id, input, editorId) => {
|
||||
return rows[0] ? getAdminPostById(rows[0].id) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 게시물 추천 상태 수정
|
||||
* @param {string} id - 게시물 ID
|
||||
* @param {boolean} isFeatured - 추천 여부
|
||||
* @returns {Promise<Object | null>} 수정된 게시물
|
||||
*/
|
||||
export const updateAdminPostFeatured = async (id, isFeatured) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
UPDATE posts
|
||||
SET
|
||||
is_featured = ${Boolean(isFeatured)},
|
||||
updated_at = now()
|
||||
WHERE id = ${id}
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
return rows[0] ? getAdminPostById(rows[0].id) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 게시물 삭제
|
||||
* @param {string} id - 게시물 ID
|
||||
|
||||
45
server/routes/admin/api/posts/[id]/featured.put.js
Normal file
45
server/routes/admin/api/posts/[id]/featured.put.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createError, getRouterParam, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { requireAdminSession } from '../../../../../utils/admin-auth'
|
||||
import { updateAdminPostFeatured } from '../../../../../repositories/content-repository'
|
||||
|
||||
const featuredInputSchema = z.object({
|
||||
isFeatured: z.boolean()
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 게시물 추천 상태 변경 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 수정된 게시물
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const id = String(getRouterParam(event, 'id') || '').trim()
|
||||
const parsedBody = featuredInputSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '게시물 ID가 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '추천 상태 입력 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const post = await updateAdminPostFeatured(id, parsedBody.data.isFeatured)
|
||||
|
||||
if (!post) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '게시물을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return post
|
||||
})
|
||||
Reference in New Issue
Block a user