'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} 결과{' '}
>
) : 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}`}
{deleteTarget && (
setDeleteTarget(null)}
onConfirm={handleConfirmDelete}
/>
)}
{isBulkDeleteOpen && selectedPosts.length > 0 && (
setIsBulkDeleteOpen(false)}
onConfirm={handleConfirmBulkDelete}
/>
)}
);
}