'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 WindowSurface from '@/components/ui/WindowSurface'; import { Category, PageMeta, Post, PostListResponse } from '@/types'; const PAGE_SIZE = 10; const sortOptions = [ { value: 'createdAt,desc', label: '최신순' }, { value: 'createdAt,asc', label: '오래된순' }, { value: 'viewCount,desc', label: '조회수순' }, ]; const emptyPageMeta: PageMeta = { totalPages: 0, totalElements: 0, number: 0, last: true, }; const formatDate = (value?: string) => { if (!value) return '-'; const date = new Date(value); if (Number.isNaN(date.getTime())) return '-'; return new Intl.DateTimeFormat('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', }).format(date); }; type CategoryOption = { id: number; name: string; label: string; }; const flattenCategoryOptions = (categories: Category[] = [], depth = 0): CategoryOption[] => { return categories.flatMap((category) => [ { id: category.id, name: category.name, label: `${' '.repeat(depth)}${depth > 0 ? '- ' : ''}${category.name}`, }, ...flattenCategoryOptions(category.children || [], depth + 1), ]); }; const getPostListMeta = (data?: PostListResponse): PageMeta => { if (!data) return emptyPageMeta; return { totalPages: data.page?.totalPages ?? data.totalPages ?? 0, totalElements: data.page?.totalElements ?? data.totalElements ?? 0, number: data.page?.number ?? data.number ?? 0, last: data.page?.last ?? data.last, }; }; const getErrorMessage = (error: unknown, fallback: string) => { if (typeof error === 'object' && error !== null && 'response' in error) { const response = (error as { response?: { data?: { message?: string } } }).response; if (response?.data?.message) return `${fallback}: ${response.data.message}`; } if (error instanceof Error) return `${fallback}: ${error.message}`; return fallback; }; function DeletePostDialog({ post, isDeleting, onCancel, onConfirm, }: { post: Post; isDeleting: boolean; onCancel: () => void; onConfirm: () => void; }) { return (

게시글 삭제

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

{post.title}

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

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

게시글 대량 삭제

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

{posts.map((post) => (

{post.title}

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

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

게시글 관리

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

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

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

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

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

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

)}
{isFetching && !isLoading ? '목록을 갱신하는 중입니다.' : `페이지 ${page + 1} / ${displayTotalPages}`}
setPageInput(event.target.value)} disabled={isFetching} className="h-9 w-20 rounded-full border border-gray-200 px-3 text-center text-sm font-semibold text-gray-700 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:bg-gray-50" aria-label="이동할 페이지 번호" />
{deleteTarget && ( setDeleteTarget(null)} onConfirm={handleConfirmDelete} /> )} {isBulkDeleteOpen && selectedPosts.length > 0 && ( setIsBulkDeleteOpen(false)} onConfirm={handleConfirmBulkDelete} /> )}
); }