All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m10s
672 lines
25 KiB
TypeScript
672 lines
25 KiB
TypeScript
'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 (
|
|
<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 (
|
|
<WindowSurface title="Posts" subtitle="Management" bodyClassName="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}
|
|
/>
|
|
)}
|
|
</WindowSurface>
|
|
);
|
|
}
|