diff --git a/LOG.md b/LOG.md
index 70121ad..afd7422 100644
--- a/LOG.md
+++ b/LOG.md
@@ -15,3 +15,21 @@
- Added `/admin` profile and category management panels with profile image upload, profile save, category creation, rename, parent move, and guarded delete confirmation flows using the existing API wrappers.
- Validation: targeted ESLint passed for the changed admin/sidebar files; `npm run build` passed, with the existing sitemap backend connection warning logged as `ECONNREFUSED`; `npm run lint` still fails on the known baseline issues outside this task; local dev server on port 3100 returned HTTP 200 for `/admin`.
- Next task: proceed with renewal Phase 3 by adding an admin posts management view with searchable post rows, edit links, and a non-browser-confirm delete flow.
+- Continued renewal Phase 3 by adding an `/admin` 게시글 관리 panel with keyword search, sort controls, pagination, view/edit actions, and an in-app delete confirmation dialog that invalidates post queries after deletion.
+- Added `PageMeta` and expanded `PostListResponse` so admin and dashboard counts can handle either top-level pagination fields or a nested `page` object from the backend.
+- Validation: installed dependencies with `npm ci`; targeted ESLint passed for `AdminPostsPanel`, `/admin`, and shared types; `tsc --noEmit` passed; `next build --webpack` passed with the existing sitemap fetch `EPERM` warning. The default `npm run build` Turbopack run hung during optimization and was terminated; full `npm run lint` still fails on known baseline issues outside this task. Local dev server on port 3100 returned HTTP 200 for `/admin` and redirected unauthenticated browser access to `/login`.
+- Next task: continue Phase 3 by tightening the write/edit flow under the admin experience, including replacing remaining browser `alert`/`confirm` usage in post detail/write flows with in-app dialogs or toast patterns.
+- Added a Next.js `src/proxy.ts` HTTPS enforcement layer that redirects non-local HTTP requests to HTTPS, respects `X-Forwarded-Proto`/`X-Forwarded-Host` to avoid reverse-proxy redirect loops, and adds HSTS on non-local HTTPS responses.
+- Validation: targeted ESLint passed for `src/proxy.ts` and the existing changed admin/type files; `tsc --noEmit` passed; `next build --webpack` passed with the existing sitemap fetch `EPERM` warning. Curl checks confirmed `Host: blog.wypark.me` over HTTP returns `301 Location: https://blog.wypark.me/archive`, `X-Forwarded-Proto: https` returns `200` with HSTS, and `127.0.0.1` local HTTP remains `200`. Full `npm run lint` still fails on the known baseline issues outside this task.
+- Next task: mirror the HTTPS redirect at the outer reverse proxy/load balancer as infrastructure defense-in-depth, then continue Phase 3 write/edit flow cleanup.
+- Completed the admin function move by extracting the post editor into `AdminPostEditor`, adding `/admin/posts/new` and `/admin/posts/[slug]/edit`, turning legacy `/write` into a compatibility redirect, and updating header/admin/post links to the new admin routes.
+- Removed direct post edit/delete controls from the public post detail page and moved admin comment deletion into a new `/admin` comments panel backed by typed `AdminComment` responses.
+- Updated `npm run build` to use `next build --webpack` so Docker/CI uses the build mode that passes locally instead of the default Turbopack build that previously hung.
+- Validation: targeted ESLint passed for changed admin/write/comment/post/header/API/type/proxy files with one existing `` warning in `PostDetailClient`; `tsc --noEmit` passed; `npm run build` passed with the existing sitemap fetch `EPERM` warning; local dev server on port 3100 returned HTTP 200 for `/admin/posts/new`, `/admin/posts/sample/edit`, and `/write`. Full `npm run lint` still fails on known baseline issues outside this task.
+- Next task: continue with non-design quality work by cleaning the remaining lint baseline (`src/api/http.ts`, category/archive/home/login/signup/comment form/list, Markdown renderer, TOC, auth store, and Tailwind config).
+- Split admin management into dedicated routes under a shared guarded admin shell: `/admin/posts`, `/admin/comments`, `/admin/categories`, and `/admin/profile`; `/admin` is now a dashboard only.
+- Added admin dashboard recent comments and total comments widgets, plus defensive `getAdminComments` normalization for possible `author`, `postSlug`, and `postTitle` response aliases.
+- Enhanced admin post management with category filtering, direct page-number navigation, current-page row selection, and bulk delete via the existing single-delete API.
+- Added login return handling with `/login?redirect=...`, kept `/write` as the chosen legacy compatibility redirect, and documented these decisions in `docs/renewal-plan.md`.
+- Validation: unauthenticated live-server request to `https://blogserver.wypark.me/api/admin/comments?page=0&size=1` returned HTTP 403 with an empty body, so exact admin comment fields still require an authenticated admin token/session; targeted ESLint passed for the changed admin/login/comment API files; `npx tsc --noEmit` passed; `npm run build` passed with the existing sitemap fetch `EPERM` warning; full `npm run lint` still fails on known baseline issues outside this task; local dev route checks returned HTTP 200 for `/admin`, `/admin/posts`, `/admin/comments`, `/admin/categories`, and `/login?redirect=%2Fadmin%2Fcomments`; in-app browser verification confirmed `/admin/comments` redirects to `/login?redirect=%2Fadmin%2Fcomments` when logged out.
+- Next task: authenticate against the real admin API to capture the exact `getAdminComments` payload, then clean the existing lint baseline so full `npm run lint` can become a reliable gate.
diff --git a/docs/renewal-plan.md b/docs/renewal-plan.md
index 225d823..1f489e2 100644
--- a/docs/renewal-plan.md
+++ b/docs/renewal-plan.md
@@ -66,7 +66,7 @@
- 일반 회원과 비로그인 사용자는 관리자 버튼을 볼 수 없게 처리
- 기존 사이드바의 카테고리 편집 버튼, 프로필 수정 모달 제거
- 카테고리/프로필/댓글/게시글 관리는 `/admin` 내부에서만 수행
-- `/write`는 `/admin/posts/new` 또는 `/admin/write`로 편입하는 방향 검토
+- `/write`는 외부 링크와 기존 북마크 호환을 위해 legacy redirect로 유지하고, 실제 작성/수정 화면은 `/admin/posts/new`, `/admin/posts/[slug]/edit`로 편입
### 마크다운 렌더링 기능이 제한적임
@@ -133,7 +133,7 @@
/admin/settings
```
-초기 구현에서는 라우트 수를 줄여 `/admin` 단일 페이지 안의 탭 UI로 시작해도 된다.
+현재 구현에서는 `/admin` 대시보드와 세부 관리 라우트를 분리한다. 기존 `/write`는 호환 redirect로만 유지한다.
권장 MVP:
@@ -183,7 +183,7 @@
권장 동작:
-- 비로그인: `/login?redirect=/admin` 또는 홈으로 이동
+- 비로그인: `/login?redirect=/admin...`로 이동하고 로그인 성공 후 원래 가려던 관리자 경로로 복귀
- 일반 회원: “관리자 권한이 필요합니다” 토스트 후 홈으로 이동
- 관리자: 관리자 메뉴 노출 및 `/admin` 접근 허용
@@ -226,7 +226,7 @@
- 카테고리 수
- 최근 작성 글 5개
- 인기 글 5개
-- 최근 댓글 5~10개
+- 최근 댓글 5개
- 빠른 작업: 새 글 작성, 카테고리 관리, 프로필 수정
### 게시글 관리
@@ -236,12 +236,16 @@
필요 UI:
- 검색/필터/정렬 가능한 게시글 테이블
+- 카테고리 필터
- 제목, 카테고리, 날짜, 조회수, 작업 버튼
+- 페이지 번호 직접 이동
+- 현재 페이지 선택 및 대량 삭제
- 새 글 작성 버튼
- 수정 버튼
- 삭제 전 확인 모달
백엔드 유지 조건에서는 공개 `getPosts`를 관리 목록에도 사용한다. 비공개 글 같은 개념이 없다면 별도 admin list API 없이 충분하다.
+대량 삭제는 별도 bulk API가 없으므로 기존 단건 삭제 API를 선택 항목별로 호출한다.
### 카테고리 관리
@@ -280,7 +284,9 @@
- 댓글 위치로 이동
- 관리자 삭제
-단, `getAdminComments` 반환 타입이 현재 `any`이므로 실제 응답을 확인한 뒤 `AdminComment` 타입을 추가한다.
+공개 댓글 목록의 일반 사용자/비회원 댓글 삭제 UX는 공개 기능으로 유지한다. 관리자 전용 강제 삭제만 `/admin/comments`로 분리한다.
+
+단, `getAdminComments` 실제 필드 검증에는 관리자 인증이 필요하다. 2026-05-28 비인증 실서버 요청은 HTTP 403과 빈 본문을 반환했으므로, 프론트에서는 `postSlug`, `postTitle`, `author` 외에 중첩 `post`, `postResponse`, `member`, `guest` 등 가능한 alias를 정규화해 방어적으로 대응한다.
## 7. 마크다운 렌더러 개선 상세
diff --git a/package.json b/package.json
index 9bd7622..ae131db 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
- "build": "next build",
+ "build": "next build --webpack",
"start": "next start",
"lint": "eslint"
},
diff --git a/src/api/comments.ts b/src/api/comments.ts
index ae8ceb6..63fd809 100644
--- a/src/api/comments.ts
+++ b/src/api/comments.ts
@@ -1,5 +1,41 @@
import { http } from './http';
-import { ApiResponse, Comment, CommentSaveRequest, CommentDeleteRequest } from '@/types';
+import {
+ AdminComment,
+ AdminCommentListResponse,
+ ApiResponse,
+ Comment,
+ CommentDeleteRequest,
+ CommentSaveRequest,
+} from '@/types';
+
+type RawCommentAuthor = {
+ nickname?: string;
+ name?: string;
+};
+
+type RawCommentPost = {
+ slug?: string;
+ title?: string;
+ postSlug?: string;
+ postTitle?: string;
+};
+
+type RawAdminComment = AdminComment & {
+ nickname?: string;
+ authorName?: string;
+ writer?: string;
+ member?: RawCommentAuthor | null;
+ guest?: RawCommentAuthor | null;
+ post?: RawCommentPost | null;
+ postResponse?: RawCommentPost | null;
+ postName?: string;
+ articleSlug?: string;
+ articleTitle?: string;
+};
+
+type RawAdminCommentListResponse = Omit & {
+ content: RawAdminComment[];
+};
// 1. 댓글 목록 조회
export const getComments = async (postSlug: string) => {
@@ -24,16 +60,66 @@ export const deleteComment = async (id: number, password?: string) => {
return response.data;
};
+const normalizeAdminComment = (comment: RawAdminComment): AdminComment => ({
+ ...comment,
+ author: comment.author
+ ?? comment.memberNickname
+ ?? comment.guestNickname
+ ?? comment.nickname
+ ?? comment.authorName
+ ?? comment.writer
+ ?? comment.member?.nickname
+ ?? comment.member?.name
+ ?? comment.guest?.nickname
+ ?? comment.guest?.name,
+ postSlug: comment.postSlug
+ ?? comment.post?.slug
+ ?? comment.post?.postSlug
+ ?? comment.postResponse?.slug
+ ?? comment.postResponse?.postSlug
+ ?? comment.articleSlug,
+ postTitle: comment.postTitle
+ ?? comment.post?.title
+ ?? comment.post?.postTitle
+ ?? comment.postResponse?.title
+ ?? comment.postResponse?.postTitle
+ ?? comment.postName
+ ?? comment.articleTitle,
+});
+
+const normalizeAdminComments = (
+ data: RawAdminCommentListResponse | RawAdminComment[],
+): AdminCommentListResponse => {
+ if (Array.isArray(data)) {
+ return {
+ content: data.map(normalizeAdminComment),
+ totalElements: data.length,
+ totalPages: 1,
+ number: 0,
+ last: true,
+ };
+ }
+
+ return {
+ ...data,
+ content: data.content.map(normalizeAdminComment),
+ totalElements: data.page?.totalElements ?? data.totalElements,
+ totalPages: data.page?.totalPages ?? data.totalPages,
+ number: data.page?.number ?? data.number,
+ last: data.page?.last ?? data.last,
+ };
+};
+
// 4. 관리자 댓글 목록 조회 (대시보드용)
export const getAdminComments = async (page = 0, size = 20) => {
- const response = await http.get>('/api/admin/comments', {
+ const response = await http.get>('/api/admin/comments', {
params: { page, size },
});
- return response.data.data;
+ return normalizeAdminComments(response.data.data);
};
// 5. 관리자 댓글 강제 삭제
export const deleteAdminComment = async (id: number) => {
const response = await http.delete>(`/api/admin/comments/${id}`);
return response.data;
-};
\ No newline at end of file
+};
diff --git a/src/app/admin/categories/page.tsx b/src/app/admin/categories/page.tsx
new file mode 100644
index 0000000..1b996ef
--- /dev/null
+++ b/src/app/admin/categories/page.tsx
@@ -0,0 +1,5 @@
+import AdminCategoryPanel from '@/components/admin/AdminCategoryPanel';
+
+export default function AdminCategoriesPage() {
+ return ;
+}
diff --git a/src/app/admin/comments/page.tsx b/src/app/admin/comments/page.tsx
new file mode 100644
index 0000000..729ab88
--- /dev/null
+++ b/src/app/admin/comments/page.tsx
@@ -0,0 +1,5 @@
+import AdminCommentsPanel from '@/components/admin/AdminCommentsPanel';
+
+export default function AdminCommentsPage() {
+ return ;
+}
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
new file mode 100644
index 0000000..955c084
--- /dev/null
+++ b/src/app/admin/layout.tsx
@@ -0,0 +1,5 @@
+import AdminRouteShell from '@/components/admin/AdminRouteShell';
+
+export default function AdminLayout({ children }: { children: React.ReactNode }) {
+ return {children};
+}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 395939d..2844469 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -1,26 +1,22 @@
'use client';
import Link from 'next/link';
-import { useRouter } from 'next/navigation';
-import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
-import toast from 'react-hot-toast';
import {
ArrowRight,
Eye,
FileText,
FolderTree,
Loader2,
+ MessageSquareText,
PenLine,
Settings,
Sparkles,
} from 'lucide-react';
import { getCategories } from '@/api/category';
+import { getAdminComments } from '@/api/comments';
import { getPosts } from '@/api/posts';
-import AdminCategoryPanel from '@/components/admin/AdminCategoryPanel';
-import AdminProfilePanel from '@/components/admin/AdminProfilePanel';
-import { useAuthStore } from '@/store/authStore';
-import { Category, Post } from '@/types';
+import { AdminComment, AdminCommentListResponse, Category, PageMeta, Post } from '@/types';
const countCategories = (categories: Category[] = []): number => {
return categories.reduce((count, category) => {
@@ -28,7 +24,14 @@ const countCategories = (categories: Category[] = []): number => {
}, 0);
};
-const formatDate = (value?: string) => {
+const emptyPageMeta: PageMeta = {
+ totalPages: 0,
+ totalElements: 0,
+ number: 0,
+ last: true,
+};
+
+const formatDate = (value?: string, withTime = false) => {
if (!value) return '-';
const date = new Date(value);
@@ -38,9 +41,25 @@ const formatDate = (value?: string) => {
year: 'numeric',
month: 'short',
day: 'numeric',
+ ...(withTime ? { hour: '2-digit', minute: '2-digit' } : {}),
}).format(date);
};
+const getCommentListMeta = (data?: AdminCommentListResponse): PageMeta => {
+ if (!data) return emptyPageMeta;
+
+ return {
+ totalElements: data.page?.totalElements ?? data.totalElements ?? 0,
+ totalPages: data.page?.totalPages ?? data.totalPages ?? 0,
+ number: data.page?.number ?? data.number ?? 0,
+ last: data.page?.last ?? data.last,
+ };
+};
+
+const getAuthorName = (comment: AdminComment) => {
+ return comment.author || comment.memberNickname || comment.guestNickname || '익명';
+};
+
function PostRow({ post }: { post: Post }) {
return (
+