.
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m4s

This commit is contained in:
wypark
2026-05-28 22:49:38 +09:00
parent 7bc75a32b2
commit 701bbdea4a
12 changed files with 169 additions and 326 deletions

View File

@@ -1,5 +1,4 @@
import Link from 'next/link';
import { ChevronRight } from 'lucide-react';
import { Post } from '@/types';
import StatusBadge from '@/components/ui/StatusBadge';
import Surface from '@/components/ui/Surface';
@@ -20,7 +19,7 @@ const formatDate = (value: string) => {
};
function getSummary(content?: string) {
if (!content) return '본문을 열어 전체 내용을 확인해 보세요.';
if (!content) return '';
return content
.replace(/```[\s\S]*?```/g, ' ')
@@ -34,13 +33,14 @@ function getSummary(content?: string) {
export default function PostCard({ post }: { post: Post }) {
const isNotice = isNoticePost(post);
const summary = getSummary(post.content);
return (
<Link href={`/posts/${post.slug}`} className="group block h-full">
<Surface
as="article"
interactive
className="flex h-full flex-col p-5"
className="flex h-full flex-col p-5 shadow-none"
>
<div className="mb-4 flex items-center justify-between gap-3">
<StatusBadge tone={isNotice ? 'danger' : 'neutral'} className="shrink-0">
@@ -55,16 +55,11 @@ export default function PostCard({ post }: { post: Post }) {
{post.title}
</h2>
<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="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>
{summary && (
<p className="mt-3 line-clamp-3 flex-1 break-words text-sm leading-6 text-[var(--color-text-muted)]">
{summary}
</p>
)}
</Surface>
</Link>
);

View File

@@ -6,10 +6,11 @@ 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, ArrowLeft, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
import { Loader2, Calendar, User, ArrowLeft, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import StatusBadge from '@/components/ui/StatusBadge';
import { Post } from '@/types'; // 타입 임포트
interface PostDetailClientProps {
@@ -89,25 +90,22 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
const nextPost = post.nextPost;
return (
<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)]">
<div className="mx-auto max-w-6xl px-4 py-10 md:px-8 md:py-14">
<Link href="/" className="mb-10 inline-flex items-center gap-1 text-sm font-medium text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)]">
<ArrowLeft size={18} />
<span className="text-sm font-medium"></span>
<span></span>
</Link>
<div className="relative grid gap-8 xl:grid-cols-[minmax(0,800px)_220px] xl:justify-center xl:gap-16">
<div className="relative grid gap-8 xl:grid-cols-[minmax(0,780px)_210px] xl:justify-center xl:gap-16">
<main className="min-w-0">
<article>
<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 || '미분류'}</span>
</div>
</div>
<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)]">
<header className="mb-12 border-b border-[var(--color-line)] pb-8">
<StatusBadge tone="neutral" className="mb-5">
{post.categoryName || '미분류'}
</StatusBadge>
<h1 className="mb-7 break-keep text-4xl font-bold leading-[1.08] tracking-normal text-[var(--color-text)] md:text-6xl">{post.title}</h1>
<div className="flex flex-wrap items-center gap-5 text-sm text-[var(--color-text-muted)]">
<div className="flex items-center gap-2">
{profile?.imageUrl ? (
<Image
@@ -124,11 +122,10 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
<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('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 mb-20 max-w-none prose-headings:font-bold prose-headings:text-[var(--color-text)] prose-p:text-[var(--color-text)] prose-strong:text-[var(--color-text)] prose-li:text-[var(--color-text-muted)] prose-a:text-[var(--color-accent)] prose-hr:border-[var(--color-line)] prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100">
<div className="prose prose-lg mb-20 max-w-none prose-headings:font-bold prose-headings:tracking-normal prose-headings:text-[var(--color-text)] prose-p:text-[var(--color-text)] prose-p:leading-8 prose-strong:text-[var(--color-text)] prose-li:text-[var(--color-text-muted)] prose-li:leading-8 prose-a:text-[var(--color-accent)] prose-hr:border-[var(--color-line)] prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100">
<MarkdownRenderer content={post.content || ''} />
</div>
</article>
@@ -152,7 +149,7 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
<CommentList postSlug={post.slug} />
</main>
<aside className="hidden w-[220px] shrink-0 xl:block">
<aside className="hidden w-[210px] shrink-0 xl:block">
<div className="sticky top-24">
<TOC content={post.content || ''} />
</div>

View File

@@ -2,8 +2,6 @@
import { useEffect, useMemo, useState } from 'react';
import { clsx } from 'clsx';
// 🛠️ 중요: rehype-slug와 동일한 ID 생성을 위해 라이브러리 사용
// npm install github-slugger 실행 필요
import GithubSlugger from 'github-slugger';
interface TOCProps {
@@ -19,13 +17,11 @@ interface HeadingItem {
export default function TOC({ content }: TOCProps) {
const [activeId, setActiveId] = useState<string>('');
// 1. 마크다운에서 헤딩(#) 추출 및 Slug 생성
const headings = useMemo(() => {
const slugger = new GithubSlugger(); // Slugger 인스턴스 (중복 ID 처리용)
const slugger = new GithubSlugger();
const lines = content.split('\n');
const result = lines.reduce<{ headings: HeadingItem[]; inCodeBlock: boolean }>((acc, line) => {
// 코드 블럭 진입/이탈 체크 (```)
if (line.trim().startsWith('```')) {
return {
...acc,
@@ -34,13 +30,10 @@ export default function TOC({ content }: TOCProps) {
}
if (acc.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 text = match[2].replace(/(\*\*|__)/g, '').trim();
const slug = slugger.slug(text);
return {
@@ -54,7 +47,6 @@ export default function TOC({ content }: TOCProps) {
return result.headings;
}, [content]);
// 2. 스크롤 감지 (IntersectionObserver)
useEffect(() => {
if (headings.length === 0) return;
@@ -66,7 +58,6 @@ export default function TOC({ content }: TOCProps) {
}
});
},
// 상단 헤더 공간 등을 고려하여 감지 영역 조정
{ rootMargin: '-10% 0px -80% 0px' }
);
@@ -78,13 +69,11 @@ export default function TOC({ content }: TOCProps) {
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;
@@ -94,7 +83,6 @@ export default function TOC({ content }: TOCProps) {
behavior: 'smooth'
});
// URL 해시 변경 및 상태 즉시 업데이트
history.pushState(null, '', `#${slug}`);
setActiveId(slug);
}
@@ -104,18 +92,18 @@ export default function TOC({ content }: TOCProps) {
return (
<aside className="w-full">
<div className="rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] p-4 shadow-[var(--shadow-control)] backdrop-blur-2xl">
<h4 className="mb-4 text-sm font-bold text-[var(--color-text)]"></h4>
<div className="border-l border-[var(--color-line)] pl-4">
<h4 className="mb-4 text-xs font-semibold uppercase text-[var(--color-text-subtle)]"></h4>
<ul className="space-y-2.5">
{headings.map((heading, index) => (
<li
key={`${heading.slug}-${index}`}
className={clsx(
"border-l-2 pl-3 text-sm transition-all duration-200",
heading.level === 3 ? "ml-3 text-xs" : "", // h3는 들여쓰기
"text-sm transition-colors duration-150",
heading.level === 3 ? "ml-3 text-xs" : "",
activeId === heading.slug
? "border-[var(--color-accent)] font-bold text-[var(--color-accent)]"
: "border-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
? "font-semibold text-[var(--color-accent)]"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
)}
>
<a