.
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m10s

This commit is contained in:
wypark
2026-05-28 19:29:54 +09:00
parent f4481b88cb
commit 58a012621a
23 changed files with 2169 additions and 679 deletions

View 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>
);
}

View 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![image](${imageUrl})`);
};
const uploadEditorImage = async (file: File, successMessage: string, toastId: string) => {
const isTokenValid = await ensureAuthToken();
if (!isTokenValid) return;
const response = await uploadImage(file);
if (response.code === 'SUCCESS' && response.data) {
appendImageMarkdown(response.data);
toast.success(successMessage, { id: toastId });
} else {
toast.error(response.message || '이미지 업로드 실패', { id: toastId });
}
};
const handleSubmit = async () => {
if (!title.trim() || !content.trim()) {
toast.error('제목과 내용을 입력해주세요.');
return;
}
if (categoryId === '') {
toast.error('카테고리를 선택해주세요.');
return;
}
setIsSubmitting(true);
const isTokenValid = await ensureAuthToken();
if (!isTokenValid) {
setIsSubmitting(false);
return;
}
mutation.mutate({
title,
content,
categoryId: Number(categoryId),
tags: tags.split(',').map((tag) => tag.trim()).filter(Boolean),
});
};
const handleFileChange = async (event: React.ChangeEvent<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>
);
}

View 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>
);
}

View 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>
);
}