.
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 1m47s

This commit is contained in:
ParkWonYeop
2025-12-28 02:31:53 +09:00
parent 4c0f792a01
commit 5b4759bf7a
3 changed files with 251 additions and 241 deletions

View File

@@ -1,9 +1,9 @@
// src/app/layout.tsx
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import './globals.css'; import './globals.css';
import Providers from './providers'; import Providers from './providers';
import Sidebar from '@/components/layout/Sidebar'; import Sidebar from '@/components/layout/Sidebar';
import TopHeader from '@/components/layout/TopHeader'; // 👈 import 추가 import TopHeader from '@/components/layout/TopHeader';
import Script from 'next/script'; // 👈 Script 컴포넌트 임포트
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'WYPark Blog', title: 'WYPark Blog',
@@ -21,18 +21,32 @@ export default function RootLayout({
<meta name="google-site-verification" content="cFJSK1ayy2Y4lqRKNv8wZ_vybg5De22zYCdbKSfvAJA" /> <meta name="google-site-verification" content="cFJSK1ayy2Y4lqRKNv8wZ_vybg5De22zYCdbKSfvAJA" />
</head> </head>
<body className="bg-[#f8f9fa] text-gray-800"> <body className="bg-[#f8f9fa] text-gray-800">
{/* 🌟 Google Analytics 스크립트 추가 */}
{/* strategy="afterInteractive"는 페이지가 로드된 직후 스크립트를 실행하여 성능을 최적화합니다. */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-2GLCM9ZKMK"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-2GLCM9ZKMK');
`}
</Script>
<Providers> <Providers>
<div className="min-h-screen flex"> <div className="min-h-screen flex">
{/* 사이드바 */} {/* 사이드바 */}
<Sidebar /> <Sidebar />
{/* 메인 영역 */} {/* 메인 영역 */}
<main className="flex-1 transition-all duration-300 md:ml-72 w-full relative"> {/* 👈 relative 확인 */} <main className="flex-1 transition-all duration-300 md:ml-72 w-full relative">
{/* 👇 여기에 TopHeader 추가! */}
<TopHeader /> <TopHeader />
<div className="max-w-4xl mx-auto p-6 md:p-12 pt-20"> {/* 👈 상단 여백(pt-20) 살짝 추가 */} <div className="max-w-4xl mx-auto p-6 md:p-12 pt-20">
{children} {children}
</div> </div>
</main> </main>

View File

@@ -1,243 +1,82 @@
'use client'; import { Metadata } from 'next';
import PostDetailClient from '@/components/post/PostDetailClient';
import { use, useEffect, useState } from 'react'; // 서버 사이드 메타데이터 생성을 위한 타입
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; type Props = {
import { getPost, deletePost, getPostsByCategory } from '@/api/posts'; // 👈 목록 조회 API 추가 params: Promise<{ slug: string }>;
import { getProfile } from '@/api/profile';
import MarkdownRenderer from '@/components/post/MarkdownRenderer';
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({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params);
const router = useRouter();
const queryClient = useQueryClient();
const { role, _hasHydrated } = useAuthStore();
const isAdmin = _hasHydrated && role?.includes('ADMIN');
// 1. 현재 게시글 상세 조회
const { data: post, isLoading: isPostLoading, error } = useQuery({
queryKey: ['post', slug],
queryFn: () => getPost(slug),
enabled: !!slug,
retry: 1,
});
// 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="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 = () => { // 메타데이터용 데이터 패칭 (src/api/http.ts를 거치지 않고 직접 호출)
router.push(`/write?slug=${post.slug}`); async function getPostForMetadata(slug: string) {
}; const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
return ( try {
<div className="max-w-4xl mx-auto px-4 py-12"> const res = await fetch(`${BASE_URL}/api/posts/${slug}`, {
<Link href="/" className="inline-flex items-center gap-1 text-gray-500 hover:text-blue-600 mb-8 transition-colors"> // 메타데이터는 캐시해도 되지만, 글 수정 시 반영을 위해 적절한 시간 설정
<ArrowLeft size={18} /> next: { revalidate: 60 },
<span className="text-sm font-medium"></span> });
</Link>
<article> if (!res.ok) return null;
<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 && ( const json = await res.json();
<div className="flex gap-2"> return json.data;
<button } catch (error) {
onClick={handleEdit} console.error('Metadata fetch error:', error);
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-full transition-colors" return null;
title="게시글 수정" }
> }
<Edit2 size={18} />
</button> // 🌟 핵심: 동적 메타데이터 생성 (SEO & 카톡 공유 미리보기)
<button export async function generateMetadata({ params }: Props): Promise<Metadata> {
onClick={handleDelete} const { slug } = await params;
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-full transition-colors" const post = await getPostForMetadata(slug);
title="게시글 삭제"
> if (!post) {
<Trash2 size={18} /> return {
</button> title: '게시글을 찾을 수 없습니다',
</div> };
)} }
</div>
// 본문 내용을 150자로 잘라서 설명으로 사용 (마크다운 제거)
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6 leading-tight break-keep"> const description = post.content
{post.title} ?.replace(/[#*`_~]/g, '')
</h1> .replace(/\n/g, ' ')
.substring(0, 150) + '...';
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-500">
<div className="flex items-center gap-2"> // 썸네일 이미지 추출 (본문에 이미지가 있다면 첫 번째 이미지 사용)
{profile?.imageUrl ? ( const imageMatch = post.content?.match(/!\[.*?\]\((.*?)\)/);
<img src={profile.imageUrl} alt="Author" className="w-8 h-8 rounded-full object-cover border border-gray-100 shadow-sm" /> const imageUrl = imageMatch ? imageMatch[1] : '/og-image.png'; // 기본 이미지
) : (
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center"> return {
<User size={16} /> title: post.title,
</div> description: description,
)} openGraph: {
<span className="font-bold text-gray-800">{profile?.name || 'Dev Park'}</span> title: post.title,
</div> description: description,
url: `https://blog.wypark.me/posts/${slug}`,
<div className="flex items-center gap-1.5"> siteName: 'WYPark Blog',
<Calendar size={16} /> images: [
{new Date(post.createdAt).toLocaleDateString()} {
</div> url: imageUrl,
<div className="flex items-center gap-1.5"> width: 1200,
<Eye size={16} /> height: 630,
{post.viewCount} views },
</div> ],
</div> type: 'article',
</header> publishedTime: post.createdAt,
authors: ['WYPark'],
<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 || ''} /> twitter: {
</div> card: 'summary_large_image',
</article> title: post.title,
description: description,
{/* 4. 🆕 이전/다음 글 네비게이션 영역 (개선됨) */} images: [imageUrl],
<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" export default async function PostDetailPage({ params }: Props) {
> const { slug } = await params;
<span className="text-xs font-bold text-gray-400 uppercase flex items-center gap-1 group-hover:text-blue-600 transition-colors"> return <PostDetailClient slug={slug} />;
<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>
) : (
<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>
)}
{/* 다음 글 (최신 글) */}
{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>
);
} }

View File

@@ -0,0 +1,157 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getPost, deletePost, getPostsByCategory } from '@/api/posts';
import { getProfile } from '@/api/profile';
import MarkdownRenderer from '@/components/post/MarkdownRenderer';
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';
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. 게시글 상세 조회
const { data: post, isLoading: isPostLoading, error } = useQuery({
queryKey: ['post', slug],
queryFn: () => getPost(slug),
enabled: !!slug,
retry: 1,
});
// 2. 같은 카테고리 글 조회 (이전/다음 글)
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 = (currentIndex > 0 && neighborPosts) ? neighborPosts.content[currentIndex - 1] : null;
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 (
<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>
<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>
<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>
) : <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>}
{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>
);
}