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",
|
"axios": "^1.13.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"github-slugger": "^2.0.0",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-syntax-highlighter": "^16.1.0",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"rehype-slug": "^6.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"yarn": "^1.22.22",
|
"yarn": "^1.22.22",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"github-slugger": "^2.0.0",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-syntax-highlighter": "^16.1.0",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"rehype-slug": "^6.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"yarn": "^1.22.22",
|
"yarn": "^1.22.22",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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 TopHeader from '@/components/layout/TopHeader';
|
||||||
import Script from 'next/script'; // 👈 Script 컴포넌트 임포트
|
import Script from 'next/script';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'WYPark Blog',
|
title: 'WYPark Blog',
|
||||||
@@ -21,8 +21,7 @@ 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 스크립트 추가 */}
|
{/* 🌟 Google Analytics 스크립트 */}
|
||||||
{/* strategy="afterInteractive"는 페이지가 로드된 직후 스크립트를 실행하여 성능을 최적화합니다. */}
|
|
||||||
<Script
|
<Script
|
||||||
src="https://www.googletagmanager.com/gtag/js?id=G-2GLCM9ZKMK"
|
src="https://www.googletagmanager.com/gtag/js?id=G-2GLCM9ZKMK"
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
@@ -46,7 +45,9 @@ export default function RootLayout({
|
|||||||
<main className="flex-1 transition-all duration-300 md:ml-72 w-full relative">
|
<main className="flex-1 transition-all duration-300 md:ml-72 w-full relative">
|
||||||
<TopHeader />
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -194,6 +194,8 @@ function CategoryItem({ category, depth, pathname, isEditMode, onDrop, onAdd, on
|
|||||||
|
|
||||||
function SidebarContent() {
|
function SidebarContent() {
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
|
const [isMounted, setIsMounted] = useState(false); // 🛠️ [Fix] 하이드레이션 매칭을 위한 상태 추가
|
||||||
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { role, _hasHydrated } = useAuthStore();
|
const { role, _hasHydrated } = useAuthStore();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -203,6 +205,10 @@ function SidebarContent() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const keyword = searchParams.get('keyword') || '';
|
const keyword = searchParams.get('keyword') || '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true); // 🛠️ [Fix] 클라이언트 마운트 후 true로 설정
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSearch = (newKeyword: string) => {
|
const handleSearch = (newKeyword: string) => {
|
||||||
if (newKeyword.trim()) {
|
if (newKeyword.trim()) {
|
||||||
router.push(`/?keyword=${encodeURIComponent(newKeyword.trim())}`);
|
router.push(`/?keyword=${encodeURIComponent(newKeyword.trim())}`);
|
||||||
@@ -380,7 +386,8 @@ function SidebarContent() {
|
|||||||
)}
|
)}
|
||||||
<Link href="/" className="block hover:opacity-80 transition-opacity">
|
<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">
|
<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" />
|
<div className="w-full h-full bg-gray-200 animate-pulse" />
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
@@ -394,7 +401,8 @@ function SidebarContent() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
<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 React, { useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
// 🛠️ 보안 패치: rehype-sanitize 추가 (반드시 npm install rehype-sanitize 실행 필요)
|
|
||||||
import rehypeSanitize from 'rehype-sanitize';
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
import { Copy, Check, Terminal, ExternalLink } from 'lucide-react';
|
import { Copy, Check, Terminal, ExternalLink } from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
|
import rehypeSlug from 'rehype-slug';
|
||||||
|
|
||||||
interface MarkdownRendererProps {
|
interface MarkdownRendererProps {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -19,8 +19,7 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
|||||||
<div className="markdown-content">
|
<div className="markdown-content">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
// 🛡️ 중요: 여기서 HTML 태그를 소독하여 XSS 공격 방지
|
rehypePlugins={[rehypeSanitize, rehypeSlug]}
|
||||||
rehypePlugins={[rehypeSanitize]}
|
|
||||||
components={{
|
components={{
|
||||||
// 1. 코드 블록 커스텀
|
// 1. 코드 블록 커스텀
|
||||||
code({ node, inline, className, children, ...props }: any) {
|
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>;
|
return <td className="px-6 py-4 border-b border-gray-100 whitespace-pre-wrap">{children}</td>;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 5. 이미지
|
// 5. 이미지 (비율 유지 및 중앙 정렬)
|
||||||
img({ src, alt }) {
|
img({ src, alt }) {
|
||||||
return (
|
return (
|
||||||
<span className="block my-8">
|
// 🛠️ [Fix] flex-col 추가: 이미지와 캡션을 세로로 정렬
|
||||||
|
// items-center 추가: 가로축 중앙 정렬
|
||||||
|
<span className="block my-8 flex flex-col items-center justify-center">
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
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"
|
loading="lazy"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
e.currentTarget.style.display = 'none';
|
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>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
// 6. 리스트 스타일 (여기가 문제였음)
|
// 6. 리스트 스타일
|
||||||
ul({ children }) {
|
ul({ children }) {
|
||||||
return <ul className="list-disc pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-400">{children}</ul>;
|
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) {
|
ol({ children, ...props }: any) {
|
||||||
return (
|
return (
|
||||||
<ol
|
<ol
|
||||||
className="list-decimal pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-500 font-medium"
|
className="list-decimal pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-500 font-medium"
|
||||||
{...props} // 👈 이게 있어야 start="3" 같은 속성이 적용됨
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -126,15 +126,15 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
|||||||
return <li className="pl-1">{children}</li>;
|
return <li className="pl-1">{children}</li>;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 7. 헤딩 스타일
|
// 7. 헤딩 스타일 (🛠️ 수정: ...props를 전달해야 id가 붙어서 목차 이동이 작동함)
|
||||||
h1({ children }) {
|
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">{children}</h1>;
|
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 }) {
|
h2({ children, ...props }: any) {
|
||||||
return <h2 className="text-2xl font-bold mt-10 mb-5 pb-2 text-gray-800">{children}</h2>;
|
return <h2 className="text-2xl font-bold mt-10 mb-5 pb-2 text-gray-800" {...props}>{children}</h2>;
|
||||||
},
|
},
|
||||||
h3({ children }) {
|
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">{children}</h3>;
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { 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';
|
||||||
|
import TOC from '@/components/post/TOC';
|
||||||
import { Loader2, Calendar, Eye, Folder, User, Edit2, Trash2, ArrowLeft, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Loader2, Calendar, Eye, Folder, User, Edit2, Trash2, ArrowLeft, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
@@ -20,7 +21,7 @@ export default function PostDetailClient({ slug }: PostDetailClientProps) {
|
|||||||
const { role, _hasHydrated } = useAuthStore();
|
const { role, _hasHydrated } = useAuthStore();
|
||||||
const isAdmin = _hasHydrated && role?.includes('ADMIN');
|
const isAdmin = _hasHydrated && role?.includes('ADMIN');
|
||||||
|
|
||||||
// 1. 게시글 상세 조회
|
// 1. 게시글 상세 조회 (이제 여기에 prevPost, nextPost가 포함됨)
|
||||||
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),
|
||||||
@@ -28,13 +29,14 @@ export default function PostDetailClient({ slug }: PostDetailClientProps) {
|
|||||||
retry: 1,
|
retry: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. 같은 카테고리 글 조회 (이전/다음 글)
|
// 🗑️ 삭제됨: 더 이상 프론트에서 앞뒤 글을 찾기 위해 목록을 조회할 필요가 없음!
|
||||||
const { data: neighborPosts } = useQuery({
|
/* const { data: neighborPosts } = useQuery({
|
||||||
queryKey: ['posts', 'category', post?.categoryName],
|
queryKey: ['posts', 'category', post?.categoryName],
|
||||||
queryFn: () => getPostsByCategory(post!.categoryName, 0, 100),
|
queryFn: () => getPostsByCategory(post!.categoryName, 0, 100),
|
||||||
enabled: !!post?.categoryName,
|
enabled: !!post?.categoryName,
|
||||||
staleTime: 1000 * 60 * 5,
|
staleTime: 1000 * 60 * 5,
|
||||||
});
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
const { data: profile } = useQuery({
|
const { data: profile } = useQuery({
|
||||||
queryKey: ['profile'],
|
queryKey: ['profile'],
|
||||||
@@ -84,9 +86,18 @@ export default function PostDetailClient({ slug }: PostDetailClientProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🗑️ 삭제됨: 인덱스 계산 로직 제거
|
||||||
|
/*
|
||||||
const currentIndex = neighborPosts?.content.findIndex((p) => p.id === post.id) ?? -1;
|
const currentIndex = neighborPosts?.content.findIndex((p) => p.id === post.id) ?? -1;
|
||||||
const newerPost = (currentIndex > 0 && neighborPosts) ? neighborPosts.content[currentIndex - 1] : null;
|
const newerPost = ...
|
||||||
const olderPost = (currentIndex !== -1 && neighborPosts && currentIndex < neighborPosts.content.length - 1) ? neighborPosts.content[currentIndex + 1] : null;
|
const olderPost = ...
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 🆕 백엔드 데이터 직접 사용
|
||||||
|
// 보통 '이전 글'은 과거 글(prev), '다음 글'은 최신 글(next)입니다.
|
||||||
|
// 백엔드 구현에 따라 prevPost/nextPost 위치가 반대일 수 있으니 확인 후 위치만 바꿔주세요.
|
||||||
|
const prevPost = post.prevPost; // 이전 글 (왼쪽 버튼)
|
||||||
|
const nextPost = post.nextPost; // 다음 글 (오른쪽 버튼)
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (confirm('정말로 이 게시글을 삭제하시겠습니까? 복구할 수 없습니다.')) {
|
if (confirm('정말로 이 게시글을 삭제하시겠습니까? 복구할 수 없습니다.')) {
|
||||||
@@ -99,59 +110,72 @@ export default function PostDetailClient({ slug }: PostDetailClientProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<Link href="/" className="inline-flex items-center gap-1 text-gray-500 hover:text-blue-600 mb-8 transition-colors">
|
||||||
<ArrowLeft size={18} />
|
<ArrowLeft size={18} />
|
||||||
<span className="text-sm font-medium">목록으로</span>
|
<span className="text-sm font-medium">목록으로</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<article>
|
<div className="flex flex-col xl:flex-row gap-8 xl:gap-16 relative">
|
||||||
<header className="mb-10 border-b border-gray-100 pb-8">
|
|
||||||
<div className="flex justify-between items-start mb-4">
|
<main className="min-w-0 xl:flex-1">
|
||||||
<div className="flex items-center gap-2 text-sm text-blue-600 font-medium bg-blue-50 px-3 py-1 rounded-full">
|
<article>
|
||||||
<Folder size={14} />
|
<header className="mb-10 border-b border-gray-100 pb-8">
|
||||||
<span>{post.categoryName || 'Uncategorized'}</span>
|
<div className="flex justify-between items-start mb-4">
|
||||||
</div>
|
<div className="flex items-center gap-2 text-sm text-blue-600 font-medium bg-blue-50 px-3 py-1 rounded-full">
|
||||||
{isAdmin && (
|
<Folder size={14} />
|
||||||
<div className="flex gap-2">
|
<span>{post.categoryName || 'Uncategorized'}</span>
|
||||||
<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>
|
</div>
|
||||||
<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>
|
{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>
|
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-500">
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6 leading-tight break-keep">{post.title}</h1>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-500">
|
{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>}
|
||||||
<div className="flex items-center gap-2">
|
<span className="font-bold text-gray-800">{profile?.name || 'Dev Park'}</span>
|
||||||
{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>}
|
</div>
|
||||||
<span className="font-bold text-gray-800">{profile?.name || 'Dev Park'}</span>
|
<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>
|
||||||
<div className="flex items-center gap-1.5"><Calendar size={16} />{new Date(post.createdAt).toLocaleDateString()}</div>
|
</article>
|
||||||
<div className="flex items-center gap-1.5"><Eye size={16} />{post.viewCount} views</div>
|
|
||||||
|
{/* 🛠️ 네비게이션 영역 수정: 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>
|
</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">
|
</div>
|
||||||
<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;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 인접 게시글 정보 (이전글/다음글)
|
||||||
|
export interface PostNeighbor {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 게시글 (Post) 타입
|
// 2. 게시글 (Post) 타입
|
||||||
export interface Post {
|
export interface Post {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -14,7 +20,10 @@ export interface Post {
|
|||||||
viewCount: number;
|
viewCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
tags: string[]; // 🆕 태그 배열 속성 추가
|
tags: string[];
|
||||||
|
// 🆕 백엔드 변경 사항 반영: 이전글/다음글 정보 추가
|
||||||
|
prevPost?: PostNeighbor | null;
|
||||||
|
nextPost?: PostNeighbor | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 게시글 목록 페이징 응답
|
// 3. 게시글 목록 페이징 응답
|
||||||
@@ -37,7 +46,7 @@ export interface AuthResponse {
|
|||||||
export interface Category {
|
export interface Category {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
parentId?: number | null; // 🆕 부모 카테고리 ID 추가
|
parentId?: number | null;
|
||||||
children: Category[];
|
children: Category[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3659,7 +3659,7 @@ rehype-sanitize@^6.0.0:
|
|||||||
"@types/hast" "^3.0.0"
|
"@types/hast" "^3.0.0"
|
||||||
hast-util-sanitize "^5.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"
|
version "6.0.0"
|
||||||
resolved "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz"
|
resolved "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz"
|
||||||
integrity sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==
|
integrity sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==
|
||||||
|
|||||||
Reference in New Issue
Block a user