chore: add deployment config

This commit is contained in:
ParkWonYeop
2025-12-27 15:45:12 +09:00
parent 87405e897e
commit 906cad6952
27 changed files with 12273 additions and 1543 deletions

View File

@@ -1,89 +1,243 @@
// src/app/posts/[slug]/page.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { getPostBySlug } from '@/api/posts';
import { use, useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getPost, deletePost, getPostsByCategory } from '@/api/posts'; // 👈 목록 조회 API 추가
import { getProfile } from '@/api/profile';
import MarkdownRenderer from '@/components/post/MarkdownRenderer';
import { format } from 'date-fns';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, Calendar, User } from 'lucide-react';
import CommentList from '@/components/comment/CommentList';
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';
export default function PostDetailPage() {
const { slug } = useParams(); // URL에서 slug 가져오기
export default function PostDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params);
const router = useRouter();
const queryClient = useQueryClient();
const { role, _hasHydrated } = useAuthStore();
// 데이터 조회
const { data: post, isLoading, isError } = useQuery({
const isAdmin = _hasHydrated && role?.includes('ADMIN');
// 1. 현재 게시글 상세 조회
const { data: post, isLoading: isPostLoading, error } = useQuery({
queryKey: ['post', slug],
queryFn: () => getPostBySlug(slug as string),
enabled: !!slug, // slug가 있을 때만 실행
queryFn: () => getPost(slug),
enabled: !!slug,
retry: 1,
});
if (isLoading) return <div className="text-center py-20 animate-pulse"> ... </div>;
if (isError || !post) {
// 2. 🆕 이전/다음 글을 찾기 위해 "같은 카테고리의 글 목록"을 조회합니다.
// (백엔드에서 prev/next를 안 주므로 프론트에서 리스트를 가져와서 계산하는 방식)
const { data: neighborPosts } = useQuery({
queryKey: ['posts', 'category', post?.categoryName],
// 💡 성능을 위해 적당한 사이즈(예: 100개)만 가져옵니다.
// 게시글이 아주 많다면 이 범위를 벗어난 과거 글에서는 네비게이션이 안 보일 수 있는 한계가 있습니다.
queryFn: () => getPostsByCategory(post!.categoryName, 0, 100),
enabled: !!post?.categoryName, // 게시글 로딩이 끝난 후 실행
staleTime: 1000 * 60 * 5, // 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="text-center py-20">
<h2 className="text-2xl font-bold mb-4"> . 😭</h2>
<button onClick={() => router.back()} className="text-blue-500 hover:underline">
</button>
<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>
{isAuthError && (
<button
onClick={() => router.push('/login')}
className="px-5 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
)}
</div>
</div>
);
}
// 3. 🆕 리스트에서 현재 글의 위치를 찾아 이전/다음 글 결정
// (리스트는 기본적으로 최신순(DESC) 정렬이므로 인덱스가 작을수록 최신글입니다)
const currentIndex = neighborPosts?.content.findIndex((p) => p.id === post.id) ?? -1;
// 인덱스가 0보다 크면 앞에 더 최신 글이 있다는 뜻 (Next Post)
const newerPost = (currentIndex > 0 && neighborPosts)
? neighborPosts.content[currentIndex - 1]
: null;
// 인덱스가 마지막이 아니면 뒤에 더 과거 글이 있다는 뜻 (Previous Post)
const olderPost = (currentIndex !== -1 && neighborPosts && currentIndex < neighborPosts.content.length - 1)
? neighborPosts.content[currentIndex + 1]
: null;
const handleDelete = () => {
if (confirm('정말로 이 게시글을 삭제하시겠습니까? 복구할 수 없습니다.')) {
deleteMutation.mutate(post.id);
}
};
const handleEdit = () => {
router.push(`/write?slug=${post.slug}`);
};
return (
<article className="max-w-3xl mx-auto pb-20">
{/* 1. 뒤로가기 버튼 */}
<button
onClick={() => router.back()}
className="flex items-center text-gray-500 hover:text-gray-900 mb-8 transition-colors"
>
<ArrowLeft size={20} className="mr-2" />
</button>
<div className="max-w-4xl mx-auto px-4 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>
{/* 2. 헤더 영역 (카테고리, 제목, 날짜) */}
<header className="mb-10 text-center">
<span className="inline-block bg-blue-100 text-blue-700 text-sm font-semibold px-3 py-1 rounded-full mb-4">
{post.categoryName}
</span>
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-6 leading-tight">
{post.title}
</h1>
<div className="flex items-center justify-center gap-6 text-gray-500 text-sm">
<div className="flex items-center gap-2">
<User size={16} />
<span>Dev Park</span>
<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"
title="게시글 수정"
>
<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"
title="게시글 삭제"
>
<Trash2 size={18} />
</button>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Calendar size={16} />
<time>{format(new Date(post.createdAt), 'yyyy년 MM월 dd일')}</time>
<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>
</header>
{/* 구분선 */}
<hr className="border-gray-200 my-10" />
</article>
{/* 3. 본문 영역 (마크다운) */}
<div className="bg-white p-6 md:p-10 rounded-2xl shadow-sm border border-gray-100 min-h-[300px]">
{/* 본문이 있으면 렌더링, 없으면 안내 문구 */}
{post.content ? (
<MarkdownRenderer content={post.content} />
{/* 4. 🆕 이전/다음 글 네비게이션 영역 (개선됨) */}
<nav className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-b border-gray-100 py-8 mb-16">
{/* 이전 글 (과거 글) */}
{olderPost ? (
<Link
href={`/posts/${olderPost.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">
{olderPost.title}
</span>
</Link>
) : (
<p className="text-gray-400 italic"> .</p>
<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>
)}
</div>
{/* 4. 하단 댓글 영역 (추후 구현 예정 자리) */}
<div className="mt-16">
<h3 className="text-xl font-bold mb-6"></h3>
<div className="bg-gray-50 rounded-xl p-10 text-center text-gray-400">
! 🚀
</div>
</div>
</article>
{/* 다음 글 (최신 글) */}
{newerPost ? (
<Link
href={`/posts/${newerPost.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">
{newerPost.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} />
</div>
);
}