feat: 목차 추가, 포스팅 조회 개선
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 1m50s
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 1m50s
This commit is contained in:
2
package-lock.json
generated
2
package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"axios": "^1.13.2",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"github-slugger": "^2.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
@@ -27,6 +28,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"yarn": "^1.22.22",
|
||||
"zustand": "^5.0.9"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"axios": "^1.13.2",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"github-slugger": "^2.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
@@ -28,6 +29,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"yarn": "^1.22.22",
|
||||
"zustand": "^5.0.9"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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
133
src/components/post/TOC.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -3659,7 +3659,7 @@ rehype-sanitize@^6.0.0:
|
||||
"@types/hast" "^3.0.0"
|
||||
hast-util-sanitize "^5.0.0"
|
||||
|
||||
rehype-slug@~6.0.0:
|
||||
rehype-slug@^6.0.0, rehype-slug@~6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz"
|
||||
integrity sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==
|
||||
|
||||
Reference in New Issue
Block a user