This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
@@ -15,128 +16,129 @@ interface MarkdownRendererProps {
|
||||
}
|
||||
|
||||
export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||
const components: Components = {
|
||||
// 1. 코드 블록 커스텀
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
const codeString = String(children).replace(/\n$/, '');
|
||||
|
||||
if (match) {
|
||||
return (
|
||||
<CodeBlock language={language} code={codeString} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
className="mx-1 break-words rounded-md bg-black/[0.05] px-1.5 py-0.5 font-mono text-[0.9em] font-medium text-red-600 dark:bg-white/10 dark:text-red-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
|
||||
// 2. 인용구
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="my-7 rounded-r-lg border-l-4 border-[var(--color-accent)] bg-[var(--color-accent-soft)] py-3 pl-5 pr-4 text-[var(--color-text-muted)]">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
|
||||
// 3. 링크
|
||||
a({ href, children }) {
|
||||
const isExternal = href?.startsWith('http');
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={isExternal ? '_blank' : undefined}
|
||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||
className="inline-flex items-center gap-0.5 font-semibold text-[var(--color-accent)] underline-offset-4 transition-colors hover:underline"
|
||||
>
|
||||
{children}
|
||||
{isExternal && <ExternalLink size={12} className="opacity-70" />}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// 4. 테이블
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="my-8 overflow-x-auto rounded-lg border border-[var(--color-line)] bg-white/70 shadow-sm dark:bg-white/10">
|
||||
<table className="w-full text-left text-sm text-[var(--color-text-muted)]">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead className="border-b border-[var(--color-line)] bg-black/[0.03] text-xs text-[var(--color-text-muted)] dark:bg-white/10">{children}</thead>;
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="px-5 py-3 font-bold text-[var(--color-text)]">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="border-b border-[var(--color-line)] px-5 py-4 whitespace-pre-wrap">{children}</td>;
|
||||
},
|
||||
|
||||
// 5. 이미지
|
||||
img({ src, alt }) {
|
||||
return (
|
||||
<span className="my-8 flex flex-col items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="h-auto max-h-[700px] max-w-full rounded-lg border border-[var(--color-line)] shadow-[var(--shadow-card)] transition-transform duration-150 hover:scale-[1.005]"
|
||||
loading="lazy"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{alt && <span className="mt-3 block w-full text-center text-sm text-[var(--color-text-subtle)]">{alt}</span>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
||||
// 6. 리스트 스타일
|
||||
ul({ children }) {
|
||||
return <ul className="my-4 list-disc space-y-2 pl-6 text-[var(--color-text-muted)] marker:text-[var(--color-text-subtle)]">{children}</ul>;
|
||||
},
|
||||
ol({ children, ...props }) {
|
||||
return (
|
||||
<ol
|
||||
className="my-4 list-decimal space-y-2 pl-6 font-medium text-[var(--color-text-muted)] marker:text-[var(--color-text-subtle)]"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
li({ children }) {
|
||||
return <li className="pl-1">{children}</li>;
|
||||
},
|
||||
|
||||
// 7. 헤딩 스타일
|
||||
h1({ children, ...props }) {
|
||||
return <h1 className="mt-12 mb-6 border-b border-[var(--color-line)] pb-4 text-3xl font-extrabold tracking-normal text-[var(--color-text)]" {...props}>{children}</h1>;
|
||||
},
|
||||
h2({ children, ...props }) {
|
||||
return <h2 className="mt-11 mb-5 text-2xl font-bold tracking-normal text-[var(--color-text)]" {...props}>{children}</h2>;
|
||||
},
|
||||
h3({ children, ...props }) {
|
||||
return <h3 className="mt-9 mb-4 border-l-4 border-[var(--color-accent)] pl-3 text-xl font-bold tracking-normal text-[var(--color-text)]" {...props}>{children}</h3>;
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize, rehypeSlug]}
|
||||
components={{
|
||||
// 1. 코드 블록 커스텀
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
const codeString = String(children).replace(/\n$/, '');
|
||||
|
||||
if (!inline && match) {
|
||||
return (
|
||||
<CodeBlock language={language} code={codeString} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
className="bg-gray-100 text-red-500 px-1.5 py-0.5 rounded-md text-[0.9em] font-mono font-medium mx-1 break-words"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
|
||||
// 2. 인용구
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="border-l-4 border-blue-500 bg-blue-50 pl-4 py-3 my-6 text-gray-700 rounded-r-lg italic shadow-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
|
||||
// 3. 링크
|
||||
a({ href, children }) {
|
||||
const isExternal = href?.startsWith('http');
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={isExternal ? '_blank' : undefined}
|
||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium underline-offset-4 hover:underline inline-flex items-center gap-0.5 transition-colors"
|
||||
>
|
||||
{children}
|
||||
{isExternal && <ExternalLink size={12} className="opacity-70" />}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// 4. 테이블
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="overflow-x-auto my-8 rounded-lg border border-gray-200 shadow-sm">
|
||||
<table className="w-full text-sm text-left text-gray-700 bg-white">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead className="text-xs text-gray-700 uppercase bg-gray-50 border-b border-gray-200">{children}</thead>;
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="px-6 py-3 font-bold text-gray-900">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="px-6 py-4 border-b border-gray-100 whitespace-pre-wrap">{children}</td>;
|
||||
},
|
||||
|
||||
// 5. 이미지 (비율 유지 및 중앙 정렬)
|
||||
img({ src, alt }) {
|
||||
return (
|
||||
// 🛠️ [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 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 w-full">{alt}</span>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
||||
// 6. 리스트 스타일
|
||||
ul({ children }) {
|
||||
return <ul className="list-disc pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-400">{children}</ul>;
|
||||
},
|
||||
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}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
li({ children }) {
|
||||
return <li className="pl-1">{children}</li>;
|
||||
},
|
||||
|
||||
// 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, ...props }: any) {
|
||||
return <h2 className="text-2xl font-bold mt-10 mb-5 pb-2 text-gray-800" {...props}>{children}</h2>;
|
||||
},
|
||||
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>;
|
||||
},
|
||||
}}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
@@ -155,7 +157,7 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative my-8 group rounded-xl overflow-hidden border border-gray-700/50 shadow-2xl bg-[#1e1e1e]">
|
||||
<div className="group relative my-8 overflow-hidden rounded-lg border border-gray-700/50 bg-[#1e1e1e] shadow-[var(--shadow-card)]">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[#2d2d2d] border-b border-gray-700 select-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
@@ -182,7 +184,7 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
|
||||
title="코드 복사"
|
||||
>
|
||||
{isCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
<span>{isCopied ? 'Copied!' : 'Copy'}</span>
|
||||
<span>{isCopied ? '복사됨' : '복사'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -204,4 +206,4 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +1,71 @@
|
||||
import { Post } from '@/types';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { clsx } from 'clsx'; // 🎨 스타일 조건부 적용을 위해 clsx 사용
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Post } from '@/types';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
|
||||
const isNoticePost = (post: Post) => {
|
||||
return post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// 🛠️ 헬퍼 함수: 마크다운 문법 제거하고 순수 텍스트만 추출
|
||||
function getSummary(content?: string) {
|
||||
if (!content) return '';
|
||||
|
||||
if (!content) return '본문을 열어 전체 내용을 확인해 보세요.';
|
||||
|
||||
return content
|
||||
.replace(/[#*`_~]/g, '') // 특수문자(#, *, ` 등) 제거
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // 링크에서 텍스트만 남김 [글자](주소) -> 글자
|
||||
.replace(/\n/g, ' ') // 줄바꿈을 공백으로
|
||||
.substring(0, 120); // 120자까지만 자르기
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/!\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/[#*`_~>]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
export default function PostCard({ post }: { post: Post }) {
|
||||
// 요약문 생성
|
||||
const summary = getSummary(post.content);
|
||||
|
||||
// 📢 공지 카테고리 여부 확인 (한글 '공지' 또는 영문 'Notice' 대소문자 무관)
|
||||
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
const isNotice = isNoticePost(post);
|
||||
|
||||
return (
|
||||
<Link href={`/posts/${post.slug}`} className="block group h-full">
|
||||
<article className={clsx(
|
||||
"flex flex-col h-full bg-white rounded-2xl p-6 transition-all duration-300 border",
|
||||
// 공지글이면 테두리에 살짝 붉은 기운을 줌
|
||||
isNotice
|
||||
? "border-red-100 shadow-[0_2px_8px_rgba(239,68,68,0.08)] hover:shadow-[0_8px_24px_rgba(239,68,68,0.12)]"
|
||||
: "border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.08)]",
|
||||
"hover:-translate-y-1"
|
||||
)}>
|
||||
|
||||
{/* 상단 카테고리 & 날짜 */}
|
||||
<div className="flex items-center justify-between text-xs mb-4">
|
||||
<span className={clsx(
|
||||
"px-2.5 py-1 rounded-md font-medium transition-colors",
|
||||
isNotice
|
||||
? "bg-red-50 text-red-600 font-bold border border-red-100" // 🔴 공지 스타일
|
||||
: "bg-slate-100 text-slate-600" // 기본 스타일
|
||||
)}>
|
||||
{isNotice && '📢 '}{post.categoryName}
|
||||
</span>
|
||||
<time className="text-gray-400 font-light">
|
||||
{format(new Date(post.createdAt), 'MMM dd, yyyy')}
|
||||
<Link href={`/posts/${post.slug}`} className="group block h-full">
|
||||
<Surface
|
||||
as="article"
|
||||
interactive
|
||||
className="flex h-full flex-col p-5"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="shrink-0">
|
||||
{post.categoryName || '미분류'}
|
||||
</StatusBadge>
|
||||
<time className="text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
{formatDate(post.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<h2 className={clsx(
|
||||
"text-xl font-bold mb-3 transition-colors line-clamp-2",
|
||||
isNotice ? "text-gray-900 group-hover:text-red-600" : "text-gray-800 group-hover:text-blue-600"
|
||||
)}>
|
||||
|
||||
<h2 className="line-clamp-2 text-xl font-bold leading-snug tracking-normal text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</h2>
|
||||
|
||||
{/* 요약글 */}
|
||||
<p className="text-gray-500 text-sm line-clamp-3 mb-6 flex-1 leading-relaxed break-words min-h-[3rem]">
|
||||
{summary}
|
||||
|
||||
<p className="mt-3 line-clamp-3 flex-1 break-words text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
{getSummary(post.content)}
|
||||
</p>
|
||||
|
||||
{/* 하단 정보 */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-50">
|
||||
<span className={clsx(
|
||||
"text-xs font-medium flex items-center gap-1",
|
||||
isNotice ? "text-red-500" : "text-blue-500"
|
||||
)}>
|
||||
Read more <span className="group-hover:translate-x-1 transition-transform">→</span>
|
||||
<div className="mt-6 flex items-center justify-between border-t border-[var(--color-line)] pt-4">
|
||||
<span className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
읽기
|
||||
<ChevronRight size={15} className="transition group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</Surface>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import TOC from '@/components/post/TOC';
|
||||
import { Loader2, Calendar, Eye, Folder, User, ArrowLeft, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Post } from '@/types'; // 타입 임포트
|
||||
|
||||
interface PostDetailClientProps {
|
||||
@@ -66,18 +67,18 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
||||
const isAuthError = errorStatus === 401 || errorStatus === 403;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-20 text-center">
|
||||
<div className="mx-auto max-w-4xl px-4 py-20 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<AlertCircle className="text-gray-300" size={64} />
|
||||
<AlertCircle className="text-[var(--color-text-subtle)]" size={64} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
<h2 className="mb-2 text-2xl font-bold text-[var(--color-text)]">
|
||||
{isAuthError ? '접근 권한이 없습니다.' : '게시글을 불러올 수 없습니다.'}
|
||||
</h2>
|
||||
<p className="text-gray-500 mb-6">
|
||||
<p className="mb-6 text-[var(--color-text-muted)]">
|
||||
{isAuthError ? '로그인이 필요하거나 비공개 게시글일 수 있습니다.' : errorMessage}
|
||||
</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<button onClick={() => router.push('/')} className="px-5 py-2.5 bg-gray-100 text-gray-700 font-medium rounded-lg hover:bg-gray-200 transition-colors">메인으로</button>
|
||||
<button onClick={() => router.push('/')} className="rounded-lg border border-[var(--color-line)] bg-white/70 px-5 py-2.5 font-semibold text-[var(--color-text-muted)] transition-colors hover:bg-white">메인으로</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -88,59 +89,70 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
|
||||
const nextPost = post.nextPost;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="mx-auto max-w-7xl px-4 py-12 md:px-8">
|
||||
<Link href="/" className="mb-8 inline-flex items-center gap-1 text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)]">
|
||||
<ArrowLeft size={18} />
|
||||
<span className="text-sm font-medium">목록으로</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col xl:flex-row gap-8 xl:gap-16 relative">
|
||||
<div className="relative grid gap-8 xl:grid-cols-[minmax(0,820px)_220px] xl:justify-center xl:gap-16">
|
||||
|
||||
<main className="min-w-0 xl:flex-1">
|
||||
<main className="min-w-0">
|
||||
<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">
|
||||
<header className="mb-10 border-b border-[var(--color-line)] pb-8">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex items-center gap-2 rounded-full bg-[var(--color-accent-soft)] px-3 py-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
<Folder size={14} />
|
||||
<span>{post.categoryName || 'Uncategorized'}</span>
|
||||
<span>{post.categoryName || '미분류'}</span>
|
||||
</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">
|
||||
<h1 className="mb-6 break-keep text-3xl font-bold leading-tight tracking-normal text-[var(--color-text)] md:text-5xl">{post.title}</h1>
|
||||
<div className="flex flex-wrap items-center gap-6 text-sm text-[var(--color-text-muted)]">
|
||||
<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>
|
||||
{profile?.imageUrl ? (
|
||||
<Image
|
||||
src={profile.imageUrl}
|
||||
alt="작성자"
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 rounded-full border border-[var(--color-line)] object-cover shadow-sm"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black/[0.05] dark:bg-white/10"><User size={16} /></div>
|
||||
)}
|
||||
<span className="font-bold text-[var(--color-text)]">{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 className="flex items-center gap-1.5"><Calendar size={16} />{new Date(post.createdAt).toLocaleDateString('ko-KR')}</div>
|
||||
<div className="flex items-center gap-1.5"><Eye size={16} />조회 {post.viewCount.toLocaleString()}</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">
|
||||
<div className="prose prose-lg mb-20 max-w-none prose-headings:font-bold prose-a:text-[var(--color-accent)] prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100">
|
||||
<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">
|
||||
<nav className="mb-16 grid grid-cols-1 gap-4 border-y border-[var(--color-line)] py-8 md:grid-cols-2">
|
||||
{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 href={`/posts/${prevPost.slug}`} className="group flex w-full flex-col items-start gap-1 rounded-lg border border-transparent bg-white/60 p-5 transition-colors hover:border-[var(--color-line)] hover:bg-white dark:bg-white/10">
|
||||
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]"><ChevronLeft size={16} /> 이전 글</span>
|
||||
<span className="line-clamp-1 w-full text-left font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">{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>}
|
||||
) : <div className="hidden w-full cursor-not-allowed rounded-lg bg-white/40 p-5 opacity-60 dark:bg-white/5 md:block"><span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]"><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 href={`/posts/${nextPost.slug}`} className="group flex w-full flex-col items-end gap-1 rounded-lg border border-transparent bg-white/60 p-5 transition-colors hover:border-[var(--color-line)] hover:bg-white dark:bg-white/10">
|
||||
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]">다음 글 <ChevronRight size={16} /></span>
|
||||
<span className="line-clamp-1 w-full text-right font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">{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>}
|
||||
) : <div className="hidden w-full cursor-not-allowed flex-col items-end gap-1 rounded-lg bg-white/40 p-5 opacity-60 dark:bg-white/5 md:flex"><span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]">다음 글 없음 <ChevronRight size={16} /></span></div>}
|
||||
</nav>
|
||||
|
||||
<CommentList postSlug={post.slug} />
|
||||
</main>
|
||||
|
||||
<aside className="hidden 2xl:block w-[220px] shrink-0">
|
||||
<aside className="hidden w-[220px] shrink-0 xl:block">
|
||||
<div className="sticky top-24">
|
||||
<TOC content={post.content || ''} />
|
||||
</div>
|
||||
|
||||
@@ -1,60 +1,64 @@
|
||||
import Link from 'next/link';
|
||||
import { Post } from '@/types';
|
||||
import { format } from 'date-fns';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { ChevronRight, Eye } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Post } from '@/types';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
|
||||
interface PostListItemProps {
|
||||
post: Post;
|
||||
showViews?: boolean;
|
||||
}
|
||||
|
||||
export default function PostListItem({ post }: PostListItemProps) {
|
||||
// 📢 공지 카테고리 여부 확인
|
||||
const isNotice = post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
const isNoticePost = (post: Post) => {
|
||||
return post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export default function PostListItem({ post, showViews = false }: PostListItemProps) {
|
||||
const isNotice = isNoticePost(post);
|
||||
|
||||
return (
|
||||
<Link href={`/posts/${post.slug}`} className="block group">
|
||||
<div className={clsx(
|
||||
"flex items-center justify-between py-4 border-b px-4 -mx-4 rounded-lg transition-colors",
|
||||
isNotice
|
||||
? "border-red-50 hover:bg-red-50/30" // 공지일 때 배경색 살짝 붉게
|
||||
: "border-gray-100 hover:bg-gray-50"
|
||||
)}>
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
{/* 카테고리 라벨 */}
|
||||
<span className={clsx(
|
||||
"hidden sm:inline-block px-2.5 py-1 rounded-md text-xs font-medium whitespace-nowrap",
|
||||
isNotice
|
||||
? "bg-red-100 text-red-600 font-bold" // 🔴 공지 스타일 강조
|
||||
: "bg-slate-100 text-slate-600"
|
||||
)}>
|
||||
{isNotice && '📢 '}{post.categoryName}
|
||||
</span>
|
||||
|
||||
{/* 제목 */}
|
||||
<h3 className={clsx(
|
||||
"text-base font-medium truncate transition-colors",
|
||||
isNotice
|
||||
? "text-gray-900 group-hover:text-red-600 font-semibold"
|
||||
: "text-gray-800 group-hover:text-blue-600"
|
||||
)}>
|
||||
<Link href={`/posts/${post.slug}`} className="group block">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between gap-4 rounded-lg px-3 py-4 transition duration-150',
|
||||
isNotice ? 'hover:bg-red-500/[0.06]' : 'hover:bg-black/[0.03] dark:hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="hidden shrink-0 sm:inline-flex">
|
||||
{post.categoryName || '미분류'}
|
||||
</StatusBadge>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</h3>
|
||||
<time className="mt-1 block text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
{formatDate(post.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 sm:gap-6 text-sm text-gray-400 ml-4 whitespace-nowrap">
|
||||
{/* 조회수 */}
|
||||
<div className="hidden sm:flex items-center gap-1.5" title="조회수">
|
||||
<Eye size={14} />
|
||||
<span className="text-xs">{post.viewCount}</span>
|
||||
</div>
|
||||
|
||||
{/* 날짜 */}
|
||||
<time className="font-light tabular-nums text-xs sm:text-sm">
|
||||
{format(new Date(post.createdAt), 'yyyy.MM.dd')}
|
||||
</time>
|
||||
<div className="ml-3 flex shrink-0 items-center gap-4 text-sm text-[var(--color-text-subtle)]">
|
||||
{showViews && (
|
||||
<div className="hidden items-center gap-1.5 sm:flex" title="조회수">
|
||||
<Eye size={14} />
|
||||
<span className="text-xs">조회 {post.viewCount.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight size={18} className="transition group-hover:translate-x-0.5 group-hover:text-[var(--color-accent)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
// 🛠️ 중요: rehype-slug와 동일한 ID 생성을 위해 라이브러리 사용
|
||||
// npm install github-slugger 실행 필요
|
||||
@@ -18,23 +18,21 @@ interface HeadingItem {
|
||||
|
||||
export default function TOC({ content }: TOCProps) {
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
const [headings, setHeadings] = useState<HeadingItem[]>([]);
|
||||
|
||||
// 1. 마크다운에서 헤딩(#) 추출 및 Slug 생성
|
||||
useEffect(() => {
|
||||
const headings = useMemo(() => {
|
||||
const slugger = new GithubSlugger(); // Slugger 인스턴스 (중복 ID 처리용)
|
||||
const lines = content.split('\n');
|
||||
|
||||
// 코드 블럭 내의 #은 무시
|
||||
let inCodeBlock = false;
|
||||
|
||||
const matches = lines.reduce<HeadingItem[]>((acc, line) => {
|
||||
const result = lines.reduce<{ headings: HeadingItem[]; inCodeBlock: boolean }>((acc, line) => {
|
||||
// 코드 블럭 진입/이탈 체크 (```)
|
||||
if (line.trim().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
return acc;
|
||||
return {
|
||||
...acc,
|
||||
inCodeBlock: !acc.inCodeBlock,
|
||||
};
|
||||
}
|
||||
if (inCodeBlock) return acc;
|
||||
if (acc.inCodeBlock) return acc;
|
||||
|
||||
// 헤딩 매칭 (# 1~3개)
|
||||
const match = line.match(/^(#{1,3})\s+(.+)$/);
|
||||
@@ -45,12 +43,15 @@ export default function TOC({ content }: TOCProps) {
|
||||
// 🛠️ 핵심: github-slugger로 ID 생성 (한글, 띄어쓰기 등 완벽 호환)
|
||||
const slug = slugger.slug(text);
|
||||
|
||||
acc.push({ text, level, slug });
|
||||
return {
|
||||
...acc,
|
||||
headings: [...acc.headings, { text, level, slug }],
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}, { headings: [], inCodeBlock: false });
|
||||
|
||||
setHeadings(matches);
|
||||
return result.headings;
|
||||
}, [content]);
|
||||
|
||||
// 2. 스크롤 감지 (IntersectionObserver)
|
||||
@@ -103,18 +104,18 @@ export default function TOC({ content }: TOCProps) {
|
||||
|
||||
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>
|
||||
<div className="rounded-lg border border-[var(--color-line)] bg-white/65 p-4 shadow-sm backdrop-blur-xl dark:bg-white/10">
|
||||
<h4 className="mb-4 text-sm font-bold text-[var(--color-text)]">목차</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는 들여쓰기
|
||||
"border-l-2 pl-3 text-sm transition-all duration-200",
|
||||
heading.level === 3 ? "ml-3 text-xs" : "", // h3는 들여쓰기
|
||||
activeId === heading.slug
|
||||
? "text-blue-600 font-bold translate-x-1"
|
||||
: "text-gray-500 hover:text-gray-900"
|
||||
? "border-[var(--color-accent)] font-bold text-[var(--color-accent)]"
|
||||
: "border-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
)}
|
||||
>
|
||||
<a
|
||||
@@ -130,4 +131,4 @@ export default function TOC({ content }: TOCProps) {
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user