From 5b4759bf7a87d5f39d019c105a62facffd9ec64b Mon Sep 17 00:00:00 2001 From: ParkWonYeop Date: Sun, 28 Dec 2025 02:31:53 +0900 Subject: [PATCH] . --- src/app/layout.tsx | 26 +- src/app/posts/[slug]/page.tsx | 309 ++++++----------------- src/components/post/PostDetailClient.tsx | 157 ++++++++++++ 3 files changed, 251 insertions(+), 241 deletions(-) create mode 100644 src/components/post/PostDetailClient.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1d3d000..32b1812 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,9 @@ -// src/app/layout.tsx import type { Metadata } from 'next'; import './globals.css'; import Providers from './providers'; 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 = { title: 'WYPark Blog', @@ -21,18 +21,32 @@ export default function RootLayout({ + {/* 🌟 Google Analytics 슀크립트 μΆ”κ°€ */} + {/* strategy="afterInteractive"λŠ” νŽ˜μ΄μ§€κ°€ λ‘œλ“œλœ 직후 슀크립트λ₯Ό μ‹€ν–‰ν•˜μ—¬ μ„±λŠ₯을 μ΅œμ ν™”ν•©λ‹ˆλ‹€. */} + +
{/* μ‚¬μ΄λ“œλ°” */} {/* 메인 μ˜μ—­ */} -
{/* πŸ‘ˆ relative 확인 */} - - {/* πŸ‘‡ 여기에 TopHeader μΆ”κ°€! */} +
-
{/* πŸ‘ˆ 상단 μ—¬λ°±(pt-20) 살짝 μΆ”κ°€ */} +
{children}
diff --git a/src/app/posts/[slug]/page.tsx b/src/app/posts/[slug]/page.tsx index a19a304..974701d 100644 --- a/src/app/posts/[slug]/page.tsx +++ b/src/app/posts/[slug]/page.tsx @@ -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'; -import { getPost, deletePost, getPostsByCategory } from '@/api/posts'; // πŸ‘ˆ λͺ©λ‘ 쑰회 API μΆ”κ°€ -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'; +// μ„œλ²„ μ‚¬μ΄λ“œ 메타데이터 생성을 μœ„ν•œ νƒ€μž… +type Props = { + params: Promise<{ slug: string }>; +}; -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 ( -
- -
- ); - } - - 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 ( -
-
- -
-

- {isAuthError ? 'μ ‘κ·Ό κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€.' : 'κ²Œμ‹œκΈ€μ„ 뢈러올 수 μ—†μŠ΅λ‹ˆλ‹€.'} -

-

- {isAuthError - ? '둜그인이 ν•„μš”ν•˜κ±°λ‚˜ λΉ„κ³΅κ°œ κ²Œμ‹œκΈ€μΌ 수 μžˆμŠ΅λ‹ˆλ‹€.' - : errorMessage} -

-
- - {isAuthError && ( - - )} -
-
- ); - } - - // 3. πŸ†• λ¦¬μŠ€νŠΈμ—μ„œ ν˜„μž¬ κΈ€μ˜ μœ„μΉ˜λ₯Ό μ°Ύμ•„ 이전/λ‹€μŒ κΈ€ κ²°μ • - // (λ¦¬μŠ€νŠΈλŠ” 기본적으둜 μ΅œμ‹ μˆœ(DESC) μ •λ ¬μ΄λ―€λ‘œ μΈλ±μŠ€κ°€ μž‘μ„μˆ˜λ‘ μ΅œμ‹ κΈ€μž…λ‹ˆλ‹€) - const currentIndex = neighborPosts?.content.findIndex((p) => p.id === post.id) ?? -1; +// λ©”νƒ€λ°μ΄ν„°μš© 데이터 패칭 (src/api/http.tsλ₯Ό κ±°μΉ˜μ§€ μ•Šκ³  직접 호좜) +async function getPostForMetadata(slug: string) { + const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; - // μΈλ±μŠ€κ°€ 0보닀 크면 μ•žμ— 더 μ΅œμ‹  글이 μžˆλ‹€λŠ” 뜻 (Next Post) - const newerPost = (currentIndex > 0 && neighborPosts) - ? neighborPosts.content[currentIndex - 1] - : null; + try { + const res = await fetch(`${BASE_URL}/api/posts/${slug}`, { + // λ©”νƒ€λ°μ΄ν„°λŠ” μΊμ‹œν•΄λ„ λ˜μ§€λ§Œ, κΈ€ μˆ˜μ • μ‹œ λ°˜μ˜μ„ μœ„ν•΄ μ μ ˆν•œ μ‹œκ°„ μ„€μ • + next: { revalidate: 60 }, + }); + + if (!res.ok) return null; + + const json = await res.json(); + return json.data; + } catch (error) { + console.error('Metadata fetch error:', error); + return null; + } +} - // μΈλ±μŠ€κ°€ λ§ˆμ§€λ§‰μ΄ μ•„λ‹ˆλ©΄ 뒀에 더 κ³Όκ±° 글이 μžˆλ‹€λŠ” 뜻 (Previous Post) - const olderPost = (currentIndex !== -1 && neighborPosts && currentIndex < neighborPosts.content.length - 1) - ? neighborPosts.content[currentIndex + 1] - : null; +// 🌟 핡심: 동적 메타데이터 생성 (SEO & 카톑 곡유 미리보기) +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params; + const post = await getPostForMetadata(slug); - const handleDelete = () => { - if (confirm('μ •λ§λ‘œ 이 κ²Œμ‹œκΈ€μ„ μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ? 볡ꡬ할 수 μ—†μŠ΅λ‹ˆλ‹€.')) { - deleteMutation.mutate(post.id); - } + if (!post) { + return { + title: 'κ²Œμ‹œκΈ€μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€', + }; + } + + // λ³Έλ¬Έ λ‚΄μš©μ„ 150자둜 μž˜λΌμ„œ μ„€λͺ…μœΌλ‘œ μ‚¬μš© (λ§ˆν¬λ‹€μš΄ 제거) + const description = post.content + ?.replace(/[#*`_~]/g, '') + .replace(/\n/g, ' ') + .substring(0, 150) + '...'; + + // 썸넀일 이미지 μΆ”μΆœ (본문에 이미지가 μžˆλ‹€λ©΄ 첫 번째 이미지 μ‚¬μš©) + const imageMatch = post.content?.match(/!\[.*?\]\((.*?)\)/); + const imageUrl = imageMatch ? imageMatch[1] : '/og-image.png'; // κΈ°λ³Έ 이미지 + + return { + title: post.title, + description: description, + openGraph: { + title: post.title, + description: description, + url: `https://blog.wypark.me/posts/${slug}`, + siteName: 'WYPark Blog', + images: [ + { + url: imageUrl, + width: 1200, + height: 630, + }, + ], + type: 'article', + publishedTime: post.createdAt, + authors: ['WYPark'], + }, + twitter: { + card: 'summary_large_image', + title: post.title, + description: description, + images: [imageUrl], + }, }; +} - const handleEdit = () => { - router.push(`/write?slug=${post.slug}`); - }; - - return ( -
- - - λͺ©λ‘μœΌλ‘œ - - -
-
-
-
- - {post.categoryName || 'Uncategorized'} -
- - {isAdmin && ( -
- - -
- )} -
- -

- {post.title} -

- -
-
- {profile?.imageUrl ? ( - Author - ) : ( -
- -
- )} - {profile?.name || 'Dev Park'} -
- -
- - {new Date(post.createdAt).toLocaleDateString()} -
-
- - {post.viewCount} views -
-
-
- -
- -
-
- - {/* 4. πŸ†• 이전/λ‹€μŒ κΈ€ λ„€λΉ„κ²Œμ΄μ…˜ μ˜μ—­ (κ°œμ„ λ¨) */} - - - -
- ); +// μ‹€μ œ νŽ˜μ΄μ§€ λ Œλ”λ§ +export default async function PostDetailPage({ params }: Props) { + const { slug } = await params; + return ; } \ No newline at end of file diff --git a/src/components/post/PostDetailClient.tsx b/src/components/post/PostDetailClient.tsx new file mode 100644 index 0000000..2362491 --- /dev/null +++ b/src/components/post/PostDetailClient.tsx @@ -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 ( +
+ +
+ ); + } + + 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 ( +
+
+ +
+

+ {isAuthError ? 'μ ‘κ·Ό κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€.' : 'κ²Œμ‹œκΈ€μ„ 뢈러올 수 μ—†μŠ΅λ‹ˆλ‹€.'} +

+

+ {isAuthError ? '둜그인이 ν•„μš”ν•˜κ±°λ‚˜ λΉ„κ³΅κ°œ κ²Œμ‹œκΈ€μΌ 수 μžˆμŠ΅λ‹ˆλ‹€.' : errorMessage} +

+
+ +
+
+ ); + } + + 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 ( +
+ + + λͺ©λ‘μœΌλ‘œ + + +
+
+
+
+ + {post.categoryName || 'Uncategorized'} +
+ {isAdmin && ( +
+ + +
+ )} +
+

{post.title}

+
+
+ {profile?.imageUrl ? Author :
} + {profile?.name || 'Dev Park'} +
+
{new Date(post.createdAt).toLocaleDateString()}
+
{post.viewCount} views
+
+
+ +
+ +
+
+ + + + +
+ ); +} \ No newline at end of file