Files
blog-frontend/src/components/post/PostDetailClient.tsx
ParkWonYeop bfda1240bd
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 1m50s
feat: 목차 추가, 포스팅 조회 개선
2025-12-28 04:16:36 +09:00

181 lines
8.8 KiB
TypeScript

'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getPost, deletePost } from '@/api/posts'; // 🛠️ getPostsByCategory 제거
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 { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/authStore';
import Link from 'next/link';
interface PostDetailClientProps {
slug: string;
}
export default function PostDetailClient({ slug }: PostDetailClientProps) {
const router = useRouter();
const queryClient = useQueryClient();
const { role, _hasHydrated } = useAuthStore();
const isAdmin = _hasHydrated && role?.includes('ADMIN');
// 1. 게시글 상세 조회 (이제 여기에 prevPost, nextPost가 포함됨)
const { data: post, isLoading: isPostLoading, error } = useQuery({
queryKey: ['post', slug],
queryFn: () => getPost(slug),
enabled: !!slug,
retry: 1,
});
// 🗑️ 삭제됨: 더 이상 프론트에서 앞뒤 글을 찾기 위해 목록을 조회할 필요가 없음!
/* const { data: neighborPosts } = useQuery({
queryKey: ['posts', 'category', post?.categoryName],
queryFn: () => getPostsByCategory(post!.categoryName, 0, 100),
enabled: !!post?.categoryName,
staleTime: 1000 * 60 * 5,
});
*/
const { data: profile } = useQuery({
queryKey: ['profile'],
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));
},
});
if (isPostLoading) {
return (
<div className="flex justify-center items-center h-screen">
<Loader2 className="animate-spin text-blue-500" size={40} />
</div>
);
}
if (error || !post) {
const errorStatus = (error as any)?.response?.status;
const errorMessage = (error as any)?.response?.data?.message || error?.message || '게시글을 찾을 수 없습니다.';
const isAuthError = errorStatus === 401 || errorStatus === 403;
return (
<div className="max-w-4xl mx-auto px-4 py-20 text-center">
<div className="flex justify-center mb-4">
<AlertCircle className="text-gray-300" size={64} />
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-2">
{isAuthError ? '접근 권한이 없습니다.' : '게시글을 불러올 수 없습니다.'}
</h2>
<p className="text-gray-500 mb-6">
{isAuthError ? '로그인이 필요하거나 비공개 게시글일 수 있습니다.' : errorMessage}
</p>
<div className="flex justify-center gap-3">
<button onClick={() => router.push('/')} className="px-5 py-2.5 bg-gray-100 text-gray-700 font-medium rounded-lg hover:bg-gray-200 transition-colors"></button>
</div>
</div>
);
}
// 🗑️ 삭제됨: 인덱스 계산 로직 제거
/*
const currentIndex = neighborPosts?.content.findIndex((p) => p.id === post.id) ?? -1;
const newerPost = ...
const olderPost = ...
*/
// 🆕 백엔드 데이터 직접 사용
// 보통 '이전 글'은 과거 글(prev), '다음 글'은 최신 글(next)입니다.
// 백엔드 구현에 따라 prevPost/nextPost 위치가 반대일 수 있으니 확인 후 위치만 바꿔주세요.
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">
<ArrowLeft size={18} />
<span className="text-sm font-medium"></span>
</Link>
<div className="flex flex-col xl:flex-row gap-8 xl:gap-16 relative">
<main className="min-w-0 xl:flex-1">
<article>
<header className="mb-10 border-b border-gray-100 pb-8">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-2 text-sm text-blue-600 font-medium bg-blue-50 px-3 py-1 rounded-full">
<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">
<div className="flex items-center gap-2">
{profile?.imageUrl ? <img src={profile.imageUrl} alt="Author" className="w-8 h-8 rounded-full object-cover border border-gray-100 shadow-sm" /> : <div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center"><User size={16} /></div>}
<span className="font-bold text-gray-800">{profile?.name || 'Dev Park'}</span>
</div>
<div className="flex items-center gap-1.5"><Calendar size={16} />{new Date(post.createdAt).toLocaleDateString()}</div>
<div className="flex items-center gap-1.5"><Eye size={16} />{post.viewCount} views</div>
</div>
</header>
<div className="prose prose-lg max-w-none prose-headings:font-bold prose-a:text-blue-600 prose-img:rounded-2xl prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100 mb-20">
<MarkdownRenderer content={post.content || ''} />
</div>
</article>
{/* 🛠️ 네비게이션 영역 수정: prevPost / nextPost 직접 사용 */}
<nav className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-b border-gray-100 py-8 mb-16">
{prevPost ? (
<Link href={`/posts/${prevPost.slug}`} className="group flex flex-col items-start gap-1 p-5 rounded-2xl bg-gray-50 hover:bg-blue-50 transition-colors w-full border border-transparent hover:border-blue-100">
<span className="text-xs font-bold text-gray-400 uppercase flex items-center gap-1 group-hover:text-blue-600 transition-colors"><ChevronLeft size={16} /> </span>
<span className="font-bold text-gray-700 group-hover:text-blue-700 transition-colors line-clamp-1 w-full text-left">{prevPost.title}</span>
</Link>
) : <div className="hidden md:block p-5 rounded-2xl bg-gray-50/50 w-full opacity-50 cursor-not-allowed"><span className="text-xs font-bold text-gray-300 uppercase flex items-center gap-1"><ChevronLeft size={16} /> </span></div>}
{nextPost ? (
<Link href={`/posts/${nextPost.slug}`} className="group flex flex-col items-end gap-1 p-5 rounded-2xl bg-gray-50 hover:bg-blue-50 transition-colors w-full border border-transparent hover:border-blue-100">
<span className="text-xs font-bold text-gray-400 uppercase flex items-center gap-1 group-hover:text-blue-600 transition-colors"> <ChevronRight size={16} /></span>
<span className="font-bold text-gray-700 group-hover:text-blue-700 transition-colors line-clamp-1 w-full text-right">{nextPost.title}</span>
</Link>
) : <div className="hidden md:flex flex-col items-end gap-1 p-5 rounded-2xl bg-gray-50/50 w-full opacity-50 cursor-not-allowed"><span className="text-xs font-bold text-gray-300 uppercase flex items-center gap-1"> <ChevronRight size={16} /></span></div>}
</nav>
<CommentList postSlug={post.slug} />
</main>
<aside className="hidden 2xl:block w-[220px] shrink-0">
<div className="sticky top-24">
<TOC content={post.content || ''} />
</div>
</aside>
</div>
</div>
);
}