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 ( +
+

{comment.content}

+

+ {getAuthorName(comment)} · {comment.postTitle || '게시글 정보 없음'} · {formatDate(comment.createdAt, true)} +

+
+ + + ); + + if (comment.postSlug) { + return ( + + {content} + + ); + } + + return
{content}
; +} + export default function AdminPage() { - const router = useRouter(); - const { isLoggedIn, role, _hasHydrated } = useAuthStore(); - const isAdmin = _hasHydrated && role?.includes('ADMIN'); - - useEffect(() => { - if (!_hasHydrated) return; - - if (!isLoggedIn) { - router.replace('/login'); - return; - } - - if (!isAdmin) { - toast.error('관리자 권한이 필요합니다.'); - router.replace('/'); - } - }, [_hasHydrated, isAdmin, isLoggedIn, router]); - const { data: latestData, isLoading: isLatestLoading } = useQuery({ queryKey: ['posts', 'admin', 'latest'], queryFn: () => getPosts({ size: 5, sort: 'createdAt,desc' }), - enabled: isAdmin, retry: 0, }); const { data: popularData, isLoading: isPopularLoading } = useQuery({ queryKey: ['posts', 'admin', 'popular'], queryFn: () => getPosts({ size: 5, sort: 'viewCount,desc' }), - enabled: isAdmin, retry: 0, }); const { data: categories, isLoading: isCategoriesLoading } = useQuery({ queryKey: ['categories'], queryFn: getCategories, - enabled: isAdmin, retry: 0, }); - if (!_hasHydrated || !isAdmin) { - return ( -
- -
- ); - } + const { data: commentsData, isLoading: isCommentsLoading } = useQuery({ + queryKey: ['comments', 'admin', 'recent'], + queryFn: () => getAdminComments(0, 5), + retry: 0, + }); - const isLoading = isLatestLoading || isPopularLoading || isCategoriesLoading; + const isLoading = isLatestLoading || isPopularLoading || isCategoriesLoading || isCommentsLoading; const latestPosts = latestData?.content ?? []; const popularPosts = popularData?.content ?? []; - const totalPosts = latestData?.totalElements ?? 0; + const recentComments = commentsData?.content ?? []; + const totalPosts = latestData?.page?.totalElements ?? latestData?.totalElements ?? 0; const totalCategories = countCategories(categories); + const totalComments = getCommentListMeta(commentsData).totalElements; const lastUpdatedAt = latestPosts[0]?.createdAt; return ( -
+

@@ -125,23 +150,32 @@ export default function AdminPage() { Admin

- 관리자 설정 + 관리자 대시보드

- 공개 화면과 분리된 운영 공간입니다. 게시글 현황을 확인하고 프로필과 카테고리를 정리할 수 있습니다. + 공개 화면과 분리된 운영 공간입니다. 게시글, 댓글, 카테고리, 프로필 관리는 상단 메뉴에서 바로 이동합니다.

- - - 새 글 작성 - +
+ + + 게시글 관리 + + + + 새 글 작성 + +
-
+
총 게시글 @@ -158,6 +192,14 @@ export default function AdminPage() {

{totalCategories.toLocaleString()}

+
+
+ 총 댓글 + +
+

{totalComments.toLocaleString()}

+
+
최근 업데이트 @@ -167,7 +209,7 @@ export default function AdminPage() {
-
+
@@ -175,10 +217,10 @@ export default function AdminPage() {

발행 흐름을 빠르게 확인합니다.

- 전체보기 + 관리
@@ -220,11 +262,38 @@ export default function AdminPage() {
)}
- -
- - +
+
+
+

최근 댓글 5개

+

새 반응을 대시보드에서 바로 봅니다.

+
+ + 관리 + + +
+ + {isCommentsLoading ? ( +
+ +
+ ) : recentComments.length > 0 ? ( +
+ {recentComments.map((comment) => ( + + ))} +
+ ) : ( +
+ 아직 작성된 댓글이 없습니다. +
+ )} +
); diff --git a/src/app/admin/posts/[slug]/edit/page.tsx b/src/app/admin/posts/[slug]/edit/page.tsx new file mode 100644 index 0000000..e029c17 --- /dev/null +++ b/src/app/admin/posts/[slug]/edit/page.tsx @@ -0,0 +1,13 @@ +import AdminPostEditor from '@/components/admin/AdminPostEditor'; + +interface EditAdminPostPageProps { + params: Promise<{ + slug: string; + }>; +} + +export default async function EditAdminPostPage({ params }: EditAdminPostPageProps) { + const { slug } = await params; + + return ; +} diff --git a/src/app/admin/posts/new/page.tsx b/src/app/admin/posts/new/page.tsx new file mode 100644 index 0000000..38a5be1 --- /dev/null +++ b/src/app/admin/posts/new/page.tsx @@ -0,0 +1,5 @@ +import AdminPostEditor from '@/components/admin/AdminPostEditor'; + +export default function NewAdminPostPage() { + return ; +} diff --git a/src/app/admin/posts/page.tsx b/src/app/admin/posts/page.tsx new file mode 100644 index 0000000..8d047a8 --- /dev/null +++ b/src/app/admin/posts/page.tsx @@ -0,0 +1,5 @@ +import AdminPostsPanel from '@/components/admin/AdminPostsPanel'; + +export default function AdminPostsPage() { + return ; +} diff --git a/src/app/admin/profile/page.tsx b/src/app/admin/profile/page.tsx new file mode 100644 index 0000000..dea007d --- /dev/null +++ b/src/app/admin/profile/page.tsx @@ -0,0 +1,5 @@ +import AdminProfilePanel from '@/components/admin/AdminProfilePanel'; + +export default function AdminProfilePage() { + return ; +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 5b11d03..75c0f9e 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,83 +1,101 @@ 'use client'; -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { Suspense, useState } from 'react'; import Link from 'next/link'; -import { useAuthStore } from '@/store/authStore'; -import { login } from '@/api/auth'; +import { useRouter, useSearchParams } from 'next/navigation'; import { Loader2 } from 'lucide-react'; +import { login } from '@/api/auth'; +import { useAuthStore } from '@/store/authStore'; -export default function LoginPage() { +const getSafeRedirectPath = (value: string | null) => { + if (!value) return '/'; + if (!value.startsWith('/') || value.startsWith('//')) return '/'; + if (value.startsWith('/login')) return '/'; + if (value.includes('://')) return '/'; + + return value; +}; + +const getErrorMessage = (error: unknown) => { + if (typeof error === 'object' && error !== null && 'response' in error) { + const response = (error as { response?: { data?: { message?: string } } }).response; + if (response?.data?.message) return response.data.message; + } + + if (error instanceof Error) return error.message; + + return '로그인 중 오류가 발생했습니다.'; +}; + +function LoginForm() { const router = useRouter(); + const searchParams = useSearchParams(); const { login: setLoginState } = useAuthStore(); - + const redirectPath = getSafeRedirectPath(searchParams.get('redirect')); + const [formData, setFormData] = useState({ email: '', password: '' }); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); setLoading(true); setError(''); try { - const res = await login(formData); - if (res.code === 'SUCCESS' && res.data) { - // ✨ 수정됨: AccessToken과 RefreshToken 모두 저장 - setLoginState(res.data.accessToken, res.data.refreshToken); - - // 로그인 성공 후 메인으로 이동 - router.push('/'); + const response = await login(formData); + if (response.code === 'SUCCESS' && response.data) { + setLoginState(response.data.accessToken, response.data.refreshToken); + router.push(redirectPath); } else { - setError(res.message || '로그인에 실패했습니다.'); + setError(response.message || '로그인에 실패했습니다.'); } - } catch (err: any) { - setError(err.response?.data?.message || '로그인 중 오류가 발생했습니다.'); + } catch (loginError) { + setError(getErrorMessage(loginError)); } finally { setLoading(false); } }; return ( - // 🎨 배경색 수정: bg-gray-50 -> bg-white -
-
-
+
+
+

로그인

-

블로그에 오신 것을 환영합니다.

+

블로그에 오신 것을 환영합니다.

- + setFormData({ ...formData, email: e.target.value })} - className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all" + onChange={(event) => setFormData({ ...formData, email: event.target.value })} + className="w-full rounded-lg border border-gray-200 px-4 py-2 outline-none transition-all focus:ring-2 focus:ring-blue-500" placeholder="example@email.com" required />
- + setFormData({ ...formData, password: e.target.value })} - className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all" + onChange={(event) => setFormData({ ...formData, password: event.target.value })} + className="w-full rounded-lg border border-gray-200 px-4 py-2 outline-none transition-all focus:ring-2 focus:ring-blue-500" placeholder="••••••••" required />
- {error &&

{error}

} + {error &&

{error}

}
); -} \ No newline at end of file +} + +export default function LoginPage() { + return ( + + +
+ } + > + + + ); +} diff --git a/src/app/write/page.tsx b/src/app/write/page.tsx index 9bf9fbb..800d31c 100644 --- a/src/app/write/page.tsx +++ b/src/app/write/page.tsx @@ -1,525 +1,29 @@ 'use client'; -import { useState, useEffect, Suspense } from 'react'; +import { Suspense, useEffect } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { createPost, updatePost, getPost } from '@/api/posts'; -import { getCategories } from '@/api/category'; -import { uploadImage } from '@/api/image'; -import { useAuthStore } from '@/store/authStore'; -import { PostSaveRequest } from '@/types'; -import { Loader2, Save, Image as ImageIcon, ArrowLeft, Folder, UploadCloud, FileText, Trash2, X, Clock } from 'lucide-react'; -import toast from 'react-hot-toast'; -import dynamic from 'next/dynamic'; -import axios from 'axios'; +import { Loader2 } from 'lucide-react'; -const MDEditor = dynamic( - () => import('@uiw/react-md-editor').then((mod) => mod.default), - { ssr: false } -); - -// 🛠️ 임시저장 데이터 타입 정의 -interface DraftPost { - id: number; - title: string; - content: string; - savedAt: string; -} - -// 🛠️ 1. 토큰 만료 체크 유틸리티 -function isTokenExpired(token: string) { - try { - const base64Url = token.split('.')[1]; - const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); - const jsonPayload = decodeURIComponent( - atob(base64) - .split('') - .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) - .join('') - ); - const { exp } = JSON.parse(jsonPayload); - return Date.now() / 1000 >= exp - 30; - } catch (e) { - return true; - } -} - -function WritePageContent() { +function LegacyWriteRedirect() { const router = useRouter(); const searchParams = useSearchParams(); - const queryClient = useQueryClient(); - const { role, _hasHydrated, accessToken, refreshToken, login } = useAuthStore(); - - const editSlug = searchParams.get('slug'); - const isEditMode = !!editSlug; - - const [title, setTitle] = useState(''); - const [content, setContent] = useState('**Hello world!**'); - const [categoryId, setCategoryId] = useState(''); - const [tags, setTags] = useState(''); - const [isUploading, setIsUploading] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - - // 💾 임시저장 관련 상태 - const [drafts, setDrafts] = useState([]); - const [showDraftList, setShowDraftList] = useState(false); useEffect(() => { - if (_hasHydrated && (!role || !role.includes('ADMIN'))) { - toast.error('관리자 권한이 필요합니다.'); - router.push('/'); - } - }, [role, _hasHydrated, router]); - - // 컴포넌트 마운트 시 로컬스토리지에서 임시저장 목록 불러오기 - useEffect(() => { - const savedDrafts = localStorage.getItem('temp_drafts'); - if (savedDrafts) { - try { - setDrafts(JSON.parse(savedDrafts)); - } catch (e) { - console.error('Failed to parse drafts', e); - } - } - }, []); - - const { data: categories } = useQuery({ - queryKey: ['categories'], - queryFn: getCategories, - }); - - const { data: existingPost, isLoading: isLoadingPost } = useQuery({ - queryKey: ['post', editSlug], - queryFn: () => getPost(editSlug!), - enabled: isEditMode, - staleTime: 0, - gcTime: 0, - refetchOnMount: 'always' - }); - - useEffect(() => { - if (existingPost) { - setTitle(existingPost.title || ''); - setContent(existingPost.content || ''); - setTags(existingPost.tags ? existingPost.tags.join(', ') : ''); - - if (categories && existingPost.categoryName) { - const found = findCategoryByName(categories, existingPost.categoryName); - if (found) setCategoryId(found.id); - } - } - }, [existingPost, categories]); - - const findCategoryByName = (cats: any[], name: string): any => { - for (const cat of cats) { - if (cat.name === name) return cat; - if (cat.children) { - const found = findCategoryByName(cat.children, name); - if (found) return found; - } - } - return null; - }; - - // 💾 임시저장 기능 - const handleTempSave = () => { - if (!title.trim() && !content.trim()) { - toast.error('제목이나 내용을 입력해주세요.'); - return; - } - - if (drafts.length >= 10) { - toast.error('임시저장은 최대 10개까지만 가능합니다.\n기존 저장분을 삭제해주세요.'); - setShowDraftList(true); // 목록 열어주기 - return; - } - - const newDraft: DraftPost = { - id: Date.now(), - title: title || '(제목 없음)', - content: content, - savedAt: new Date().toLocaleString(), - }; - - const newDrafts = [newDraft, ...drafts]; - setDrafts(newDrafts); - localStorage.setItem('temp_drafts', JSON.stringify(newDrafts)); - toast.success('임시저장 되었습니다!'); - }; - - // 💾 임시저장 불러오기 - const handleLoadDraft = (draft: DraftPost) => { - if (confirm('현재 작성 중인 내용이 사라집니다.\n선택한 임시저장 글을 불러오시겠습니까?')) { - setTitle(draft.title === '(제목 없음)' ? '' : draft.title); - setContent(draft.content); - setShowDraftList(false); - toast.success('불러오기 완료'); - } - }; - - // 💾 임시저장 삭제 - const handleDeleteDraft = (id: number) => { - const newDrafts = drafts.filter(d => d.id !== id); - setDrafts(newDrafts); - localStorage.setItem('temp_drafts', JSON.stringify(newDrafts)); - toast.success('삭제되었습니다.'); - }; - - const mutation = useMutation({ - mutationFn: (data: PostSaveRequest) => isEditMode ? updatePost(existingPost!.id, data) : createPost(data), - onSuccess: async (response: any) => { - const savedPost = response?.data; - const newSlug = savedPost?.slug || editSlug; - - await queryClient.resetQueries({ queryKey: ['posts'] }); - await queryClient.invalidateQueries({ queryKey: ['categories'] }); - - if (editSlug) { - queryClient.removeQueries({ queryKey: ['post', editSlug] }); - } - if (newSlug && newSlug !== editSlug) { - queryClient.removeQueries({ queryKey: ['post', newSlug] }); - } - - toast.success(isEditMode ? '게시글이 수정되었습니다.' : '게시글이 발행되었습니다!'); - router.push(isEditMode ? `/posts/${newSlug}` : '/'); - }, - onError: (err: any) => { - toast.error('저장 실패: ' + (err.response?.data?.message || err.message)); - }, - onSettled: () => { - setIsSubmitting(false); - } - }); - - const ensureAuthToken = async (): Promise => { - if (!accessToken || !refreshToken) { - toast.error('로그인이 필요합니다.'); - return false; - } - - if (!isTokenExpired(accessToken)) { - return true; - } - - try { - console.log('🔄 Access token expired during write. Refreshing...'); - const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; - - const { data } = await axios.post( - `${BASE_URL}/api/auth/reissue`, - { accessToken, refreshToken }, - { headers: { 'Content-Type': 'application/json' }, withCredentials: true } - ); - - if (data.code === 'SUCCESS' && data.data) { - login(data.data.accessToken, data.data.refreshToken); - console.log('✅ Token refreshed successfully before save.'); - return true; - } - return false; - } catch (error) { - console.error('❌ Failed to refresh token before save:', error); - toast.error('세션이 만료되었습니다.\n작성 중인 글을 복사해두고 다시 로그인해주세요!', { duration: 5000 }); - return false; - } - }; - - const handleSubmit = async () => { - if (!title.trim() || !content.trim()) { - toast.error('제목과 내용을 입력해주세요.'); - return; - } - if (categoryId === '') { - toast.error('카테고리를 선택해주세요.'); - return; - } - - setIsSubmitting(true); - - const isTokenValid = await ensureAuthToken(); - if (!isTokenValid) { - setIsSubmitting(false); - return; - } - - mutation.mutate({ - title, - content, - categoryId: Number(categoryId), - tags: tags.split(',').map(t => t.trim()).filter(t => t), - }); - }; - - const handleFileChange = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - setIsUploading(true); - const uploadToast = toast.loading('이미지 업로드 중...'); - - try { - await ensureAuthToken(); - - const res = await uploadImage(file); - if (res.code === 'SUCCESS' && res.data) { - const imageUrl = res.data; - const markdownImage = `![image](${imageUrl})`; - setContent((prev) => prev + '\n' + markdownImage); - toast.success('이미지가 업로드되었습니다.', { id: uploadToast }); - } - } catch (error) { - toast.error('이미지 업로드 실패', { id: uploadToast }); - } finally { - setIsUploading(false); - e.target.value = ''; - } - }; - - const onPaste = async (event: any) => { - const items = event.clipboardData?.items; - if (!items) return; - - for (const item of items) { - if (item.type.indexOf('image') !== -1) { - event.preventDefault(); - const file = item.getAsFile(); - if (!file) return; - - setIsUploading(true); - const uploadToast = toast.loading('이미지 업로드 중...'); - - try { - await ensureAuthToken(); - - const res = await uploadImage(file); - if (res.code === 'SUCCESS' && res.data) { - const imageUrl = res.data; - const markdownImage = `![image](${imageUrl})`; - setContent((prev) => prev + '\n' + markdownImage); - toast.success('이미지 붙여넣기 완료!', { id: uploadToast }); - } - } catch (error) { - toast.error('이미지 업로드 실패', { id: uploadToast }); - } finally { - setIsUploading(false); - } - } - } - }; - - if (isEditMode && isLoadingPost) { - return ( -
- -
- ); - } - - const renderCategoryOptions = (cats: any[], depth = 0) => { - return cats.map((cat) => ( -
- - {cat.children && renderCategoryOptions(cat.children, depth + 1)} -
- )); - }; + const editSlug = searchParams.get('slug'); + router.replace(editSlug ? `/admin/posts/${editSlug}/edit` : '/admin/posts/new'); + }, [router, searchParams]); return ( -
- {/* 상단 헤더 영역 */} -
-
- -

{isEditMode ? '게시글 수정' : '새 글 작성'}

-
- -
- {/* 💾 임시저장 버튼 그룹 */} -
-
- - -
- - {/* 💾 임시저장 목록 팝업 */} - {showDraftList && ( - <> -
setShowDraftList(false)} - /> -
-
-

임시저장 목록 ({drafts.length}/10)

- -
- -
- {drafts.length === 0 ? ( -
- 저장된 글이 없습니다. -
- ) : ( -
    - {drafts.map((draft) => ( -
  • -
    - - -
    -
  • - ))} -
- )} -
-
- - )} -
- - -
-
- -
-
- setTitle(e.target.value)} - placeholder="제목을 입력하세요" - className="w-full text-3xl font-bold placeholder:text-gray-300 border-none outline-none py-2 bg-transparent" - /> -
- setContent(val || '')} - height={600} - preview="edit" - className="border border-gray-200 rounded-lg shadow-sm !font-sans" - /> -
-
- -
-
-

- 카테고리 -

-
- {categories ? renderCategoryOptions(categories) :

로딩 중...

} -
-
- - {/* 🛠️ [Legacy] 태그 입력 UI 비활성화 - 사용자 요청으로 UI에서 숨김 처리 (데이터 구조는 유지) - */} - {/* -
-

- 🏷️ 태그 -

- setTags(e.target.value)} - placeholder="태그 입력 (콤마 , 로 구분)" - className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-100 focus:border-blue-500 outline-none" - /> -

예: React, Next.js, 튜토리얼

-
- */} - -
-

- 이미지 업로드 -

-

- 에디터에 이미지를
복사 & 붙여넣기(Ctrl+V) 하거나
아래 버튼을 사용하세요. -

- - -
-
-
+
+
); } export default function WritePage() { return ( -
}> - +
}> + ); } diff --git a/src/components/admin/AdminCommentsPanel.tsx b/src/components/admin/AdminCommentsPanel.tsx new file mode 100644 index 0000000..45568f0 --- /dev/null +++ b/src/components/admin/AdminCommentsPanel.tsx @@ -0,0 +1,288 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import toast from 'react-hot-toast'; +import { + ChevronLeft, + ChevronRight, + ExternalLink, + Loader2, + MessageSquareText, + Trash2, + X, +} from 'lucide-react'; +import { deleteAdminComment, getAdminComments } from '@/api/comments'; +import { AdminComment, AdminCommentListResponse, PageMeta } from '@/types'; + +const PAGE_SIZE = 10; + +const emptyPageMeta: PageMeta = { + totalElements: 0, + totalPages: 0, + number: 0, + last: true, +}; + +const getErrorMessage = (error: unknown, fallback: string) => { + if (typeof error === 'object' && error !== null && 'response' in error) { + const response = (error as { response?: { data?: { message?: string } } }).response; + if (response?.data?.message) return `${fallback}: ${response.data.message}`; + } + + if (error instanceof Error) return `${fallback}: ${error.message}`; + + return fallback; +}; + +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 formatDate = (value?: string) => { + if (!value) return '-'; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + + return new Intl.DateTimeFormat('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date); +}; + +const getAuthorName = (comment: AdminComment) => { + return comment.author || comment.memberNickname || comment.guestNickname || '익명'; +}; + +function DeleteCommentDialog({ + comment, + isDeleting, + onCancel, + onConfirm, +}: { + comment: AdminComment; + isDeleting: boolean; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( +
+
+
+
+

댓글 삭제

+

+ 관리자 권한으로 댓글을 삭제합니다. 계속 진행할까요? +

+
+ +
+ +
+
+

{comment.content}

+

+ {getAuthorName(comment)} · {formatDate(comment.createdAt)} +

+
+ +
+ + +
+
+
+
+ ); +} + +export default function AdminCommentsPanel() { + const queryClient = useQueryClient(); + const [page, setPage] = useState(0); + const [deleteTarget, setDeleteTarget] = useState(null); + + const { data, isLoading, isFetching } = useQuery({ + queryKey: ['comments', 'admin', { page }], + queryFn: () => getAdminComments(page, PAGE_SIZE), + retry: 0, + }); + + const comments = data?.content ?? []; + const meta = getCommentListMeta(data); + const displayTotalPages = Math.max(meta.totalPages, 1); + const isLastPage = meta.last ?? (page + 1 >= displayTotalPages); + + const deleteMutation = useMutation({ + mutationFn: deleteAdminComment, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['comments'] }); + + if (comments.length === 1 && page > 0) { + setPage((currentPage) => Math.max(0, currentPage - 1)); + } + + setDeleteTarget(null); + toast.success('댓글이 삭제되었습니다.'); + }, + onError: (error) => toast.error(getErrorMessage(error, '댓글 삭제 실패')), + }); + + const handleConfirmDelete = () => { + if (!deleteTarget) return; + deleteMutation.mutate(deleteTarget.id); + }; + + return ( +
+
+
+

+ + 댓글 관리 +

+

최근 댓글을 확인하고 관리자 권한으로 삭제합니다.

+
+ + {meta.totalElements.toLocaleString()}개 + +
+ +
+
+ 작성자 + 내용 + 작성일 + 작업 +
+ + {isLoading ? ( +
+ +
+ ) : comments.length > 0 ? ( +
+ {comments.map((comment) => ( +
+
+

{getAuthorName(comment)}

+

{formatDate(comment.createdAt)}

+
+ +
+

{comment.content}

+ {comment.postTitle && ( +

+ 글: {comment.postTitle} +

+ )} +
+ + {formatDate(comment.createdAt)} + +
+ {comment.postSlug && ( + + + + )} + +
+
+ ))} +
+ ) : ( +
+ +

아직 관리할 댓글이 없습니다.

+

댓글이 작성되면 이곳에 표시됩니다.

+
+ )} +
+ +
+
+ {isFetching && !isLoading ? '댓글을 갱신하는 중입니다.' : `페이지 ${page + 1} / ${displayTotalPages}`} +
+ +
+ + +
+
+ + {deleteTarget && ( + setDeleteTarget(null)} + onConfirm={handleConfirmDelete} + /> + )} +
+ ); +} diff --git a/src/components/admin/AdminPostEditor.tsx b/src/components/admin/AdminPostEditor.tsx new file mode 100644 index 0000000..e6cb7a1 --- /dev/null +++ b/src/components/admin/AdminPostEditor.tsx @@ -0,0 +1,624 @@ +'use client'; + +import axios from 'axios'; +import dynamic from 'next/dynamic'; +import { usePathname, useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import toast from 'react-hot-toast'; +import { + ArrowLeft, + Clock, + FileText, + Folder, + Image as ImageIcon, + Loader2, + Save, + Trash2, + UploadCloud, + X, +} from 'lucide-react'; +import { getCategories } from '@/api/category'; +import { uploadImage } from '@/api/image'; +import { createPost, getPost, updatePost } from '@/api/posts'; +import { useAuthStore } from '@/store/authStore'; +import { ApiResponse, AuthResponse, Category, Post, PostSaveRequest } from '@/types'; + +const MDEditor = dynamic( + () => import('@uiw/react-md-editor').then((mod) => mod.default), + { ssr: false }, +); + +interface DraftPost { + id: number; + title: string; + content: string; + savedAt: string; +} + +interface AdminPostEditorProps { + editSlug?: string; +} + +const getErrorMessage = (error: unknown, fallback: string) => { + if (typeof error === 'object' && error !== null && 'response' in error) { + const response = (error as { response?: { data?: { message?: string } } }).response; + if (response?.data?.message) return `${fallback}: ${response.data.message}`; + } + + if (error instanceof Error) return `${fallback}: ${error.message}`; + + return fallback; +}; + +const findCategoryByName = (categories: Category[], name: string): Category | null => { + for (const category of categories) { + if (category.name === name) return category; + + const found = findCategoryByName(category.children || [], name); + if (found) return found; + } + + return null; +}; + +const isTokenExpired = (token: string) => { + try { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map((character) => `%${(`00${character.charCodeAt(0).toString(16)}`).slice(-2)}`) + .join(''), + ); + const { exp } = JSON.parse(jsonPayload) as { exp?: number }; + + return !exp || Date.now() / 1000 >= exp - 30; + } catch { + return true; + } +}; + +function CategoryOptions({ + categories, + selectedId, + onSelect, + depth = 0, +}: { + categories: Category[]; + selectedId: number | ''; + onSelect: (id: number) => void; + depth?: number; +}) { + return ( + <> + {categories.map((category) => ( +
+ + {category.children?.length > 0 && ( + + )} +
+ ))} + + ); +} + +function DraftLoadDialog({ + draft, + onCancel, + onConfirm, +}: { + draft: DraftPost; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( +
+
+
+

임시저장 불러오기

+

+ 현재 작성 중인 내용이 선택한 임시저장 글로 바뀝니다. +

+
+ +
+
+

{draft.title}

+

{draft.savedAt}

+
+ +
+ + +
+
+
+
+ ); +} + +export default function AdminPostEditor({ editSlug }: AdminPostEditorProps) { + const router = useRouter(); + const pathname = usePathname(); + const queryClient = useQueryClient(); + const { isLoggedIn, role, _hasHydrated, accessToken, refreshToken, login } = useAuthStore(); + const isAdmin = _hasHydrated && role?.includes('ADMIN'); + const isEditMode = Boolean(editSlug); + + const [title, setTitle] = useState(''); + const [content, setContent] = useState('**Hello world!**'); + const [categoryId, setCategoryId] = useState(''); + const [tags, setTags] = useState(''); + const [isUploading, setIsUploading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [drafts, setDrafts] = useState([]); + const [showDraftList, setShowDraftList] = useState(false); + const [draftToLoad, setDraftToLoad] = useState(null); + + useEffect(() => { + if (!_hasHydrated) return; + + if (!isLoggedIn) { + toast.error('로그인이 필요합니다.'); + const currentPath = typeof window === 'undefined' ? pathname : `${window.location.pathname}${window.location.search}`; + router.replace(`/login?redirect=${encodeURIComponent(currentPath || '/admin/posts/new')}`); + return; + } + + if (!isAdmin) { + toast.error('관리자 권한이 필요합니다.'); + router.replace('/'); + } + }, [_hasHydrated, isAdmin, isLoggedIn, pathname, router]); + + useEffect(() => { + const savedDrafts = localStorage.getItem('temp_drafts'); + if (!savedDrafts) return; + + try { + setDrafts(JSON.parse(savedDrafts) as DraftPost[]); + } catch { + localStorage.removeItem('temp_drafts'); + } + }, []); + + const { data: categories = [] } = useQuery({ + queryKey: ['categories'], + queryFn: getCategories, + enabled: isAdmin, + }); + + const { data: existingPost, isLoading: isLoadingPost } = useQuery({ + queryKey: ['post', editSlug], + queryFn: () => getPost(editSlug!), + enabled: isAdmin && isEditMode, + staleTime: 0, + gcTime: 0, + refetchOnMount: 'always', + }); + + useEffect(() => { + if (!existingPost) return; + + setTitle(existingPost.title || ''); + setContent(existingPost.content || ''); + setTags(existingPost.tags ? existingPost.tags.join(', ') : ''); + + if (categories.length > 0 && existingPost.categoryName) { + const found = findCategoryByName(categories, existingPost.categoryName); + if (found) setCategoryId(found.id); + } + }, [existingPost, categories]); + + const saveDrafts = (nextDrafts: DraftPost[]) => { + setDrafts(nextDrafts); + localStorage.setItem('temp_drafts', JSON.stringify(nextDrafts)); + }; + + const handleTempSave = () => { + if (!title.trim() && !content.trim()) { + toast.error('제목이나 내용을 입력해주세요.'); + return; + } + + if (drafts.length >= 10) { + toast.error('임시저장은 최대 10개까지만 가능합니다.\n기존 저장분을 삭제해주세요.'); + setShowDraftList(true); + return; + } + + saveDrafts([ + { + id: Date.now(), + title: title || '(제목 없음)', + content, + savedAt: new Date().toLocaleString(), + }, + ...drafts, + ]); + toast.success('임시저장 되었습니다.'); + }; + + const handleLoadDraft = (draft: DraftPost) => { + setDraftToLoad(draft); + }; + + const confirmLoadDraft = () => { + if (!draftToLoad) return; + + setTitle(draftToLoad.title === '(제목 없음)' ? '' : draftToLoad.title); + setContent(draftToLoad.content); + setShowDraftList(false); + setDraftToLoad(null); + toast.success('불러오기 완료'); + }; + + const handleDeleteDraft = (id: number) => { + saveDrafts(drafts.filter((draft) => draft.id !== id)); + toast.success('삭제되었습니다.'); + }; + + const mutation = useMutation({ + mutationFn: (data: PostSaveRequest) => { + if (isEditMode) { + return updatePost(existingPost!.id, data); + } + + return createPost(data); + }, + onSuccess: async (response: ApiResponse) => { + const savedPost = response.data; + const newSlug = savedPost?.slug || editSlug; + + await queryClient.resetQueries({ queryKey: ['posts'] }); + await queryClient.invalidateQueries({ queryKey: ['categories'] }); + + if (editSlug) { + queryClient.removeQueries({ queryKey: ['post', editSlug] }); + } + if (newSlug && newSlug !== editSlug) { + queryClient.removeQueries({ queryKey: ['post', newSlug] }); + } + + toast.success(isEditMode ? '게시글이 수정되었습니다.' : '게시글이 발행되었습니다.'); + router.push(newSlug ? `/posts/${newSlug}` : '/admin'); + }, + onError: (error) => { + toast.error(getErrorMessage(error, '저장 실패')); + }, + onSettled: () => { + setIsSubmitting(false); + }, + }); + + const ensureAuthToken = async (): Promise => { + if (!accessToken || !refreshToken) { + toast.error('로그인이 필요합니다.'); + return false; + } + + if (!isTokenExpired(accessToken)) return true; + + try { + const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; + const { data } = await axios.post>( + `${baseUrl}/api/auth/reissue`, + { accessToken, refreshToken }, + { headers: { 'Content-Type': 'application/json' }, withCredentials: true }, + ); + + if (data.code === 'SUCCESS' && data.data) { + login(data.data.accessToken, data.data.refreshToken); + return true; + } + + return false; + } catch { + toast.error('세션이 만료되었습니다.\n작성 중인 글을 복사해두고 다시 로그인해주세요.', { duration: 5000 }); + return false; + } + }; + + const appendImageMarkdown = (imageUrl: string) => { + setContent((previous) => `${previous}\n![image](${imageUrl})`); + }; + + const uploadEditorImage = async (file: File, successMessage: string, toastId: string) => { + const isTokenValid = await ensureAuthToken(); + if (!isTokenValid) return; + + const response = await uploadImage(file); + if (response.code === 'SUCCESS' && response.data) { + appendImageMarkdown(response.data); + toast.success(successMessage, { id: toastId }); + } else { + toast.error(response.message || '이미지 업로드 실패', { id: toastId }); + } + }; + + const handleSubmit = async () => { + if (!title.trim() || !content.trim()) { + toast.error('제목과 내용을 입력해주세요.'); + return; + } + if (categoryId === '') { + toast.error('카테고리를 선택해주세요.'); + return; + } + + setIsSubmitting(true); + + const isTokenValid = await ensureAuthToken(); + if (!isTokenValid) { + setIsSubmitting(false); + return; + } + + mutation.mutate({ + title, + content, + categoryId: Number(categoryId), + tags: tags.split(',').map((tag) => tag.trim()).filter(Boolean), + }); + }; + + const handleFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + setIsUploading(true); + const uploadToast = toast.loading('이미지 업로드 중...'); + + try { + await uploadEditorImage(file, '이미지가 업로드되었습니다.', uploadToast); + } catch (error) { + toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast }); + } finally { + setIsUploading(false); + event.target.value = ''; + } + }; + + const handlePaste = async (event: React.ClipboardEvent) => { + const items = event.clipboardData?.items; + if (!items) return; + + for (const item of Array.from(items)) { + if (!item.type.startsWith('image')) continue; + + event.preventDefault(); + const file = item.getAsFile(); + if (!file) return; + + setIsUploading(true); + const uploadToast = toast.loading('이미지 업로드 중...'); + + try { + await uploadEditorImage(file, '이미지 붙여넣기 완료.', uploadToast); + } catch (error) { + toast.error(getErrorMessage(error, '이미지 업로드 실패'), { id: uploadToast }); + } finally { + setIsUploading(false); + } + } + }; + + if (!_hasHydrated || !isAdmin || (isEditMode && isLoadingPost)) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ +

{isEditMode ? '게시글 수정' : '새 글 작성'}

+
+ +
+
+
+ + +
+ + {showDraftList && ( + <> +
setShowDraftList(false)} /> +
+
+

임시저장 목록 ({drafts.length}/10)

+ +
+ +
+ {drafts.length === 0 ? ( +
저장된 글이 없습니다.
+ ) : ( +
    + {drafts.map((draft) => ( +
  • +
    + + +
    +
  • + ))} +
+ )} +
+
+ + )} +
+ + +
+
+ +
+
+ setTitle(event.target.value)} + placeholder="제목을 입력하세요" + className="w-full border-none bg-transparent py-2 text-3xl font-bold outline-none placeholder:text-gray-300" + /> +
+ setContent(value || '')} + height={600} + preview="edit" + className="rounded-lg border border-gray-200 shadow-sm !font-sans" + /> +
+
+ +
+
+

+ + 카테고리 +

+
+ {categories.length > 0 ? ( + + ) : ( +

로딩 중...

+ )} +
+
+ +
+

+ + 이미지 업로드 +

+

+ 에디터에 이미지를
+ 복사 & 붙여넣기(Ctrl+V) 하거나 +
아래 버튼을 사용하세요. +

+ + +
+
+
+ + {draftToLoad && ( + setDraftToLoad(null)} + onConfirm={confirmLoadDraft} + /> + )} +
+ ); +} diff --git a/src/components/admin/AdminPostsPanel.tsx b/src/components/admin/AdminPostsPanel.tsx new file mode 100644 index 0000000..cb1dd83 --- /dev/null +++ b/src/components/admin/AdminPostsPanel.tsx @@ -0,0 +1,670 @@ +'use client'; + +import Link from 'next/link'; +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import toast from 'react-hot-toast'; +import { clsx } from 'clsx'; +import { + ChevronLeft, + ChevronRight, + Edit2, + Eye, + FileText, + Loader2, + Search, + Trash2, + X, +} from 'lucide-react'; +import { getCategories } from '@/api/category'; +import { deletePost, getPosts } from '@/api/posts'; +import { Category, PageMeta, Post, PostListResponse } from '@/types'; + +const PAGE_SIZE = 10; + +const sortOptions = [ + { value: 'createdAt,desc', label: '최신순' }, + { value: 'createdAt,asc', label: '오래된순' }, + { value: 'viewCount,desc', label: '조회수순' }, +]; + +const emptyPageMeta: PageMeta = { + totalPages: 0, + totalElements: 0, + number: 0, + last: true, +}; + +const formatDate = (value?: string) => { + if (!value) return '-'; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + + return new Intl.DateTimeFormat('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(date); +}; + +type CategoryOption = { + id: number; + name: string; + label: string; +}; + +const flattenCategoryOptions = (categories: Category[] = [], depth = 0): CategoryOption[] => { + return categories.flatMap((category) => [ + { + id: category.id, + name: category.name, + label: `${' '.repeat(depth)}${depth > 0 ? '- ' : ''}${category.name}`, + }, + ...flattenCategoryOptions(category.children || [], depth + 1), + ]); +}; + +const getPostListMeta = (data?: PostListResponse): PageMeta => { + if (!data) return emptyPageMeta; + + return { + totalPages: data.page?.totalPages ?? data.totalPages ?? 0, + totalElements: data.page?.totalElements ?? data.totalElements ?? 0, + number: data.page?.number ?? data.number ?? 0, + last: data.page?.last ?? data.last, + }; +}; + +const getErrorMessage = (error: unknown, fallback: string) => { + if (typeof error === 'object' && error !== null && 'response' in error) { + const response = (error as { response?: { data?: { message?: string } } }).response; + if (response?.data?.message) return `${fallback}: ${response.data.message}`; + } + + if (error instanceof Error) return `${fallback}: ${error.message}`; + + return fallback; +}; + +function DeletePostDialog({ + post, + isDeleting, + onCancel, + onConfirm, +}: { + post: Post; + isDeleting: boolean; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( +
+
+
+
+

게시글 삭제

+

+ 삭제한 글은 복구할 수 없습니다. 계속 진행할까요? +

+
+ +
+ +
+
+

{post.title}

+

+ {post.categoryName || '미분류'} · {formatDate(post.createdAt)} +

+
+ +
+ + +
+
+
+
+ ); +} + +function BulkDeletePostsDialog({ + posts, + isDeleting, + onCancel, + onConfirm, +}: { + posts: Post[]; + isDeleting: boolean; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( +
+
+
+
+

게시글 대량 삭제

+

+ 선택한 게시글 {posts.length.toLocaleString()}개를 삭제합니다. 삭제한 글은 복구할 수 없습니다. +

+
+ +
+ +
+
+ {posts.map((post) => ( +
+

{post.title}

+

+ {post.categoryName || '미분류'} · {formatDate(post.createdAt)} +

+
+ ))} +
+ +
+ + +
+
+
+
+ ); +} + +export default function AdminPostsPanel() { + const queryClient = useQueryClient(); + const [page, setPage] = useState(0); + const [pageInput, setPageInput] = useState('1'); + const [keywordInput, setKeywordInput] = useState(''); + const [keyword, setKeyword] = useState(''); + const [sort, setSort] = useState(sortOptions[0].value); + const [categoryName, setCategoryName] = useState(''); + const [selectedPostIds, setSelectedPostIds] = useState>(() => new Set()); + const [deleteTarget, setDeleteTarget] = useState(null); + const [isBulkDeleteOpen, setIsBulkDeleteOpen] = useState(false); + + const { data, isLoading, isFetching } = useQuery({ + queryKey: ['posts', 'admin', 'management', { page, keyword, sort, categoryName }], + queryFn: () => getPosts({ + page, + size: PAGE_SIZE, + keyword: keyword || undefined, + category: categoryName || undefined, + sort, + }), + retry: 0, + }); + + const { data: categories = [] } = useQuery({ + queryKey: ['categories'], + queryFn: getCategories, + retry: 0, + }); + + const posts = useMemo(() => data?.content ?? [], [data?.content]); + const meta = getPostListMeta(data); + const displayTotalPages = Math.max(meta.totalPages, 1); + const isLastPage = meta.last ?? (page + 1 >= displayTotalPages); + const categoryOptions = useMemo(() => flattenCategoryOptions(categories), [categories]); + const selectedPosts = useMemo( + () => posts.filter((post) => selectedPostIds.has(post.id)), + [posts, selectedPostIds], + ); + const allPageSelected = posts.length > 0 && posts.every((post) => selectedPostIds.has(post.id)); + + const resetSelection = () => { + setSelectedPostIds(new Set()); + }; + + const goToPage = (nextPage: number) => { + const boundedPage = Math.min(Math.max(nextPage, 0), displayTotalPages - 1); + setPage(boundedPage); + setPageInput(String(boundedPage + 1)); + resetSelection(); + }; + + const deleteMutation = useMutation({ + mutationFn: deletePost, + onSuccess: async () => { + const deletedPost = deleteTarget; + + await queryClient.invalidateQueries({ queryKey: ['posts'] }); + if (deletedPost?.slug) { + queryClient.removeQueries({ queryKey: ['post', deletedPost.slug] }); + } + + if (posts.length === 1 && page > 0) { + goToPage(page - 1); + } + + resetSelection(); + setDeleteTarget(null); + toast.success('게시글이 삭제되었습니다.'); + }, + onError: (error) => toast.error(getErrorMessage(error, '게시글 삭제 실패')), + }); + + const bulkDeleteMutation = useMutation({ + mutationFn: async (targets: Post[]) => { + await Promise.all(targets.map((post) => deletePost(post.id))); + return targets; + }, + onSuccess: async (deletedPosts) => { + await queryClient.invalidateQueries({ queryKey: ['posts'] }); + deletedPosts.forEach((post) => { + if (post.slug) queryClient.removeQueries({ queryKey: ['post', post.slug] }); + }); + + if (deletedPosts.length >= posts.length && page > 0) { + goToPage(page - 1); + } else { + resetSelection(); + } + + setIsBulkDeleteOpen(false); + toast.success(`게시글 ${deletedPosts.length.toLocaleString()}개가 삭제되었습니다.`); + }, + onError: (error) => toast.error(getErrorMessage(error, '게시글 대량 삭제 실패')), + }); + + const handleSearch = (event: React.FormEvent) => { + event.preventDefault(); + setPage(0); + setPageInput('1'); + resetSelection(); + setKeyword(keywordInput.trim()); + }; + + const handleClearSearch = () => { + setKeywordInput(''); + setKeyword(''); + setPage(0); + setPageInput('1'); + resetSelection(); + }; + + const handleSortChange = (event: React.ChangeEvent) => { + setSort(event.target.value); + setPage(0); + setPageInput('1'); + resetSelection(); + }; + + const handleCategoryChange = (event: React.ChangeEvent) => { + setCategoryName(event.target.value); + setPage(0); + setPageInput('1'); + resetSelection(); + }; + + const handlePageJump = (event: React.FormEvent) => { + event.preventDefault(); + + const parsedPage = Number(pageInput); + if (!Number.isFinite(parsedPage) || parsedPage < 1) { + toast.error('이동할 페이지 번호를 입력해주세요.'); + setPageInput(String(page + 1)); + return; + } + + goToPage(parsedPage - 1); + }; + + const handleConfirmDelete = () => { + if (!deleteTarget) return; + deleteMutation.mutate(deleteTarget.id); + }; + + const handleConfirmBulkDelete = () => { + if (selectedPosts.length === 0) return; + bulkDeleteMutation.mutate(selectedPosts); + }; + + const togglePostSelection = (id: number) => { + setSelectedPostIds((currentIds) => { + const nextIds = new Set(currentIds); + + if (nextIds.has(id)) { + nextIds.delete(id); + } else { + nextIds.add(id); + } + + return nextIds; + }); + }; + + const togglePageSelection = () => { + setSelectedPostIds((currentIds) => { + const nextIds = new Set(currentIds); + + if (allPageSelected) { + posts.forEach((post) => nextIds.delete(post.id)); + } else { + posts.forEach((post) => nextIds.add(post.id)); + } + + return nextIds; + }); + }; + + return ( +
+
+
+

+ + 게시글 관리 +

+

검색, 수정, 삭제를 한 화면에서 처리합니다.

+
+ + + + 새 글 작성 + +
+ +
+ + + + + + {keyword && ( + + )} + + +
+
+ {keyword ? ( + <> + {keyword} 결과{' '} + + ) : null} + {meta.totalElements.toLocaleString()}개 +
+ +
+ {selectedPostIds.size > 0 && ( + + )} + + + + +
+
+
+ +
+
+ + + + 제목 + 카테고리 + 작성일 + 조회수 + 작업 +
+ + {isLoading ? ( +
+ +
+ ) : posts.length > 0 ? ( +
+ {posts.map((post) => ( +
+ + +
+ + {post.title} + +

+ {post.categoryName || '미분류'} · {formatDate(post.createdAt)} · 조회 {post.viewCount.toLocaleString()} +

+
+ + {post.categoryName || '미분류'} + {formatDate(post.createdAt)} + + {post.viewCount.toLocaleString()} + + +
+ + + + + + + +
+
+ ))} +
+ ) : ( +
+ +

+ {keyword ? '검색 조건에 맞는 게시글이 없습니다.' : '아직 작성된 글이 없습니다.'} +

+

새 글을 작성하면 이곳에서 바로 관리할 수 있습니다.

+
+ )} +
+ +
+
+ {isFetching && !isLoading ? '목록을 갱신하는 중입니다.' : `페이지 ${page + 1} / ${displayTotalPages}`} +
+ +
+ +
+ setPageInput(event.target.value)} + disabled={isFetching} + className="h-9 w-20 rounded-full border border-gray-200 px-3 text-center text-sm font-semibold text-gray-700 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:bg-gray-50" + aria-label="이동할 페이지 번호" + /> + +
+ +
+
+ + {deleteTarget && ( + setDeleteTarget(null)} + onConfirm={handleConfirmDelete} + /> + )} + + {isBulkDeleteOpen && selectedPosts.length > 0 && ( + setIsBulkDeleteOpen(false)} + onConfirm={handleConfirmBulkDelete} + /> + )} +
+ ); +} diff --git a/src/components/admin/AdminRouteShell.tsx b/src/components/admin/AdminRouteShell.tsx new file mode 100644 index 0000000..34d2670 --- /dev/null +++ b/src/components/admin/AdminRouteShell.tsx @@ -0,0 +1,86 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import toast from 'react-hot-toast'; +import { BarChart3, FileText, FolderTree, Loader2, MessageSquareText, UserRound } from 'lucide-react'; +import { clsx } from 'clsx'; +import { useAuthStore } from '@/store/authStore'; + +const navItems = [ + { href: '/admin', label: '대시보드', icon: BarChart3, exact: true }, + { href: '/admin/posts', label: '게시글', icon: FileText }, + { href: '/admin/comments', label: '댓글', icon: MessageSquareText }, + { href: '/admin/categories', label: '카테고리', icon: FolderTree }, + { href: '/admin/profile', label: '프로필', icon: UserRound }, +]; + +const getCurrentPath = (pathname: string) => { + if (typeof window === 'undefined') return pathname || '/admin'; + + return `${window.location.pathname}${window.location.search}`; +}; + +const getLoginRedirectPath = (pathname: string) => { + const currentPath = getCurrentPath(pathname); + return `/login?redirect=${encodeURIComponent(currentPath || '/admin')}`; +}; + +export default function AdminRouteShell({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const pathname = usePathname(); + const { isLoggedIn, role, _hasHydrated } = useAuthStore(); + const isAdmin = _hasHydrated && role?.includes('ADMIN'); + + useEffect(() => { + if (!_hasHydrated) return; + + if (!isLoggedIn) { + router.replace(getLoginRedirectPath(pathname)); + return; + } + + if (!isAdmin) { + toast.error('관리자 권한이 필요합니다.'); + router.replace('/'); + } + }, [_hasHydrated, isAdmin, isLoggedIn, pathname, router]); + + if (!_hasHydrated || !isAdmin) { + return ( +
+ +
+ ); + } + + return ( +
+ + + {children} +
+ ); +} diff --git a/src/components/comment/CommentItem.tsx b/src/components/comment/CommentItem.tsx index ba6ac1d..7704af3 100644 --- a/src/components/comment/CommentItem.tsx +++ b/src/components/comment/CommentItem.tsx @@ -4,9 +4,9 @@ import { useState } from 'react'; import { Comment } from '@/types'; import { useAuthStore } from '@/store/authStore'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { deleteComment, deleteAdminComment } from '@/api/comments'; +import { deleteComment } from '@/api/comments'; import { format } from 'date-fns'; -import { User, CheckCircle2, Trash2, MessageSquare, UserCheck, ShieldAlert, X } from 'lucide-react'; // X 아이콘 추가 +import { User, CheckCircle2, Trash2, MessageSquare, UserCheck, X } from 'lucide-react'; // X 아이콘 추가 import CommentForm from './CommentForm'; import { clsx } from 'clsx'; import toast from 'react-hot-toast'; // 🎨 Toast 추가 @@ -17,8 +17,19 @@ interface CommentItemProps { depth?: number; } +const getErrorMessage = (error: unknown, fallback: string) => { + if (typeof error === 'object' && error !== null && 'response' in error) { + const response = (error as { response?: { data?: { message?: string } } }).response; + if (response?.data?.message) return response.data.message; + } + + if (error instanceof Error) return error.message; + + return fallback; +}; + export default function CommentItem({ comment, postSlug, depth = 0 }: CommentItemProps) { - const { isLoggedIn, role, user } = useAuthStore(); + const { isLoggedIn, user } = useAuthStore(); const queryClient = useQueryClient(); const [isReplying, setIsReplying] = useState(false); @@ -26,7 +37,6 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte const [isDeleting, setIsDeleting] = useState(false); const [guestPassword, setGuestPassword] = useState(''); - const isAdmin = isLoggedIn && role?.includes('ADMIN'); const isGuestComment = !comment.memberId; const isMyComment = isLoggedIn && !isGuestComment && ( @@ -34,7 +44,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte (user?.nickname === comment.author) ); - const showDeleteButton = isAdmin || isGuestComment || isMyComment; + const showDeleteButton = isGuestComment || isMyComment; const deleteMutation = useMutation({ mutationFn: ({ id, password }: { id: number; password?: string }) => deleteComment(id, password), @@ -42,32 +52,12 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte queryClient.invalidateQueries({ queryKey: ['comments', postSlug] }); toast.success('댓글이 삭제되었습니다.'); }, - onError: (err: any) => { - toast.error('삭제 실패: ' + (err.response?.data?.message || '비밀번호가 틀렸습니다.')); - }, - }); - - const adminDeleteMutation = useMutation({ - mutationFn: deleteAdminComment, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['comments', postSlug] }); - toast.success('관리자 권한으로 삭제했습니다.'); - }, - onError: (err: any) => { - toast.error('관리자 삭제 실패: ' + (err.response?.data?.message || err.message)); + onError: (error) => { + toast.error(`삭제 실패: ${getErrorMessage(error, '비밀번호가 틀렸습니다.')}`); }, }); const handleDeleteClick = () => { - // A. 관리자 -> 즉시 삭제 (컨펌만) - if (isAdmin) { - if (confirm('관리자 권한으로 삭제하시겠습니까?')) { - adminDeleteMutation.mutate(comment.id); - } - return; - } - - // B. 내 댓글 -> 즉시 삭제 (컨펌만) if (isMyComment) { if (confirm('이 댓글을 삭제하시겠습니까?')) { deleteMutation.mutate({ id: comment.id }); @@ -138,11 +128,11 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte className={clsx( "p-1.5 rounded transition-colors", isDeleting ? "bg-red-50 text-red-600" : - isAdmin ? "text-red-400 hover:text-red-600 hover:bg-red-50" : "text-gray-400 hover:text-red-600 hover:bg-red-50" + "text-gray-400 hover:text-red-600 hover:bg-red-50" )} - title={isAdmin ? "관리자 삭제" : "삭제"} + title="삭제" > - {isAdmin ? : } + )}
@@ -204,4 +194,4 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte )}
); -} \ No newline at end of file +} diff --git a/src/components/layout/TopHeader.tsx b/src/components/layout/TopHeader.tsx index 338c002..328d1ea 100644 --- a/src/components/layout/TopHeader.tsx +++ b/src/components/layout/TopHeader.tsx @@ -36,7 +36,7 @@ export default function TopHeader() { diff --git a/src/components/post/PostDetailClient.tsx b/src/components/post/PostDetailClient.tsx index bc50c9a..10ba8d2 100644 --- a/src/components/post/PostDetailClient.tsx +++ b/src/components/post/PostDetailClient.tsx @@ -1,14 +1,13 @@ 'use client'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getPost, deletePost } from '@/api/posts'; +import { useQuery } from '@tanstack/react-query'; +import { getPost } from '@/api/posts'; import { getProfile } from '@/api/profile'; import MarkdownRenderer from '@/components/post/MarkdownRenderer'; import CommentList from '@/components/comment/CommentList'; import TOC from '@/components/post/TOC'; -import { Loader2, Calendar, Eye, Folder, User, Edit2, Trash2, ArrowLeft, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Loader2, Calendar, Eye, Folder, User, ArrowLeft, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { useAuthStore } from '@/store/authStore'; import Link from 'next/link'; import { Post } from '@/types'; // 타입 임포트 @@ -17,11 +16,24 @@ interface PostDetailClientProps { initialPost: Post; // 🌟 서버에서 넘겨받는 초기 데이터 (필수) } +const getPostErrorInfo = (error: unknown) => { + if (typeof error === 'object' && error !== null && 'response' in error) { + const response = (error as { response?: { status?: number; data?: { message?: string } } }).response; + + return { + status: response?.status, + message: response?.data?.message, + }; + } + + return { + status: undefined, + message: error instanceof Error ? error.message : undefined, + }; +}; + export default function PostDetailClient({ slug, initialPost }: PostDetailClientProps) { const router = useRouter(); - const queryClient = useQueryClient(); - const { role, _hasHydrated } = useAuthStore(); - const isAdmin = _hasHydrated && role?.includes('ADMIN'); // 1. 게시글 상세 조회 const { data: post, isLoading: isPostLoading, error } = useQuery({ @@ -37,18 +49,6 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient queryFn: getProfile, }); - const deleteMutation = useMutation({ - mutationFn: deletePost, - onSuccess: () => { - alert('게시글이 삭제되었습니다.'); - queryClient.invalidateQueries({ queryKey: ['posts'] }); - router.push('/'); - }, - onError: (err: any) => { - alert('삭제 실패: ' + (err.response?.data?.message || err.message)); - }, - }); - // 🌟 Loading 상태 처리를 제거하거나 조건을 완화합니다. // initialData가 있으면 isLoading은 false가 되므로 바로 아래 컨텐츠가 렌더링됩니다. if (isPostLoading) { @@ -61,8 +61,8 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient // 에러 처리 if (error || !post) { - const errorStatus = (error as any)?.response?.status; - const errorMessage = (error as any)?.response?.data?.message || error?.message || '게시글을 찾을 수 없습니다.'; + const { status: errorStatus, message } = getPostErrorInfo(error); + const errorMessage = message || '게시글을 찾을 수 없습니다.'; const isAuthError = errorStatus === 401 || errorStatus === 403; return ( @@ -87,16 +87,6 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient const prevPost = post.prevPost; const nextPost = post.nextPost; - const handleDelete = () => { - if (confirm('정말로 이 게시글을 삭제하시겠습니까? 복구할 수 없습니다.')) { - deleteMutation.mutate(post.id); - } - }; - - const handleEdit = () => { - router.push(`/write?slug=${post.slug}`); - }; - return (
@@ -114,12 +104,6 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient {post.categoryName || 'Uncategorized'}
- {isAdmin && ( -
- - -
- )}

{post.title}

@@ -165,4 +149,4 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
); -} \ No newline at end of file +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..64cf301 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); +const HSTS_HEADER = 'max-age=31536000; includeSubDomains'; + +const getForwardedProtocol = (request: NextRequest) => { + const forwardedProtocol = request.headers.get('x-forwarded-proto'); + if (forwardedProtocol) { + return forwardedProtocol.split(',')[0]?.trim().toLowerCase(); + } + + return request.nextUrl.protocol.replace(':', '').toLowerCase(); +}; + +const getRequestHost = (request: NextRequest) => { + const forwardedHost = request.headers.get('x-forwarded-host'); + if (forwardedHost) { + return forwardedHost.split(',')[0]?.trim(); + } + + return request.headers.get('host') || request.nextUrl.host; +}; + +const getHostname = (host: string) => { + if (host.startsWith('[')) { + return host.slice(1, host.indexOf(']')); + } + + return host.split(':')[0]; +}; + +const getRedirectHost = (host: string) => { + if (host.startsWith('[')) { + return host.slice(0, host.indexOf(']') + 1); + } + + return getHostname(host); +}; + +const isLocalRequest = (hostname: string) => { + return LOCAL_HOSTNAMES.has(hostname) || hostname.endsWith('.localhost'); +}; + +export function proxy(request: NextRequest) { + const protocol = getForwardedProtocol(request); + const requestHost = getRequestHost(request); + const hostname = getHostname(requestHost); + + if (!isLocalRequest(hostname) && protocol === 'http') { + const redirectUrl = request.nextUrl.clone(); + redirectUrl.protocol = 'https:'; + redirectUrl.host = getRedirectHost(requestHost); + redirectUrl.port = ''; + + return NextResponse.redirect(redirectUrl, 301); + } + + const response = NextResponse.next(); + + if (!isLocalRequest(hostname)) { + response.headers.set('Strict-Transport-Security', HSTS_HEADER); + } + + return response; +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], +}; diff --git a/src/types/index.ts b/src/types/index.ts index 8cdcf7f..12a2d99 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -26,12 +26,17 @@ export interface Post { nextPost?: PostNeighbor | null; } -// 3. 게시글 목록 페이징 응답 -export interface PostListResponse { - content: Post[]; +export interface PageMeta { totalPages: number; totalElements: number; - last: boolean; + number?: number; + last?: boolean; +} + +// 3. 게시글 목록 페이징 응답 +export interface PostListResponse extends PageMeta { + content: Post[]; + page?: PageMeta; } export interface PostSaveRequest { @@ -136,3 +141,19 @@ export interface CommentSaveRequest { export interface CommentDeleteRequest { guestPassword?: string; } + +export interface AdminComment { + id: number; + content: string; + author?: string; + guestNickname?: string; + memberNickname?: string; + postSlug?: string; + postTitle?: string; + createdAt: string; +} + +export interface AdminCommentListResponse extends PageMeta { + content: AdminComment[]; + page?: PageMeta; +}