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

This commit is contained in:
ParkWonYeop
2025-12-29 00:46:47 +09:00
parent b3ba5c2374
commit 2847cf52f8
2 changed files with 38 additions and 46 deletions

View File

@@ -1,18 +1,20 @@
import { Metadata } from 'next'; import { Metadata } from 'next';
import PostDetailClient from '@/components/post/PostDetailClient'; import PostDetailClient from '@/components/post/PostDetailClient';
import { notFound } from 'next/navigation';
import { Post } from '@/types';
// 서버 사이드 메타데이터 생성을 위한 타입
type Props = { type Props = {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}; };
// 메타데이터용 데이터 패칭 (src/api/http.ts를 거치지 않고 직접 호출) // 🛠️ 서버 사이드 데이터 패칭 함수 (Metadata와 Page 양쪽에서 재사용)
async function getPostForMetadata(slug: string) { async function getPostFromServer(slug: string): Promise<Post | null> {
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
try { try {
const res = await fetch(`${BASE_URL}/api/posts/${slug}`, { const res = await fetch(`${BASE_URL}/api/posts/${slug}`, {
// 메타데이터는 캐시해도 되지만, 글 수정 시 반영을 위해 적절한 시간 설정 // 캐시 설정: 60초마다 갱신 (블로그 특성상 적절)
// 즉시 반영이 필요하다면 'no-store'로 설정하거나 revalidate: 0 사용
next: { revalidate: 60 }, next: { revalidate: 60 },
}); });
@@ -21,15 +23,15 @@ async function getPostForMetadata(slug: string) {
const json = await res.json(); const json = await res.json();
return json.data; return json.data;
} catch (error) { } catch (error) {
console.error('Metadata fetch error:', error); console.error('Server fetch error:', error);
return null; return null;
} }
} }
// 🌟 핵심: 동적 메타데이터 생성 (SEO & 카톡 공유 미리보기) // 🌟 메타데이터 생성 (SEO)
export async function generateMetadata({ params }: Props): Promise<Metadata> { export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params; const { slug } = await params;
const post = await getPostForMetadata(slug); const post = await getPostFromServer(slug);
if (!post) { if (!post) {
return { return {
@@ -37,15 +39,14 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}; };
} }
// 본문 내용을 150자로 잘라서 설명으로 사용 (마크다운 제거) // 본문 요약 및 이미지 추출 로직
const description = post.content const description = post.content
?.replace(/[#*`_~]/g, '') ?.replace(/[#*`_~]/g, '')
.replace(/\n/g, ' ') .replace(/\n/g, ' ')
.substring(0, 150) + '...'; .substring(0, 150) + '...';
// 썸네일 이미지 추출 (본문에 이미지가 있다면 첫 번째 이미지 사용)
const imageMatch = post.content?.match(/!\[.*?\]\((.*?)\)/); const imageMatch = post.content?.match(/!\[.*?\]\((.*?)\)/);
const imageUrl = imageMatch ? imageMatch[1] : '/og-image.png'; // 기본 이미지 const imageUrl = imageMatch ? imageMatch[1] : '/og-image.png';
return { return {
title: post.title, title: post.title,
@@ -55,15 +56,9 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
description: description, description: description,
url: `https://blog.wypark.me/posts/${slug}`, url: `https://blog.wypark.me/posts/${slug}`,
siteName: 'WYPark Blog', siteName: 'WYPark Blog',
images: [ images: [{ url: imageUrl, width: 1200, height: 630 }],
{
url: imageUrl,
width: 1200,
height: 630,
},
],
type: 'article', type: 'article',
publishedTime: post.updatedAt, publishedTime: post.createdAt, // created_at 대신 createdAt 사용 주의 (타입 정의 따름)
authors: ['WYPark'], authors: ['WYPark'],
}, },
twitter: { twitter: {
@@ -75,8 +70,18 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}; };
} }
// 실제 페이지 렌더링 // 🌟 실제 페이지 렌더링 (SSR 적용)
export default async function PostDetailPage({ params }: Props) { export default async function PostDetailPage({ params }: Props) {
const { slug } = await params; const { slug } = await params;
return <PostDetailClient slug={slug} />;
// 1. 서버에서 데이터를 미리 가져옵니다.
const post = await getPostFromServer(slug);
// 2. 데이터가 없으면 404 페이지로 보냅니다. (봇에게도 404 신호를 줌)
if (!post) {
notFound();
}
// 3. 가져온 데이터를 클라이언트 컴포넌트에 'initialPost'로 넘겨줍니다.
return <PostDetailClient slug={slug} initialPost={post} />;
} }

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getPost, deletePost } from '@/api/posts'; // 🛠️ getPostsByCategory 제거 import { getPost, deletePost } from '@/api/posts';
import { getProfile } from '@/api/profile'; import { getProfile } from '@/api/profile';
import MarkdownRenderer from '@/components/post/MarkdownRenderer'; import MarkdownRenderer from '@/components/post/MarkdownRenderer';
import CommentList from '@/components/comment/CommentList'; import CommentList from '@/components/comment/CommentList';
@@ -10,34 +10,28 @@ import { Loader2, Calendar, Eye, Folder, User, Edit2, Trash2, ArrowLeft, AlertCi
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import Link from 'next/link'; import Link from 'next/link';
import { Post } from '@/types'; // 타입 임포트
interface PostDetailClientProps { interface PostDetailClientProps {
slug: string; slug: string;
initialPost: Post; // 🌟 서버에서 넘겨받는 초기 데이터 (필수)
} }
export default function PostDetailClient({ slug }: PostDetailClientProps) { export default function PostDetailClient({ slug, initialPost }: PostDetailClientProps) {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { role, _hasHydrated } = useAuthStore(); const { role, _hasHydrated } = useAuthStore();
const isAdmin = _hasHydrated && role?.includes('ADMIN'); const isAdmin = _hasHydrated && role?.includes('ADMIN');
// 1. 게시글 상세 조회 (이제 여기에 prevPost, nextPost가 포함됨) // 1. 게시글 상세 조회
const { data: post, isLoading: isPostLoading, error } = useQuery({ const { data: post, isLoading: isPostLoading, error } = useQuery({
queryKey: ['post', slug], queryKey: ['post', slug],
queryFn: () => getPost(slug), queryFn: () => getPost(slug),
enabled: !!slug, enabled: !!slug,
retry: 1, // 🌟 핵심: 서버에서 가져온 데이터를 초기값으로 사용하여 즉시 렌더링 (Hydration)
initialData: initialPost,
}); });
// 🗑️ 삭제됨: 더 이상 프론트에서 앞뒤 글을 찾기 위해 목록을 조회할 필요가 없음!
/* 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({ const { data: profile } = useQuery({
queryKey: ['profile'], queryKey: ['profile'],
queryFn: getProfile, queryFn: getProfile,
@@ -55,6 +49,8 @@ export default function PostDetailClient({ slug }: PostDetailClientProps) {
}, },
}); });
// 🌟 Loading 상태 처리를 제거하거나 조건을 완화합니다.
// initialData가 있으면 isLoading은 false가 되므로 바로 아래 컨텐츠가 렌더링됩니다.
if (isPostLoading) { if (isPostLoading) {
return ( return (
<div className="flex justify-center items-center h-screen"> <div className="flex justify-center items-center h-screen">
@@ -63,6 +59,7 @@ export default function PostDetailClient({ slug }: PostDetailClientProps) {
); );
} }
// 에러 처리
if (error || !post) { if (error || !post) {
const errorStatus = (error as any)?.response?.status; const errorStatus = (error as any)?.response?.status;
const errorMessage = (error as any)?.response?.data?.message || error?.message || '게시글을 찾을 수 없습니다.'; const errorMessage = (error as any)?.response?.data?.message || error?.message || '게시글을 찾을 수 없습니다.';
@@ -86,18 +83,9 @@ export default function PostDetailClient({ slug }: PostDetailClientProps) {
); );
} }
// 🗑️ 삭제됨: 인덱스 계산 로직 제거 // 백엔드 데이터 사용 (이전글/다음글)
/* const prevPost = post.prevPost;
const currentIndex = neighborPosts?.content.findIndex((p) => p.id === post.id) ?? -1; const nextPost = post.nextPost;
const newerPost = ...
const olderPost = ...
*/
// 🆕 백엔드 데이터 직접 사용
// 보통 '이전 글'은 과거 글(prev), '다음 글'은 최신 글(next)입니다.
// 백엔드 구현에 따라 prevPost/nextPost 위치가 반대일 수 있으니 확인 후 위치만 바꿔주세요.
const prevPost = post.prevPost; // 이전 글 (왼쪽 버튼)
const nextPost = post.nextPost; // 다음 글 (오른쪽 버튼)
const handleDelete = () => { const handleDelete = () => {
if (confirm('정말로 이 게시글을 삭제하시겠습니까? 복구할 수 없습니다.')) { if (confirm('정말로 이 게시글을 삭제하시겠습니까? 복구할 수 없습니다.')) {
@@ -149,7 +137,6 @@ export default function PostDetailClient({ slug }: PostDetailClientProps) {
</div> </div>
</article> </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"> <nav className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-b border-gray-100 py-8 mb-16">
{prevPost ? ( {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"> <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">