feat: 목차 추가, 포스팅 조회 개선
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 1m50s

This commit is contained in:
ParkWonYeop
2025-12-28 04:16:36 +09:00
parent 5b4759bf7a
commit bfda1240bd
9 changed files with 255 additions and 76 deletions

View File

@@ -3,7 +3,7 @@ import './globals.css';
import Providers from './providers';
import Sidebar from '@/components/layout/Sidebar';
import TopHeader from '@/components/layout/TopHeader';
import Script from 'next/script'; // 👈 Script 컴포넌트 임포트
import Script from 'next/script';
export const metadata: Metadata = {
title: 'WYPark Blog',
@@ -21,8 +21,7 @@ export default function RootLayout({
<meta name="google-site-verification" content="cFJSK1ayy2Y4lqRKNv8wZ_vybg5De22zYCdbKSfvAJA" />
</head>
<body className="bg-[#f8f9fa] text-gray-800">
{/* 🌟 Google Analytics 스크립트 추가 */}
{/* strategy="afterInteractive"는 페이지가 로드된 직후 스크립트를 실행하여 성능을 최적화합니다. */}
{/* 🌟 Google Analytics 스크립트 */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-2GLCM9ZKMK"
strategy="afterInteractive"
@@ -46,7 +45,9 @@ export default function RootLayout({
<main className="flex-1 transition-all duration-300 md:ml-72 w-full relative">
<TopHeader />
<div className="max-w-4xl mx-auto p-6 md:p-12 pt-20">
{/* 🛠️ 수정됨: max-w-4xl 제한을 제거하고 w-full로 변경 */}
{/* 이제 각 페이지(page.tsx)에서 원하는 너비를 설정할 수 있습니다. */}
<div className="w-full mx-auto p-4 md:p-8 pt-20">
{children}
</div>
</main>

View File

@@ -194,6 +194,8 @@ function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, on
function SidebarContent() {
const [isOpen, setIsOpen] = useState(true);
const [isMounted, setIsMounted] = useState(false); // 🛠️ [Fix] 하이드레이션 매칭을 위한 상태 추가
const pathname = usePathname();
const { role, _hasHydrated } = useAuthStore();
const queryClient = useQueryClient();
@@ -203,6 +205,10 @@ function SidebarContent() {
const searchParams = useSearchParams();
const keyword = searchParams.get('keyword') || '';
useEffect(() => {
setIsMounted(true); // 🛠️ [Fix] 클라이언트 마운트 후 true로 설정
}, []);
const handleSearch = (newKeyword: string) => {
if (newKeyword.trim()) {
router.push(`/?keyword=${encodeURIComponent(newKeyword.trim())}`);
@@ -380,7 +386,8 @@ function SidebarContent() {
)}
<Link href="/" className="block hover:opacity-80 transition-opacity">
<div className="w-24 h-24 mx-auto bg-gray-200 rounded-full mb-4 overflow-hidden shadow-inner ring-4 ring-gray-50 relative">
{isProfileLoading ? (
{/* 🛠️ [Fix] isMounted를 체크하여 첫 렌더링 시 무조건 스켈레톤을 보여줍니다. (서버와 일치시킴) */}
{!isMounted || isProfileLoading ? (
<div className="w-full h-full bg-gray-200 animate-pulse" />
) : (
<Image
@@ -394,7 +401,8 @@ function SidebarContent() {
/>
)}
</div>
{isProfileLoading ? (
{/* 🛠️ [Fix] 텍스트 영역도 동일하게 처리 */}
{!isMounted || isProfileLoading ? (
<div className="space-y-2 flex flex-col items-center"><div className="h-6 w-24 bg-gray-200 rounded animate-pulse" /><div className="h-4 w-32 bg-gray-100 rounded animate-pulse" /></div>
) : (
<>

View File

@@ -3,12 +3,12 @@
import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
// 🛠️ 보안 패치: rehype-sanitize 추가 (반드시 npm install rehype-sanitize 실행 필요)
import rehypeSanitize from 'rehype-sanitize';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { Copy, Check, Terminal, ExternalLink } from 'lucide-react';
import { clsx } from 'clsx';
import rehypeSlug from 'rehype-slug';
interface MarkdownRendererProps {
content: string;
@@ -19,8 +19,7 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
<div className="markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
// 🛡️ 중요: 여기서 HTML 태그를 소독하여 XSS 공격 방지
rehypePlugins={[rehypeSanitize]}
rehypePlugins={[rehypeSanitize, rehypeSlug]}
components={{
// 1. 코드 블록 커스텀
code({ node, inline, className, children, ...props }: any) {
@@ -89,34 +88,35 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
return <td className="px-6 py-4 border-b border-gray-100 whitespace-pre-wrap">{children}</td>;
},
// 5. 이미지
// 5. 이미지 (비율 유지 및 중앙 정렬)
img({ src, alt }) {
return (
<span className="block my-8">
// 🛠️ [Fix] flex-col 추가: 이미지와 캡션을 세로로 정렬
// items-center 추가: 가로축 중앙 정렬
<span className="block my-8 flex flex-col items-center justify-center">
<img
src={src}
alt={alt}
className="rounded-xl shadow-lg border border-gray-100 w-full object-cover max-h-[600px] hover:scale-[1.01] transition-transform duration-300"
className="rounded-xl shadow-lg border border-gray-100 max-w-full h-auto max-h-[700px] mx-auto hover:scale-[1.01] transition-transform duration-300"
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
{alt && <span className="block text-center text-sm text-gray-400 mt-2">{alt}</span>}
{alt && <span className="block text-center text-sm text-gray-400 mt-2 w-full">{alt}</span>}
</span>
);
},
// 6. 리스트 스타일 (여기가 문제였음)
// 6. 리스트 스타일
ul({ children }) {
return <ul className="list-disc pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-400">{children}</ul>;
},
// 🛠️ 수정됨: ...props를 전달하여 start 속성을 적용
ol({ children, ...props }: any) {
return (
<ol
className="list-decimal pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-500 font-medium"
{...props} // 👈 이게 있어야 start="3" 같은 속성이 적용됨
{...props}
>
{children}
</ol>
@@ -126,15 +126,15 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
return <li className="pl-1">{children}</li>;
},
// 7. 헤딩 스타일
h1({ children }) {
return <h1 className="text-3xl font-extrabold mt-12 mb-6 pb-4 border-b border-gray-100 text-gray-900">{children}</h1>;
// 7. 헤딩 스타일 (🛠️ 수정: ...props를 전달해야 id가 붙어서 목차 이동이 작동함)
h1({ children, ...props }: any) {
return <h1 className="text-3xl font-extrabold mt-12 mb-6 pb-4 border-b border-gray-100 text-gray-900" {...props}>{children}</h1>;
},
h2({ children }) {
return <h2 className="text-2xl font-bold mt-10 mb-5 pb-2 text-gray-800">{children}</h2>;
h2({ children, ...props }: any) {
return <h2 className="text-2xl font-bold mt-10 mb-5 pb-2 text-gray-800" {...props}>{children}</h2>;
},
h3({ children }) {
return <h3 className="text-xl font-bold mt-8 mb-4 text-gray-800 flex items-center gap-2 before:content-[''] before:w-1.5 before:h-6 before:bg-blue-500 before:rounded-full before:mr-1">{children}</h3>;
h3({ children, ...props }: any) {
return <h3 className="text-xl font-bold mt-8 mb-4 text-gray-800 flex items-center gap-2 before:content-[''] before:w-1.5 before:h-6 before:bg-blue-500 before:rounded-full before:mr-1" {...props}>{children}</h3>;
},
}}
>

View File

@@ -1,10 +1,11 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getPost, deletePost, getPostsByCategory } from '@/api/posts';
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';
@@ -20,7 +21,7 @@ export default function PostDetailClient({ slug }: PostDetailClientProps) {
const { role, _hasHydrated } = useAuthStore();
const isAdmin = _hasHydrated && role?.includes('ADMIN');
// 1. 게시글 상세 조회
// 1. 게시글 상세 조회 (이제 여기에 prevPost, nextPost가 포함됨)
const { data: post, isLoading: isPostLoading, error } = useQuery({
queryKey: ['post', slug],
queryFn: () => getPost(slug),
@@ -28,13 +29,14 @@ export default function PostDetailClient({ slug }: PostDetailClientProps) {
retry: 1,
});
// 2. 같은 카테고리 글 조회 (이전/다음 글)
const { data: neighborPosts } = useQuery({
// 🗑️ 삭제됨: 더 이상 프론트에서 앞뒤 글을 찾기 위해 목록을 조회할 필요가 없음!
/* 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'],
@@ -84,9 +86,18 @@ export default function PostDetailClient({ slug }: PostDetailClientProps) {
);
}
// 🗑️ 삭제됨: 인덱스 계산 로직 제거
/*
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 newerPost = ...
const olderPost = ...
*/
// 🆕 백엔드 데이터 직접 사용
// 보통 '이전 글'은 과거 글(prev), '다음 글'은 최신 글(next)입니다.
// 백엔드 구현에 따라 prevPost/nextPost 위치가 반대일 수 있으니 확인 후 위치만 바꿔주세요.
const prevPost = post.prevPost; // 이전 글 (왼쪽 버튼)
const nextPost = post.nextPost; // 다음 글 (오른쪽 버튼)
const handleDelete = () => {
if (confirm('정말로 이 게시글을 삭제하시겠습니까? 복구할 수 없습니다.')) {
@@ -99,59 +110,72 @@ export default function PostDetailClient({ slug }: PostDetailClientProps) {
};
return (
<div className="max-w-4xl mx-auto px-4 py-12">
<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>
<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 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>
)}
</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>
<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>
<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>
</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>
</header>
</aside>
<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>
</div>
);
}

133
src/components/post/TOC.tsx Normal file
View File

@@ -0,0 +1,133 @@
'use client';
import { useEffect, useState } from 'react';
import { clsx } from 'clsx';
// 🛠️ 중요: rehype-slug와 동일한 ID 생성을 위해 라이브러리 사용
// npm install github-slugger 실행 필요
import GithubSlugger from 'github-slugger';
interface TOCProps {
content: string;
}
interface HeadingItem {
text: string;
level: number;
slug: string;
}
export default function TOC({ content }: TOCProps) {
const [activeId, setActiveId] = useState<string>('');
const [headings, setHeadings] = useState<HeadingItem[]>([]);
// 1. 마크다운에서 헤딩(#) 추출 및 Slug 생성
useEffect(() => {
const slugger = new GithubSlugger(); // Slugger 인스턴스 (중복 ID 처리용)
const lines = content.split('\n');
// 코드 블럭 내의 #은 무시
let inCodeBlock = false;
const matches = lines.reduce<HeadingItem[]>((acc, line) => {
// 코드 블럭 진입/이탈 체크 (```)
if (line.trim().startsWith('```')) {
inCodeBlock = !inCodeBlock;
return acc;
}
if (inCodeBlock) return acc;
// 헤딩 매칭 (# 1~3개)
const match = line.match(/^(#{1,3})\s+(.+)$/);
if (match) {
const level = match[1].length;
const text = match[2].replace(/(\*\*|__)/g, '').trim(); // 볼드체 마크다운 제거
// 🛠️ 핵심: github-slugger로 ID 생성 (한글, 띄어쓰기 등 완벽 호환)
const slug = slugger.slug(text);
acc.push({ text, level, slug });
}
return acc;
}, []);
setHeadings(matches);
}, [content]);
// 2. 스크롤 감지 (IntersectionObserver)
useEffect(() => {
if (headings.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
// 상단 헤더 공간 등을 고려하여 감지 영역 조정
{ rootMargin: '-10% 0px -80% 0px' }
);
headings.forEach((heading) => {
const element = document.getElementById(heading.slug);
if (element) observer.observe(element);
});
return () => observer.disconnect();
}, [headings]);
// 3. 클릭 핸들러
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
e.preventDefault();
const element = document.getElementById(slug);
if (element) {
// 상단 고정 헤더 높이 여유 공간
const headerOffset = 100;
const elementPosition = element.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
// URL 해시 변경 및 상태 즉시 업데이트
history.pushState(null, '', `#${slug}`);
setActiveId(slug);
}
};
if (headings.length === 0) return null;
return (
<aside className="w-full">
<div className="border-l-2 border-gray-100 pl-4 py-2">
<h4 className="font-bold text-gray-900 mb-4 text-sm uppercase tracking-wider text-opacity-80">On this page</h4>
<ul className="space-y-2.5">
{headings.map((heading, index) => (
<li
key={`${heading.slug}-${index}`}
className={clsx(
"text-sm transition-all duration-200",
heading.level === 3 ? "pl-4 text-xs" : "", // h3는 들여쓰기
activeId === heading.slug
? "text-blue-600 font-bold translate-x-1"
: "text-gray-500 hover:text-gray-900"
)}
>
<a
href={`#${heading.slug}`}
onClick={(e) => handleClick(e, heading.slug)}
className="block truncate leading-snug"
>
{heading.text}
</a>
</li>
))}
</ul>
</div>
</aside>
);
}

View File

@@ -5,6 +5,12 @@ export interface ApiResponse<T> {
data: T;
}
// 🆕 인접 게시글 정보 (이전글/다음글)
export interface PostNeighbor {
slug: string;
title: string;
}
// 2. 게시글 (Post) 타입
export interface Post {
id: number;
@@ -14,7 +20,10 @@ export interface Post {
viewCount: number;
createdAt: string;
content?: string;
tags: string[]; // 🆕 태그 배열 속성 추가
tags: string[];
// 🆕 백엔드 변경 사항 반영: 이전글/다음글 정보 추가
prevPost?: PostNeighbor | null;
nextPost?: PostNeighbor | null;
}
// 3. 게시글 목록 페이징 응답
@@ -37,7 +46,7 @@ export interface AuthResponse {
export interface Category {
id: number;
name: string;
parentId?: number | null; // 🆕 부모 카테고리 ID 추가
parentId?: number | null;
children: Category[];
}