This commit is contained in:
18
LOG.md
18
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 `<img>` 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.
|
||||
|
||||
@@ -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. 마크다운 렌더러 개선 상세
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
|
||||
@@ -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<AdminCommentListResponse, 'content'> & {
|
||||
content: RawAdminComment[];
|
||||
};
|
||||
|
||||
// 1. 댓글 목록 조회
|
||||
export const getComments = async (postSlug: string) => {
|
||||
@@ -24,12 +60,62 @@ 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<ApiResponse<any>>('/api/admin/comments', {
|
||||
const response = await http.get<ApiResponse<RawAdminCommentListResponse | RawAdminComment[]>>('/api/admin/comments', {
|
||||
params: { page, size },
|
||||
});
|
||||
return response.data.data;
|
||||
return normalizeAdminComments(response.data.data);
|
||||
};
|
||||
|
||||
// 5. 관리자 댓글 강제 삭제
|
||||
|
||||
5
src/app/admin/categories/page.tsx
Normal file
5
src/app/admin/categories/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminCategoryPanel from '@/components/admin/AdminCategoryPanel';
|
||||
|
||||
export default function AdminCategoriesPage() {
|
||||
return <AdminCategoryPanel />;
|
||||
}
|
||||
5
src/app/admin/comments/page.tsx
Normal file
5
src/app/admin/comments/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminCommentsPanel from '@/components/admin/AdminCommentsPanel';
|
||||
|
||||
export default function AdminCommentsPage() {
|
||||
return <AdminCommentsPanel />;
|
||||
}
|
||||
5
src/app/admin/layout.tsx
Normal file
5
src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminRouteShell from '@/components/admin/AdminRouteShell';
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AdminRouteShell>{children}</AdminRouteShell>;
|
||||
}
|
||||
@@ -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 (
|
||||
<Link
|
||||
@@ -61,63 +80,69 @@ function PostRow({ post }: { post: Post }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CommentRow({ comment }: { comment: AdminComment }) {
|
||||
const content = (
|
||||
<>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="line-clamp-2 text-sm leading-6 text-gray-700">{comment.content}</p>
|
||||
<p className="mt-1 truncate text-xs text-gray-400">
|
||||
{getAuthorName(comment)} · {comment.postTitle || '게시글 정보 없음'} · {formatDate(comment.createdAt, true)}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="shrink-0 text-gray-300" size={15} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (comment.postSlug) {
|
||||
return (
|
||||
<Link
|
||||
href={`/posts/${comment.postSlug}`}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-3 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="flex items-center gap-3 rounded-lg px-3 py-3">{content}</div>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={36} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div className="mx-auto max-w-6xl space-y-8 px-1 py-4 md:px-4">
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-col justify-between gap-5 border-b border-gray-200 pb-8 md:flex-row md:items-end">
|
||||
<div>
|
||||
<p className="mb-3 inline-flex items-center gap-2 rounded-full border border-blue-100 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
|
||||
@@ -125,23 +150,32 @@ export default function AdminPage() {
|
||||
Admin
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold tracking-normal text-gray-950 md:text-4xl">
|
||||
관리자 설정
|
||||
관리자 대시보드
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-gray-500">
|
||||
공개 화면과 분리된 운영 공간입니다. 게시글 현황을 확인하고 프로필과 카테고리를 정리할 수 있습니다.
|
||||
공개 화면과 분리된 운영 공간입니다. 게시글, 댓글, 카테고리, 프로필 관리는 상단 메뉴에서 바로 이동합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/write"
|
||||
href="/admin/posts"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full border border-gray-200 bg-white px-5 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
<FileText size={17} />
|
||||
게시글 관리
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/posts/new"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-gray-800"
|
||||
>
|
||||
<PenLine size={17} />
|
||||
새 글 작성
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between text-gray-500">
|
||||
<span className="text-sm font-medium">총 게시글</span>
|
||||
@@ -158,6 +192,14 @@ export default function AdminPage() {
|
||||
<p className="mt-4 text-3xl font-bold text-gray-950">{totalCategories.toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between text-gray-500">
|
||||
<span className="text-sm font-medium">총 댓글</span>
|
||||
<MessageSquareText size={18} />
|
||||
</div>
|
||||
<p className="mt-4 text-3xl font-bold text-gray-950">{totalComments.toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between text-gray-500">
|
||||
<span className="text-sm font-medium">최근 업데이트</span>
|
||||
@@ -167,7 +209,7 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(280px,0.8fr)]">
|
||||
<section className="grid gap-6 xl:grid-cols-3">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -175,10 +217,10 @@ export default function AdminPage() {
|
||||
<p className="mt-1 text-sm text-gray-500">발행 흐름을 빠르게 확인합니다.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/archive"
|
||||
href="/admin/posts"
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
전체보기
|
||||
관리
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -220,11 +262,38 @@ export default function AdminPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
<AdminProfilePanel />
|
||||
<AdminCategoryPanel />
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-950">최근 댓글 5개</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">새 반응을 대시보드에서 바로 봅니다.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/comments"
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
관리
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isCommentsLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||
</div>
|
||||
) : recentComments.length > 0 ? (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{recentComments.map((comment) => (
|
||||
<CommentRow key={comment.id} comment={comment} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-gray-50 px-4 py-12 text-center text-sm text-gray-400">
|
||||
아직 작성된 댓글이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
13
src/app/admin/posts/[slug]/edit/page.tsx
Normal file
13
src/app/admin/posts/[slug]/edit/page.tsx
Normal file
@@ -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 <AdminPostEditor editSlug={slug} />;
|
||||
}
|
||||
5
src/app/admin/posts/new/page.tsx
Normal file
5
src/app/admin/posts/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminPostEditor from '@/components/admin/AdminPostEditor';
|
||||
|
||||
export default function NewAdminPostPage() {
|
||||
return <AdminPostEditor />;
|
||||
}
|
||||
5
src/app/admin/posts/page.tsx
Normal file
5
src/app/admin/posts/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminPostsPanel from '@/components/admin/AdminPostsPanel';
|
||||
|
||||
export default function AdminPostsPage() {
|
||||
return <AdminPostsPanel />;
|
||||
}
|
||||
5
src/app/admin/profile/page.tsx
Normal file
5
src/app/admin/profile/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminProfilePanel from '@/components/admin/AdminProfilePanel';
|
||||
|
||||
export default function AdminProfilePage() {
|
||||
return <AdminProfilePanel />;
|
||||
}
|
||||
@@ -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
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-4 bg-white">
|
||||
<div className="w-full max-w-md bg-white rounded-2xl shadow-xl overflow-hidden p-8 border border-gray-100">
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-white p-4">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-gray-100 bg-white p-8 shadow-xl">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900">로그인</h1>
|
||||
<p className="text-gray-500 mt-2">블로그에 오신 것을 환영합니다.</p>
|
||||
<p className="mt-2 text-gray-500">블로그에 오신 것을 환영합니다.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">이메일</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
|
||||
{error && <p className="text-center text-sm text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors disabled:bg-blue-400 flex justify-center items-center gap-2"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 py-3 font-bold text-white transition-colors hover:bg-blue-700 disabled:bg-blue-400"
|
||||
>
|
||||
{loading && <Loader2 className="animate-spin" size={20} />}
|
||||
로그인
|
||||
@@ -86,7 +104,7 @@ export default function LoginPage() {
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-500">
|
||||
계정이 없으신가요?{' '}
|
||||
<Link href="/signup" className="text-blue-600 font-medium hover:underline">
|
||||
<Link href="/signup" className="font-medium text-blue-600 hover:underline">
|
||||
회원가입
|
||||
</Link>
|
||||
</div>
|
||||
@@ -94,3 +112,17 @@ export default function LoginPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={36} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
useEffect(() => {
|
||||
const editSlug = searchParams.get('slug');
|
||||
const isEditMode = !!editSlug;
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('**Hello world!**');
|
||||
const [categoryId, setCategoryId] = useState<number | ''>('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 💾 임시저장 관련 상태
|
||||
const [drafts, setDrafts] = useState<DraftPost[]>([]);
|
||||
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<boolean> => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 = ``;
|
||||
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 = ``;
|
||||
setContent((prev) => prev + '\n' + markdownImage);
|
||||
toast.success('이미지 붙여넣기 완료!', { id: uploadToast });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('이미지 업로드 실패', { id: uploadToast });
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditMode && isLoadingPost) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen">
|
||||
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderCategoryOptions = (cats: any[], depth = 0) => {
|
||||
return cats.map((cat) => (
|
||||
<div key={cat.id}>
|
||||
<label className="flex items-center gap-2 p-2 hover:bg-gray-50 cursor-pointer rounded transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="category"
|
||||
value={cat.id}
|
||||
checked={categoryId === cat.id}
|
||||
onChange={(e) => setCategoryId(Number(e.target.value))}
|
||||
className="text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span style={{ marginLeft: depth * 10 + 'px' }} className={depth === 0 ? 'font-medium' : 'text-gray-600'}>
|
||||
{depth > 0 && '- '} {cat.name}
|
||||
</span>
|
||||
</label>
|
||||
{cat.children && renderCategoryOptions(cat.children, depth + 1)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
router.replace(editSlug ? `/admin/posts/${editSlug}/edit` : '/admin/posts/new');
|
||||
}, [router, searchParams]);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8" onPaste={onPaste}>
|
||||
{/* 상단 헤더 영역 */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => router.back()} className="p-2 hover:bg-gray-100 rounded-full text-gray-500 transition-colors">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-800">{isEditMode ? '게시글 수정' : '새 글 작성'}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 💾 임시저장 버튼 그룹 */}
|
||||
<div className="relative">
|
||||
<div className="flex items-center bg-white border border-gray-300 rounded-lg shadow-sm">
|
||||
<button
|
||||
onClick={handleTempSave}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 flex items-center gap-2 border-r border-gray-300 rounded-l-lg transition-colors"
|
||||
title="현재 내용 임시저장"
|
||||
>
|
||||
<FileText size={16} className="text-gray-500" />
|
||||
<span className="hidden sm:inline">임시저장</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDraftList(!showDraftList)}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 rounded-r-lg transition-colors flex items-center gap-1"
|
||||
title="임시저장 목록 보기"
|
||||
>
|
||||
<span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded text-xs font-bold">
|
||||
{drafts.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 💾 임시저장 목록 팝업 */}
|
||||
{showDraftList && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setShowDraftList(false)}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-2 w-80 bg-white border border-gray-200 rounded-xl shadow-xl z-20 overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
|
||||
<h3 className="font-bold text-gray-700 text-sm">임시저장 목록 ({drafts.length}/10)</h3>
|
||||
<button onClick={() => setShowDraftList(false)}><X size={16} className="text-gray-400 hover:text-gray-600" /></button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{drafts.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400 text-sm">
|
||||
저장된 글이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{drafts.map((draft) => (
|
||||
<li key={draft.id} className="p-3 hover:bg-blue-50 transition-colors group">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<button
|
||||
onClick={() => handleLoadDraft(draft)}
|
||||
className="flex-1 text-left"
|
||||
>
|
||||
<p className="font-medium text-gray-800 text-sm line-clamp-1 mb-1 group-hover:text-blue-600">
|
||||
{draft.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-400">
|
||||
<Clock size={12} />
|
||||
{draft.savedAt}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteDraft(draft.id); }}
|
||||
className="p-1.5 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || isUploading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors disabled:bg-gray-400 shadow-md hover:shadow-lg transform active:scale-95 duration-200"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="animate-spin" size={18} /> : <Save size={18} />}
|
||||
{isEditMode ? '수정하기' : '작성하기'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="제목을 입력하세요"
|
||||
className="w-full text-3xl font-bold placeholder:text-gray-300 border-none outline-none py-2 bg-transparent"
|
||||
/>
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
value={content}
|
||||
onChange={(val) => setContent(val || '')}
|
||||
height={600}
|
||||
preview="edit"
|
||||
className="border border-gray-200 rounded-lg shadow-sm !font-sans"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-6">
|
||||
<h3 className="font-bold text-gray-700 mb-3 flex items-center gap-2">
|
||||
<Folder size={18} /> 카테고리
|
||||
</h3>
|
||||
<div className="max-h-60 overflow-y-auto space-y-1 text-sm border-t border-gray-100 pt-2 scrollbar-thin scrollbar-thumb-gray-200">
|
||||
{categories ? renderCategoryOptions(categories) : <p className="text-gray-400 text-sm">로딩 중...</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🛠️ [Legacy] 태그 입력 UI 비활성화
|
||||
사용자 요청으로 UI에서 숨김 처리 (데이터 구조는 유지)
|
||||
*/}
|
||||
{/*
|
||||
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-[280px]">
|
||||
<h3 className="font-bold text-gray-700 mb-3 flex items-center gap-2">
|
||||
🏷️ 태그
|
||||
</h3>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-2">예: React, Next.js, 튜토리얼</p>
|
||||
</div>
|
||||
*/}
|
||||
|
||||
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm sticky top-[280px]">
|
||||
<h3 className="font-bold text-gray-700 mb-3 flex items-center gap-2">
|
||||
<ImageIcon size={18} /> 이미지 업로드
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-3 leading-relaxed">
|
||||
에디터에 이미지를 <br/><strong>복사 & 붙여넣기(Ctrl+V)</strong> 하거나<br/> 아래 버튼을 사용하세요.
|
||||
</p>
|
||||
|
||||
<label
|
||||
className={`flex flex-col items-center justify-center w-full h-24 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50 hover:border-blue-400 transition-all duration-200 ${isUploading ? 'opacity-50 cursor-wait' : ''}`}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<UploadCloud className="w-8 h-8 text-gray-400 mb-2 group-hover:text-blue-500" />
|
||||
<p className="text-xs text-gray-500 font-medium">
|
||||
{isUploading ? '업로드 중...' : '클릭하여 이미지 선택'}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={36} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WritePage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex justify-center items-center h-screen"><Loader2 className="animate-spin text-blue-500" /></div>}>
|
||||
<WritePageContent />
|
||||
<Suspense fallback={<div className="flex min-h-[60vh] items-center justify-center"><Loader2 className="animate-spin text-blue-500" /></div>}>
|
||||
<LegacyWriteRedirect />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
288
src/components/admin/AdminCommentsPanel.tsx
Normal file
288
src/components/admin/AdminCommentsPanel.tsx
Normal file
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-gray-950/35 px-4 py-6 backdrop-blur-sm sm:items-center">
|
||||
<div className="w-full max-w-md rounded-xl border border-gray-200 bg-white shadow-xl">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-gray-100 p-5">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-950">댓글 삭제</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-gray-500">
|
||||
관리자 권한으로 댓글을 삭제합니다. 계속 진행할까요?
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 disabled:cursor-not-allowed"
|
||||
aria-label="댓글 삭제 닫기"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="rounded-lg border border-red-100 bg-red-50 px-4 py-3">
|
||||
<p className="line-clamp-3 text-sm font-semibold text-red-950">{comment.content}</p>
|
||||
<p className="mt-2 text-xs text-red-700">
|
||||
{getAuthorName(comment)} · {formatDate(comment.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
className="inline-flex items-center justify-center rounded-full border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isDeleting}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-red-300"
|
||||
>
|
||||
{isDeleting ? <Loader2 className="animate-spin" size={15} /> : <Trash2 size={15} />}
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminCommentsPanel() {
|
||||
const queryClient = useQueryClient();
|
||||
const [page, setPage] = useState(0);
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminComment | null>(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 (
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="mb-5 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
|
||||
<MessageSquareText size={19} />
|
||||
댓글 관리
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">최근 댓글을 확인하고 관리자 권한으로 삭제합니다.</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-gray-50 px-3 py-1 text-sm font-semibold text-gray-500">
|
||||
{meta.totalElements.toLocaleString()}개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||
<div className="hidden grid-cols-[120px_minmax(0,1fr)_180px_96px] gap-3 border-b border-gray-100 bg-gray-50 px-4 py-3 text-xs font-bold text-gray-500 md:grid">
|
||||
<span>작성자</span>
|
||||
<span>내용</span>
|
||||
<span>작성일</span>
|
||||
<span className="text-right">작업</span>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-48 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||
</div>
|
||||
) : comments.length > 0 ? (
|
||||
<div className={isFetching ? 'divide-y divide-gray-100 opacity-70' : 'divide-y divide-gray-100'}>
|
||||
{comments.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="grid gap-3 px-4 py-4 transition hover:bg-gray-50 md:grid-cols-[120px_minmax(0,1fr)_180px_96px] md:items-center"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-gray-800">{getAuthorName(comment)}</p>
|
||||
<p className="mt-1 text-xs text-gray-400 md:hidden">{formatDate(comment.createdAt)}</p>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="line-clamp-2 text-sm leading-6 text-gray-700">{comment.content}</p>
|
||||
{comment.postTitle && (
|
||||
<p className="mt-1 truncate text-xs text-gray-400">
|
||||
글: {comment.postTitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="hidden text-sm text-gray-500 md:block">{formatDate(comment.createdAt)}</span>
|
||||
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{comment.postSlug && (
|
||||
<Link
|
||||
href={`/posts/${comment.postSlug}`}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-400 transition hover:bg-gray-100 hover:text-gray-700"
|
||||
aria-label="댓글이 달린 글 보기"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteTarget(comment)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-400 transition hover:bg-red-50 hover:text-red-600"
|
||||
aria-label="댓글 삭제"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-16 text-center">
|
||||
<MessageSquareText className="mx-auto mb-3 text-gray-300" size={42} />
|
||||
<p className="text-sm font-semibold text-gray-700">아직 관리할 댓글이 없습니다.</p>
|
||||
<p className="mt-2 text-sm text-gray-400">댓글이 작성되면 이곳에 표시됩니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
{isFetching && !isLoading ? '댓글을 갱신하는 중입니다.' : `페이지 ${page + 1} / ${displayTotalPages}`}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((currentPage) => Math.max(0, currentPage - 1))}
|
||||
disabled={page === 0 || isFetching}
|
||||
className="inline-flex items-center justify-center gap-1 rounded-full border border-gray-200 px-3 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft size={15} />
|
||||
이전
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((currentPage) => currentPage + 1)}
|
||||
disabled={isLastPage || isFetching}
|
||||
className="inline-flex items-center justify-center gap-1 rounded-full border border-gray-200 px-3 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
다음
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deleteTarget && (
|
||||
<DeleteCommentDialog
|
||||
comment={deleteTarget}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
624
src/components/admin/AdminPostEditor.tsx
Normal file
624
src/components/admin/AdminPostEditor.tsx
Normal file
@@ -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) => (
|
||||
<div key={category.id}>
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded p-2 transition-colors hover:bg-gray-50">
|
||||
<input
|
||||
type="radio"
|
||||
name="category"
|
||||
value={category.id}
|
||||
checked={selectedId === category.id}
|
||||
onChange={(event) => onSelect(Number(event.target.value))}
|
||||
className="text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span
|
||||
style={{ marginLeft: `${depth * 10}px` }}
|
||||
className={depth === 0 ? 'font-medium' : 'text-gray-600'}
|
||||
>
|
||||
{depth > 0 && '- '}
|
||||
{category.name}
|
||||
</span>
|
||||
</label>
|
||||
{category.children?.length > 0 && (
|
||||
<CategoryOptions
|
||||
categories={category.children}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftLoadDialog({
|
||||
draft,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
draft: DraftPost;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-gray-950/35 px-4 py-6 backdrop-blur-sm sm:items-center">
|
||||
<div className="w-full max-w-md rounded-xl border border-gray-200 bg-white shadow-xl">
|
||||
<div className="border-b border-gray-100 p-5">
|
||||
<h3 className="text-lg font-bold text-gray-950">임시저장 불러오기</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-gray-500">
|
||||
현재 작성 중인 내용이 선택한 임시저장 글로 바뀝니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<p className="line-clamp-2 text-sm font-semibold text-gray-950">{draft.title}</p>
|
||||
<p className="mt-1 text-xs text-gray-500">{draft.savedAt}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="inline-flex items-center justify-center rounded-full border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="inline-flex items-center justify-center rounded-full bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700"
|
||||
>
|
||||
불러오기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<number | ''>('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [drafts, setDrafts] = useState<DraftPost[]>([]);
|
||||
const [showDraftList, setShowDraftList] = useState(false);
|
||||
const [draftToLoad, setDraftToLoad] = useState<DraftPost | null>(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<Post>) => {
|
||||
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<boolean> => {
|
||||
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<ApiResponse<AuthResponse>>(
|
||||
`${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`);
|
||||
};
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8" onPaste={handlePaste}>
|
||||
<div className="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/admin/posts')}
|
||||
className="rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100"
|
||||
aria-label="게시글 관리로 돌아가기"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-800">{isEditMode ? '게시글 수정' : '새 글 작성'}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className="flex items-center rounded-lg border border-gray-300 bg-white shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTempSave}
|
||||
className="flex items-center gap-2 rounded-l-lg border-r border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
title="현재 내용 임시저장"
|
||||
>
|
||||
<FileText size={16} className="text-gray-500" />
|
||||
<span className="hidden sm:inline">임시저장</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDraftList(!showDraftList)}
|
||||
className="flex items-center gap-1 rounded-r-lg px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
title="임시저장 목록 보기"
|
||||
>
|
||||
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-xs font-bold text-gray-600">
|
||||
{drafts.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDraftList && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowDraftList(false)} />
|
||||
<div className="absolute right-0 top-full z-20 mt-2 w-80 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 bg-gray-50 px-4 py-3">
|
||||
<h3 className="text-sm font-bold text-gray-700">임시저장 목록 ({drafts.length}/10)</h3>
|
||||
<button type="button" onClick={() => setShowDraftList(false)} aria-label="임시저장 목록 닫기">
|
||||
<X size={16} className="text-gray-400 hover:text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{drafts.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-gray-400">저장된 글이 없습니다.</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{drafts.map((draft) => (
|
||||
<li key={draft.id} className="group p-3 transition-colors hover:bg-blue-50">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<button type="button" onClick={() => handleLoadDraft(draft)} className="flex-1 text-left">
|
||||
<p className="mb-1 line-clamp-1 text-sm font-medium text-gray-800 group-hover:text-blue-600">
|
||||
{draft.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-400">
|
||||
<Clock size={12} />
|
||||
{draft.savedAt}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteDraft(draft.id)}
|
||||
className="rounded p-1.5 text-gray-300 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || isUploading}
|
||||
className="flex items-center gap-2 rounded-lg bg-blue-600 px-5 py-2.5 font-bold text-white shadow-md transition-colors duration-200 hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="animate-spin" size={18} /> : <Save size={18} />}
|
||||
{isEditMode ? '수정하기' : '작성하기'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
||||
<div className="space-y-4 lg:col-span-3">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="제목을 입력하세요"
|
||||
className="w-full border-none bg-transparent py-2 text-3xl font-bold outline-none placeholder:text-gray-300"
|
||||
/>
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
value={content}
|
||||
onChange={(value) => setContent(value || '')}
|
||||
height={600}
|
||||
preview="edit"
|
||||
className="rounded-lg border border-gray-200 shadow-sm !font-sans"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="sticky top-6 rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 flex items-center gap-2 font-bold text-gray-700">
|
||||
<Folder size={18} />
|
||||
카테고리
|
||||
</h3>
|
||||
<div className="max-h-60 space-y-1 overflow-y-auto border-t border-gray-100 pt-2 text-sm">
|
||||
{categories.length > 0 ? (
|
||||
<CategoryOptions categories={categories} selectedId={categoryId} onSelect={setCategoryId} />
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">로딩 중...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky top-[280px] rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 flex items-center gap-2 font-bold text-gray-700">
|
||||
<ImageIcon size={18} />
|
||||
이미지 업로드
|
||||
</h3>
|
||||
<p className="mb-3 text-xs leading-relaxed text-gray-500">
|
||||
에디터에 이미지를 <br />
|
||||
<strong>복사 & 붙여넣기(Ctrl+V)</strong> 하거나
|
||||
<br /> 아래 버튼을 사용하세요.
|
||||
</p>
|
||||
|
||||
<label
|
||||
className={`flex h-24 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 transition-all duration-200 hover:border-blue-400 hover:bg-gray-50 ${isUploading ? 'cursor-wait opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center pb-6 pt-5">
|
||||
<UploadCloud className="mb-2 h-8 w-8 text-gray-400" />
|
||||
<p className="text-xs font-medium text-gray-500">
|
||||
{isUploading ? '업로드 중...' : '클릭하여 이미지 선택'}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draftToLoad && (
|
||||
<DraftLoadDialog
|
||||
draft={draftToLoad}
|
||||
onCancel={() => setDraftToLoad(null)}
|
||||
onConfirm={confirmLoadDraft}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
670
src/components/admin/AdminPostsPanel.tsx
Normal file
670
src/components/admin/AdminPostsPanel.tsx
Normal file
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-gray-950/35 px-4 py-6 backdrop-blur-sm sm:items-center">
|
||||
<div className="w-full max-w-md rounded-xl border border-gray-200 bg-white shadow-xl">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-gray-100 p-5">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-950">게시글 삭제</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-gray-500">
|
||||
삭제한 글은 복구할 수 없습니다. 계속 진행할까요?
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 disabled:cursor-not-allowed"
|
||||
aria-label="삭제 확인 닫기"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="rounded-lg border border-red-100 bg-red-50 px-4 py-3">
|
||||
<p className="line-clamp-2 text-sm font-semibold text-red-950">{post.title}</p>
|
||||
<p className="mt-1 text-xs text-red-700">
|
||||
{post.categoryName || '미분류'} · {formatDate(post.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
className="inline-flex items-center justify-center rounded-full border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isDeleting}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-red-300"
|
||||
>
|
||||
{isDeleting ? <Loader2 className="animate-spin" size={15} /> : <Trash2 size={15} />}
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkDeletePostsDialog({
|
||||
posts,
|
||||
isDeleting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
posts: Post[];
|
||||
isDeleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-gray-950/35 px-4 py-6 backdrop-blur-sm sm:items-center">
|
||||
<div className="w-full max-w-lg rounded-xl border border-gray-200 bg-white shadow-xl">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-gray-100 p-5">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-950">게시글 대량 삭제</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-gray-500">
|
||||
선택한 게시글 {posts.length.toLocaleString()}개를 삭제합니다. 삭제한 글은 복구할 수 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
className="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 disabled:cursor-not-allowed"
|
||||
aria-label="대량 삭제 확인 닫기"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="max-h-52 overflow-y-auto rounded-lg border border-red-100 bg-red-50">
|
||||
{posts.map((post) => (
|
||||
<div key={post.id} className="border-b border-red-100 px-4 py-3 last:border-b-0">
|
||||
<p className="line-clamp-1 text-sm font-semibold text-red-950">{post.title}</p>
|
||||
<p className="mt-1 text-xs text-red-700">
|
||||
{post.categoryName || '미분류'} · {formatDate(post.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
className="inline-flex items-center justify-center rounded-full border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isDeleting}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-red-300"
|
||||
>
|
||||
{isDeleting ? <Loader2 className="animate-spin" size={15} /> : <Trash2 size={15} />}
|
||||
선택 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<Set<number>>(() => new Set());
|
||||
const [deleteTarget, setDeleteTarget] = useState<Post | null>(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<HTMLSelectElement>) => {
|
||||
setSort(event.target.value);
|
||||
setPage(0);
|
||||
setPageInput('1');
|
||||
resetSelection();
|
||||
};
|
||||
|
||||
const handleCategoryChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
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 (
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="mb-5 flex flex-col justify-between gap-4 md:flex-row md:items-end">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-950">
|
||||
<FileText size={19} />
|
||||
게시글 관리
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">검색, 수정, 삭제를 한 화면에서 처리합니다.</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/admin/posts/new"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-2 text-sm font-semibold text-white transition hover:bg-gray-800"
|
||||
>
|
||||
<Edit2 size={15} />
|
||||
새 글 작성
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<form onSubmit={handleSearch} className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<label className="relative block min-w-0 flex-1">
|
||||
<span className="sr-only">게시글 검색</span>
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={16} />
|
||||
<input
|
||||
value={keywordInput}
|
||||
onChange={(event) => setKeywordInput(event.target.value)}
|
||||
placeholder="제목 또는 본문 검색"
|
||||
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2 pl-9 pr-3 text-sm outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-full bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700"
|
||||
>
|
||||
검색
|
||||
</button>
|
||||
|
||||
{keyword && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearSearch}
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-full px-3 py-2 text-sm font-semibold text-gray-500 transition hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between lg:justify-end">
|
||||
<div className="text-sm text-gray-500">
|
||||
{keyword ? (
|
||||
<>
|
||||
<span className="font-semibold text-gray-900">{keyword}</span> 결과{' '}
|
||||
</>
|
||||
) : null}
|
||||
<span className="font-semibold text-gray-900">{meta.totalElements.toLocaleString()}</span>개
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{selectedPostIds.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsBulkDeleteOpen(true)}
|
||||
disabled={isFetching}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full border border-red-100 bg-red-50 px-3 py-2 text-sm font-semibold text-red-600 transition hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
선택 삭제 {selectedPostIds.size.toLocaleString()}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="sr-only">카테고리 필터</span>
|
||||
<select
|
||||
value={categoryName}
|
||||
onChange={handleCategoryChange}
|
||||
className="rounded-full border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-700 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="">전체 카테고리</option>
|
||||
{categoryOptions.map((category) => (
|
||||
<option key={category.id} value={category.name}>
|
||||
{category.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="sr-only">게시글 정렬</span>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={handleSortChange}
|
||||
className="rounded-full border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-700 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||
<div className="hidden grid-cols-[44px_minmax(0,1fr)_130px_110px_84px_132px] gap-3 border-b border-gray-100 bg-gray-50 px-4 py-3 text-xs font-bold text-gray-500 md:grid">
|
||||
<span className="flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allPageSelected}
|
||||
onChange={togglePageSelection}
|
||||
disabled={posts.length === 0 || isFetching}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="현재 페이지 게시글 전체 선택"
|
||||
/>
|
||||
</span>
|
||||
<span>제목</span>
|
||||
<span>카테고리</span>
|
||||
<span>작성일</span>
|
||||
<span className="text-right">조회수</span>
|
||||
<span className="text-right">작업</span>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-64 items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={28} />
|
||||
</div>
|
||||
) : posts.length > 0 ? (
|
||||
<div className={clsx('divide-y divide-gray-100', isFetching && 'opacity-70')}>
|
||||
{posts.map((post) => (
|
||||
<div
|
||||
key={post.id}
|
||||
className="grid gap-3 px-4 py-4 transition hover:bg-gray-50 md:grid-cols-[44px_minmax(0,1fr)_130px_110px_84px_132px] md:items-center"
|
||||
>
|
||||
<label className="flex items-start md:items-center md:justify-center">
|
||||
<span className="sr-only">{post.title} 선택</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPostIds.has(post.id)}
|
||||
onChange={() => togglePostSelection(post.id)}
|
||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 md:mt-0"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/posts/${post.slug}`}
|
||||
className="line-clamp-2 text-sm font-semibold text-gray-950 transition hover:text-blue-600"
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
<p className="mt-1 text-xs text-gray-400 md:hidden">
|
||||
{post.categoryName || '미분류'} · {formatDate(post.createdAt)} · 조회 {post.viewCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="hidden truncate text-sm text-gray-500 md:block">{post.categoryName || '미분류'}</span>
|
||||
<span className="hidden text-sm text-gray-500 md:block">{formatDate(post.createdAt)}</span>
|
||||
<span className="hidden text-right text-sm tabular-nums text-gray-500 md:block">
|
||||
{post.viewCount.toLocaleString()}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link
|
||||
href={`/posts/${post.slug}`}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-400 transition hover:bg-gray-100 hover:text-gray-700"
|
||||
aria-label={`${post.title} 보기`}
|
||||
>
|
||||
<Eye size={16} />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/posts/${post.slug}/edit`}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-400 transition hover:bg-blue-50 hover:text-blue-600"
|
||||
aria-label={`${post.title} 수정`}
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteTarget(post)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-gray-400 transition hover:bg-red-50 hover:text-red-600"
|
||||
aria-label={`${post.title} 삭제`}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-16 text-center">
|
||||
<FileText className="mx-auto mb-3 text-gray-300" size={42} />
|
||||
<p className="text-sm font-semibold text-gray-700">
|
||||
{keyword ? '검색 조건에 맞는 게시글이 없습니다.' : '아직 작성된 글이 없습니다.'}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-gray-400">새 글을 작성하면 이곳에서 바로 관리할 수 있습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
{isFetching && !isLoading ? '목록을 갱신하는 중입니다.' : `페이지 ${page + 1} / ${displayTotalPages}`}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToPage(page - 1)}
|
||||
disabled={page === 0 || isFetching}
|
||||
className="inline-flex items-center justify-center gap-1 rounded-full border border-gray-200 px-3 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft size={15} />
|
||||
이전
|
||||
</button>
|
||||
<form onSubmit={handlePageJump} className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={displayTotalPages}
|
||||
value={pageInput}
|
||||
onChange={(event) => 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="이동할 페이지 번호"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isFetching}
|
||||
className="inline-flex h-9 items-center justify-center rounded-full border border-gray-200 px-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
이동
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToPage(page + 1)}
|
||||
disabled={isLastPage || isFetching}
|
||||
className="inline-flex items-center justify-center gap-1 rounded-full border border-gray-200 px-3 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
다음
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deleteTarget && (
|
||||
<DeletePostDialog
|
||||
post={deleteTarget}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBulkDeleteOpen && selectedPosts.length > 0 && (
|
||||
<BulkDeletePostsDialog
|
||||
posts={selectedPosts}
|
||||
isDeleting={bulkDeleteMutation.isPending}
|
||||
onCancel={() => setIsBulkDeleteOpen(false)}
|
||||
onConfirm={handleConfirmBulkDelete}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
86
src/components/admin/AdminRouteShell.tsx
Normal file
86
src/components/admin/AdminRouteShell.tsx
Normal file
@@ -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 (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={36} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6 px-1 py-4 md:px-4">
|
||||
<nav className="flex gap-2 overflow-x-auto border-b border-gray-200 pb-3" aria-label="관리자 메뉴">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
'inline-flex shrink-0 items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition',
|
||||
isActive
|
||||
? 'bg-gray-950 text-white'
|
||||
: 'border border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-950',
|
||||
)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 ? <ShieldAlert size={14} /> : <Trash2 size={14} />}
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function TopHeader() {
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/write"
|
||||
href="/admin/posts/new"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-bold rounded-full shadow-md hover:bg-blue-700 hover:shadow-lg transition-all transform hover:-translate-y-0.5"
|
||||
>
|
||||
<PenLine size={16} />
|
||||
|
||||
@@ -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 (
|
||||
<div className="max-w-screen-2xl mx-auto px-4 md:px-8 py-12">
|
||||
<Link href="/" className="inline-flex items-center gap-1 text-gray-500 hover:text-blue-600 mb-8 transition-colors">
|
||||
@@ -114,12 +104,6 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
||||
<Folder size={14} />
|
||||
<span>{post.categoryName || 'Uncategorized'}</span>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleEdit} className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-full transition-colors"><Edit2 size={18} /></button>
|
||||
<button onClick={handleDelete} className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-full transition-colors"><Trash2 size={18} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6 leading-tight break-keep">{post.title}</h1>
|
||||
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-500">
|
||||
|
||||
69
src/proxy.ts
Normal file
69
src/proxy.ts
Normal file
@@ -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).*)'],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user