This commit is contained in:
BIN
src/app/fonts/PretendardVariable.woff2
Normal file
BIN
src/app/fonts/PretendardVariable.woff2
Normal file
Binary file not shown.
@@ -4,13 +4,15 @@
|
||||
|
||||
:root {
|
||||
--font-apple-sans:
|
||||
var(--font-pretendard),
|
||||
"Pretendard Variable",
|
||||
Pretendard,
|
||||
"Apple SD Gothic Neo",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"SF Pro Display",
|
||||
"SF Pro Text",
|
||||
"Apple SD Gothic Neo",
|
||||
"Helvetica Neue",
|
||||
Pretendard,
|
||||
"Noto Sans KR",
|
||||
system-ui,
|
||||
sans-serif;
|
||||
@@ -34,9 +36,9 @@
|
||||
--color-accent-hover: #0071e3;
|
||||
--color-accent-soft: rgba(0, 102, 204, 0.1);
|
||||
--color-danger-soft: rgba(255, 59, 48, 0.1);
|
||||
--shadow-panel: 0 22px 70px rgba(0, 0, 0, 0.1);
|
||||
--shadow-card: 0 14px 34px rgba(0, 0, 0, 0.065);
|
||||
--shadow-control: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
--shadow-panel: 0 18px 45px rgba(0, 0, 0, 0.07);
|
||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.035);
|
||||
--shadow-control: 0 6px 18px rgba(0, 0, 0, 0.055);
|
||||
--focus-ring: 0 0 0 4px rgba(0, 102, 204, 0.14);
|
||||
--background: var(--color-page);
|
||||
--foreground: var(--color-text);
|
||||
@@ -63,9 +65,9 @@ html[data-theme="dark"] {
|
||||
--color-accent-hover: #46a6ff;
|
||||
--color-accent-soft: rgba(41, 151, 255, 0.16);
|
||||
--color-danger-soft: rgba(255, 69, 58, 0.16);
|
||||
--shadow-panel: 0 22px 70px rgba(0, 0, 0, 0.42);
|
||||
--shadow-card: 0 14px 34px rgba(0, 0, 0, 0.3);
|
||||
--shadow-control: 0 10px 28px rgba(0, 0, 0, 0.32);
|
||||
--shadow-panel: 0 18px 45px rgba(0, 0, 0, 0.32);
|
||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
--shadow-control: 0 8px 22px rgba(0, 0, 0, 0.24);
|
||||
--focus-ring: 0 0 0 4px rgba(41, 151, 255, 0.18);
|
||||
--background: var(--color-page);
|
||||
--foreground: var(--color-text);
|
||||
@@ -76,8 +78,13 @@ body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-apple-sans);
|
||||
font-feature-settings: "kern";
|
||||
font-feature-settings: "kern", "ss05";
|
||||
font-size: 16px;
|
||||
font-weight: 450;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import localFont from 'next/font/local';
|
||||
import './globals.css';
|
||||
import Providers from './providers';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
@@ -6,6 +7,13 @@ import TopHeader from '@/components/layout/TopHeader';
|
||||
import Script from 'next/script';
|
||||
import { THEME_INIT_SCRIPT } from '@/components/theme/theme';
|
||||
|
||||
const pretendard = localFont({
|
||||
src: './fonts/PretendardVariable.woff2',
|
||||
variable: '--font-pretendard',
|
||||
weight: '45 920',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'WYPark Blog',
|
||||
description: '개발 블로그',
|
||||
@@ -21,7 +29,7 @@ export default function RootLayout({
|
||||
<head>
|
||||
<meta name="google-site-verification" content="cFJSK1ayy2Y4lqRKNv8wZ_vybg5De22zYCdbKSfvAJA" />
|
||||
</head>
|
||||
<body className="bg-[var(--color-page)] text-[var(--color-text)]">
|
||||
<body className={`${pretendard.variable} ${pretendard.className} bg-[var(--color-page)] text-[var(--color-text)] antialiased`}>
|
||||
<Script id="theme-init" strategy="beforeInteractive">
|
||||
{THEME_INIT_SCRIPT}
|
||||
</Script>
|
||||
|
||||
277
src/app/page.tsx
277
src/app/page.tsx
@@ -1,24 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useMemo } from 'react';
|
||||
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||
import { Suspense } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Archive,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
FolderTree,
|
||||
Loader2,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { getCategories } from '@/api/category';
|
||||
import { getPosts } from '@/api/posts';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import Surface from '@/components/ui/Surface';
|
||||
import { Category, Post, PostListResponse } from '@/types';
|
||||
import { Post, PostListResponse } from '@/types';
|
||||
|
||||
const isNoticePost = (post: Post) => {
|
||||
return post.categoryName === '공지' || post.categoryName.toLowerCase() === 'notice';
|
||||
@@ -38,7 +35,7 @@ const formatDate = (value?: string) => {
|
||||
};
|
||||
|
||||
const getSummary = (content?: string, maxLength = 118) => {
|
||||
if (!content) return '아직 요약할 본문이 없습니다. 글을 열어 전체 내용을 확인해 보세요.';
|
||||
if (!content) return '';
|
||||
|
||||
return content
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
@@ -50,23 +47,6 @@ const getSummary = (content?: string, maxLength = 118) => {
|
||||
.slice(0, maxLength);
|
||||
};
|
||||
|
||||
const flattenCategories = (categories: Category[] = []): Category[] => {
|
||||
return categories.flatMap((category) => [
|
||||
category,
|
||||
...flattenCategories(category.children || []),
|
||||
]);
|
||||
};
|
||||
|
||||
const getHomeCategories = (categories: Category[] = []) => {
|
||||
return flattenCategories(categories)
|
||||
.filter((category) => {
|
||||
const name = category.name.toLowerCase();
|
||||
return category.name !== '공지' && name !== 'notice' && name !== '미분류';
|
||||
})
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.slice(0, 3);
|
||||
};
|
||||
|
||||
const getTotalElements = (data?: PostListResponse) => {
|
||||
return data?.page?.totalElements ?? data?.totalElements ?? 0;
|
||||
};
|
||||
@@ -123,7 +103,7 @@ function NoticeStrip({ notices }: { notices: Post[] }) {
|
||||
<Link key={notice.id} href={`/posts/${notice.slug}`} className="group block">
|
||||
<Surface
|
||||
interactive
|
||||
className="flex items-center justify-between gap-4 border-red-500/10 bg-red-500/[0.06] px-4 py-3 shadow-none"
|
||||
className="flex items-center justify-between gap-4 border-red-500/10 bg-red-500/[0.045] px-4 py-3 shadow-none"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<StatusBadge tone="danger">공지</StatusBadge>
|
||||
@@ -145,33 +125,33 @@ function NoticeStrip({ notices }: { notices: Post[] }) {
|
||||
function FeaturedPost({ post }: { post?: Post }) {
|
||||
if (!post) {
|
||||
return (
|
||||
<Surface as="section" strong className="flex min-h-72 items-center justify-center p-6">
|
||||
<EmptyState title="대표 글을 기다리고 있습니다." description="새 글이 발행되면 이곳에 먼저 표시됩니다." />
|
||||
<Surface as="section" strong className="flex min-h-72 items-center justify-center p-6 shadow-none">
|
||||
<EmptyState title="아직 공개된 글이 없습니다." />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = getSummary(post.content, 170);
|
||||
|
||||
return (
|
||||
<Link href={`/posts/${post.slug}`} className="group block h-full">
|
||||
<Surface as="article" strong interactive className="flex h-full min-h-64 flex-col p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<StatusBadge tone="info">{post.categoryName || '미분류'}</StatusBadge>
|
||||
<time className="text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
<Surface as="article" strong interactive className="flex h-full min-h-72 flex-col p-7 shadow-none md:p-9">
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
<StatusBadge tone="neutral">{post.categoryName || '미분류'}</StatusBadge>
|
||||
<time className="text-xs font-medium tabular-nums text-[var(--color-text-subtle)]">
|
||||
{formatDate(post.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<p className="mb-3 text-sm font-semibold text-[var(--color-accent)]">대표 글</p>
|
||||
<h2 className="text-2xl font-bold leading-tight tracking-normal text-[var(--color-text)] md:text-3xl">
|
||||
<p className="mb-4 text-sm font-semibold text-[var(--color-accent)]">대표 글</p>
|
||||
<h2 className="text-3xl font-bold leading-tight tracking-normal text-[var(--color-text)] md:text-4xl">
|
||||
{post.title}
|
||||
</h2>
|
||||
<p className="mt-4 line-clamp-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
{getSummary(post.content, 160)}
|
||||
</p>
|
||||
<span className="mt-6 inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
읽기
|
||||
<ChevronRight size={16} className="transition group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
{summary && (
|
||||
<p className="mt-5 line-clamp-3 text-[15px] leading-7 text-[var(--color-text-muted)]">
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Surface>
|
||||
</Link>
|
||||
@@ -179,10 +159,12 @@ function FeaturedPost({ post }: { post?: Post }) {
|
||||
}
|
||||
|
||||
function CompactPostRow({ post }: { post: Post }) {
|
||||
const summary = getSummary(post.content, 92);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/posts/${post.slug}`}
|
||||
className="group flex items-start justify-between gap-4 rounded-lg px-3 py-4 transition duration-150 hover:bg-black/[0.03] dark:hover:bg-white/10"
|
||||
className="group flex items-start justify-between gap-4 rounded-lg px-1 py-4 transition duration-150 hover:bg-black/[0.025] hover:px-3 dark:hover:bg-white/[0.07]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
@@ -196,69 +178,17 @@ function CompactPostRow({ post }: { post: Post }) {
|
||||
<h3 className="line-clamp-1 text-base font-semibold text-[var(--color-text)] transition group-hover:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</h3>
|
||||
<p className="mt-1 line-clamp-2 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
{getSummary(post.content, 96)}
|
||||
</p>
|
||||
{summary && (
|
||||
<p className="mt-1 line-clamp-2 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight size={18} className="mt-7 shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5 group-hover:text-[var(--color-accent)]" />
|
||||
<ChevronRight size={17} className="mt-7 shrink-0 text-[var(--color-text-subtle)] transition group-hover:translate-x-0.5 group-hover:text-[var(--color-accent)]" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryShelf({
|
||||
category,
|
||||
posts,
|
||||
isLoading,
|
||||
}: {
|
||||
category: Category;
|
||||
posts: Post[];
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Surface as="article" className="p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-bold text-[var(--color-text)]">
|
||||
{category.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">최근 글</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/category/${category.name}`}
|
||||
className="shrink-0 text-xs font-semibold text-[var(--color-accent)]"
|
||||
>
|
||||
전체 보기
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2].map((item) => (
|
||||
<div key={item} className="h-10 animate-pulse rounded bg-black/[0.04] dark:bg-white/10" />
|
||||
))}
|
||||
</div>
|
||||
) : posts.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{posts.slice(0, 3).map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/posts/${post.slug}`}
|
||||
className="group flex items-center justify-between gap-3 py-3"
|
||||
>
|
||||
<span className="line-clamp-1 text-sm font-semibold text-[var(--color-text-muted)] transition group-hover:text-[var(--color-accent)]">
|
||||
{post.title}
|
||||
</span>
|
||||
<ChevronRight size={15} className="shrink-0 text-[var(--color-text-subtle)]" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="아직 글이 없습니다." className="min-h-28 py-6" />
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const keyword = searchParams.get('keyword') || '';
|
||||
@@ -271,19 +201,7 @@ function HomeContent() {
|
||||
|
||||
const { data: latestData, isLoading: isLatestLoading } = useQuery({
|
||||
queryKey: ['posts', 'latest'],
|
||||
queryFn: () => getPosts({ size: 8, sort: 'createdAt,desc' }),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const { data: popularData, isLoading: isPopularLoading } = useQuery({
|
||||
queryKey: ['posts', 'popular'],
|
||||
queryFn: () => getPosts({ size: 5, sort: 'viewCount,desc' }),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const { data: categories } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: getCategories,
|
||||
queryFn: () => getPosts({ size: 7, sort: 'createdAt,desc' }),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
@@ -294,22 +212,11 @@ function HomeContent() {
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const shelfCategories = useMemo(() => getHomeCategories(categories), [categories]);
|
||||
const categoryPostQueries = useQueries({
|
||||
queries: shelfCategories.map((category) => ({
|
||||
queryKey: ['posts', 'category-shelf', category.name],
|
||||
queryFn: () => getPosts({ category: category.name, size: 3, sort: 'createdAt,desc' }),
|
||||
enabled: !keyword,
|
||||
retry: 0,
|
||||
})),
|
||||
});
|
||||
|
||||
const notices = noticesData?.content || [];
|
||||
const latestPosts = (latestData?.content || []).filter((post) => !isNoticePost(post));
|
||||
const popularPosts = (popularData?.content || []).filter((post) => !isNoticePost(post));
|
||||
const featuredPost = latestPosts[0];
|
||||
const latestList = latestPosts.slice(1, 7);
|
||||
const isHomeLoading = !keyword && (isNoticesLoading || isLatestLoading || isPopularLoading);
|
||||
const isHomeLoading = !keyword && (isNoticesLoading || isLatestLoading);
|
||||
|
||||
if (keyword) {
|
||||
return (
|
||||
@@ -328,64 +235,36 @@ function HomeContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl space-y-12 px-4 py-8 md:px-6">
|
||||
<section className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr] lg:items-end">
|
||||
<div className="max-w-2xl">
|
||||
<StatusBadge tone="info" className="mb-5">
|
||||
WYPark Blog
|
||||
</StatusBadge>
|
||||
<h1 className="text-4xl font-bold leading-tight tracking-normal text-[var(--color-text)] md:text-5xl">
|
||||
<main className="mx-auto max-w-5xl space-y-10 px-4 py-8 md:px-6 md:py-12">
|
||||
<section className="max-w-3xl pt-2 md:pt-8">
|
||||
<h1 className="text-4xl font-bold leading-[1.08] tracking-normal text-[var(--color-text)] md:text-6xl">
|
||||
박원엽의 개발 기록
|
||||
</h1>
|
||||
<p className="mt-5 text-base leading-7 text-[var(--color-text-muted)] md:text-lg">
|
||||
백엔드, 네트워크, 운영 경험을 차분하게 정리하는 개인 기술 블로그입니다.
|
||||
</p>
|
||||
<div className="mt-7 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/archive"
|
||||
className="inline-flex h-10 items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 text-sm font-semibold text-white shadow-[var(--shadow-control)] transition hover:bg-[var(--color-accent-hover)]"
|
||||
>
|
||||
전체 글 둘러보기
|
||||
<Archive size={16} />
|
||||
</Link>
|
||||
<Link
|
||||
href="#blog-search"
|
||||
className="inline-flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-4 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
검색으로 찾기
|
||||
<Search size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Surface className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[var(--color-accent-soft)] text-[var(--color-accent)]">
|
||||
<Sparkles size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[var(--color-text)]">최근 업데이트</p>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{featuredPost ? formatDate(featuredPost.createdAt) : '새 글을 준비 중입니다.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
</h1>
|
||||
<p className="mt-5 max-w-2xl text-[17px] leading-8 text-[var(--color-text-muted)]">
|
||||
배운 것과 운영하며 부딪힌 문제를 짧고 정확하게 남깁니다.
|
||||
</p>
|
||||
<Link
|
||||
href="/archive"
|
||||
className="mt-7 inline-flex h-10 items-center gap-2 rounded-full bg-[var(--color-text)] px-4 text-sm font-semibold text-[var(--color-page)] shadow-[var(--shadow-control)] transition hover:opacity-90 dark:bg-white dark:text-black"
|
||||
>
|
||||
전체 글
|
||||
<Archive size={16} />
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<NoticeStrip notices={notices} />
|
||||
|
||||
<section className={latestList.length > 0 ? 'grid gap-6 lg:grid-cols-[1.05fr_0.95fr]' : 'grid gap-6'}>
|
||||
<section className={latestList.length > 0 ? 'grid gap-6 lg:grid-cols-[1.1fr_0.9fr]' : 'grid gap-6'}>
|
||||
<FeaturedPost post={featuredPost} />
|
||||
|
||||
{latestList.length > 0 && (
|
||||
<Surface as="section" className="p-5">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<Surface as="section" className="p-5 shadow-none md:p-6">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={18} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">최신 글</h2>
|
||||
</div>
|
||||
<Link href="/archive" className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
<Link href="/archive" className="inline-flex items-center gap-1 text-sm font-semibold text-[var(--color-text-muted)] transition hover:text-[var(--color-accent)]">
|
||||
전체 보기
|
||||
<ChevronRight size={15} />
|
||||
</Link>
|
||||
@@ -399,64 +278,6 @@ function HomeContent() {
|
||||
</Surface>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<FolderTree size={19} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-xl font-bold text-[var(--color-text)]">카테고리별 글</h2>
|
||||
</div>
|
||||
{shelfCategories.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{shelfCategories.map((category, index) => (
|
||||
<CategoryShelf
|
||||
key={category.id}
|
||||
category={category}
|
||||
posts={categoryPostQueries[index]?.data?.content || []}
|
||||
isLoading={categoryPostQueries[index]?.isLoading || false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="카테고리를 불러오지 못했습니다." description="카테고리 API가 연결되면 이곳에 탐색 선반이 표시됩니다." />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<Surface as="section" className="p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Sparkles size={18} className="text-[var(--color-accent)]" />
|
||||
<h2 className="text-lg font-bold text-[var(--color-text)]">많이 읽힌 글</h2>
|
||||
</div>
|
||||
{popularPosts.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--color-line)]">
|
||||
{popularPosts.slice(0, 4).map((post) => (
|
||||
<CompactPostRow key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="인기 글을 집계 중입니다." description="조회수 숫자는 공개 홈에서 강조하지 않습니다." />
|
||||
)}
|
||||
</Surface>
|
||||
|
||||
<Surface as="section" className="flex flex-col justify-between gap-8 p-6">
|
||||
<div>
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--color-accent-soft)] text-[var(--color-accent)]">
|
||||
<Archive size={20} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold tracking-normal text-[var(--color-text)]">기록은 아카이브에 쌓입니다.</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
월별 글 흐름과 지난 글은 아카이브에서 한 번에 살펴볼 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/archive"
|
||||
className="inline-flex w-fit items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-2 text-sm font-semibold text-[var(--color-text)] shadow-[var(--shadow-control)] transition hover:bg-[var(--color-surface-strong)]"
|
||||
>
|
||||
아카이브 보기
|
||||
<ChevronRight size={16} />
|
||||
</Link>
|
||||
</Surface>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,12 +25,13 @@ import { Category, Profile } from '@/types';
|
||||
interface CategoryItemProps {
|
||||
category: Category;
|
||||
depth: number;
|
||||
onNavigate: () => void;
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
const defaultProfile: Profile = {
|
||||
name: 'Dev Park',
|
||||
bio: '풀스택을 꿈꾸는 개발자\n"코드로 세상을 바꾸고 싶은 박개발의 기술 블로그입니다."',
|
||||
bio: '개발자',
|
||||
imageUrl: 'https://api.dicebear.com/7.x/notionists/svg?seed=Felix',
|
||||
githubUrl: 'https://github.com',
|
||||
email: 'user@example.com',
|
||||
@@ -40,7 +41,7 @@ const sortCategories = (categories: Category[] = []) => {
|
||||
return [...categories].sort((a, b) => a.id - b.id);
|
||||
};
|
||||
|
||||
function CategoryItem({ category, depth, pathname }: CategoryItemProps) {
|
||||
function CategoryItem({ category, depth, onNavigate, pathname }: CategoryItemProps) {
|
||||
const sortedChildren = useMemo(() => sortCategories(category.children), [category.children]);
|
||||
const hasChildren = sortedChildren.length > 0;
|
||||
const isActive = decodeURIComponent(pathname) === `/category/${category.name}`;
|
||||
@@ -64,14 +65,18 @@ function CategoryItem({ category, depth, pathname }: CategoryItemProps) {
|
||||
<div className="mb-1">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between rounded-lg px-4 py-2 text-sm transition-all',
|
||||
'flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-all',
|
||||
isActive
|
||||
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
|
||||
)}
|
||||
style={{ marginLeft: `${depth * 10}px` }}
|
||||
style={{ marginLeft: `${depth * 8}px` }}
|
||||
>
|
||||
<Link href={`/category/${category.name}`} className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||
<Link
|
||||
href={`/category/${category.name}`}
|
||||
onClick={onNavigate}
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5"
|
||||
>
|
||||
{isActive ? <FolderOpen size={16} /> : <Folder size={16} />}
|
||||
<span className="truncate">{category.name}</span>
|
||||
</Link>
|
||||
@@ -102,6 +107,7 @@ function CategoryItem({ category, depth, pathname }: CategoryItemProps) {
|
||||
key={child.id}
|
||||
category={child}
|
||||
depth={depth + 1}
|
||||
onNavigate={onNavigate}
|
||||
pathname={pathname}
|
||||
/>
|
||||
))}
|
||||
@@ -112,7 +118,7 @@ function CategoryItem({ category, depth, pathname }: CategoryItemProps) {
|
||||
}
|
||||
|
||||
function SidebarContent() {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
@@ -134,14 +140,18 @@ function SidebarContent() {
|
||||
|
||||
const displayProfile = profile ? { ...defaultProfile, ...profile } : defaultProfile;
|
||||
|
||||
const closeSidebar = () => setIsOpen(false);
|
||||
|
||||
const handleSearch = (newKeyword: string) => {
|
||||
const trimmedKeyword = newKeyword.trim();
|
||||
if (trimmedKeyword) {
|
||||
router.push(`/?keyword=${encodeURIComponent(trimmedKeyword)}`);
|
||||
closeSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/');
|
||||
closeSidebar();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -155,15 +165,25 @@ function SidebarContent() {
|
||||
{isOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="사이드바 닫기"
|
||||
onClick={closeSidebar}
|
||||
className={clsx(
|
||||
'fixed inset-0 z-30 bg-black/35 backdrop-blur-sm transition-opacity md:hidden',
|
||||
isOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
/>
|
||||
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed left-0 top-0 z-40 flex h-screen flex-col overflow-hidden border-r border-[var(--color-line)] bg-[var(--color-surface-strong)]/95 backdrop-blur-xl transition-all duration-300 ease-in-out',
|
||||
isOpen ? 'w-72 translate-x-0' : 'w-0 -translate-x-full md:w-20 md:translate-x-0',
|
||||
'fixed left-0 top-0 z-40 flex h-screen w-72 flex-col overflow-hidden border-r border-[var(--color-line)] bg-[var(--color-surface-strong)]/96 backdrop-blur-xl transition-transform duration-300 ease-out',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
||||
)}
|
||||
>
|
||||
<div className={clsx('shrink-0 p-6 text-center transition-opacity duration-200', !isOpen && 'md:hidden md:opacity-0')}>
|
||||
<Link href="/" className="block transition-opacity hover:opacity-80">
|
||||
<div className="relative mx-auto mb-4 h-24 w-24 overflow-hidden rounded-full bg-black/[0.04] shadow-inner ring-4 ring-white/70 dark:bg-white/10 dark:ring-white/10">
|
||||
<div className="shrink-0 px-6 pb-5 pt-8 text-center">
|
||||
<Link href="/" onClick={closeSidebar} className="block transition-opacity hover:opacity-80">
|
||||
<div className="relative mx-auto mb-4 h-20 w-20 overflow-hidden rounded-full bg-black/[0.04] shadow-inner ring-1 ring-[var(--color-line)] dark:bg-white/10">
|
||||
{isProfileLoading ? (
|
||||
<div className="h-full w-full animate-pulse bg-black/[0.06] dark:bg-white/10" />
|
||||
) : (
|
||||
@@ -186,21 +206,17 @@ function SidebarContent() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-bold tracking-normal text-[var(--color-text)]">{displayProfile.name}</h2>
|
||||
<p className="mt-2 whitespace-pre-line text-sm leading-relaxed text-[var(--color-text-muted)]">{displayProfile.bio}</p>
|
||||
<h2 className="text-lg font-bold tracking-normal text-[var(--color-text)]">{displayProfile.name}</h2>
|
||||
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed text-[var(--color-text-muted)]">{displayProfile.bio}</p>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="flex min-h-0 flex-1 flex-col px-4 py-2">
|
||||
<div className={clsx('mt-4 flex shrink-0 flex-col items-center gap-4', isOpen && 'hidden')}>
|
||||
<Folder size={24} className="text-[var(--color-text-subtle)]" />
|
||||
</div>
|
||||
|
||||
<div className={clsx('flex h-full flex-col', !isOpen && 'md:hidden')}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 space-y-1">
|
||||
<div id="blog-search" className="mb-6 mt-2 px-1">
|
||||
<div id="blog-search" className="mb-5 mt-2 px-1">
|
||||
<PostSearch
|
||||
onSearch={handleSearch}
|
||||
placeholder="검색..."
|
||||
@@ -211,8 +227,9 @@ function SidebarContent() {
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href="/archive"
|
||||
onClick={closeSidebar}
|
||||
className={clsx(
|
||||
'flex items-center gap-2.5 rounded-lg px-4 py-2 text-sm transition-all',
|
||||
'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all',
|
||||
pathname === '/archive'
|
||||
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
|
||||
@@ -225,8 +242,8 @@ function SidebarContent() {
|
||||
|
||||
<div className="mb-4 border-t border-[var(--color-line)]" />
|
||||
|
||||
<div className="mb-3 flex h-8 items-center justify-between px-4">
|
||||
<p className="text-xs font-bold text-[var(--color-text-subtle)]">카테고리</p>
|
||||
<div className="mb-3 flex h-8 items-center justify-between px-3">
|
||||
<p className="text-xs font-semibold uppercase text-[var(--color-text-subtle)]">카테고리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -245,6 +262,7 @@ function SidebarContent() {
|
||||
key={category.id}
|
||||
category={category}
|
||||
depth={0}
|
||||
onNavigate={closeSidebar}
|
||||
pathname={pathname}
|
||||
/>
|
||||
))}
|
||||
@@ -252,8 +270,9 @@ function SidebarContent() {
|
||||
<div className="mb-1 mt-2">
|
||||
<Link
|
||||
href="/category/uncategorized"
|
||||
onClick={closeSidebar}
|
||||
className={clsx(
|
||||
'flex items-center gap-2.5 rounded-lg px-4 py-2 text-sm transition-all',
|
||||
'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all',
|
||||
pathname === '/category/uncategorized'
|
||||
? 'bg-[var(--color-accent-soft)] font-semibold text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
|
||||
@@ -268,7 +287,7 @@ function SidebarContent() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className={clsx('shrink-0 border-t border-[var(--color-line)] bg-[var(--color-surface-strong)]/80 p-6', !isOpen && 'hidden')}>
|
||||
<div className="shrink-0 border-t border-[var(--color-line)] bg-[var(--color-surface-strong)]/80 p-5">
|
||||
<div className="flex justify-center gap-3">
|
||||
<a
|
||||
href={displayProfile.githubUrl || '#'}
|
||||
@@ -288,7 +307,7 @@ function SidebarContent() {
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-4 text-center text-[10px] font-light text-[var(--color-text-subtle)]">
|
||||
© {new Date().getFullYear()} {displayProfile.name}. All rights reserved.
|
||||
© {new Date().getFullYear()} {displayProfile.name}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function TopHeader() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute right-4 top-4 z-30 flex max-w-[calc(100vw-5.5rem)] flex-wrap items-center justify-end gap-2 md:right-6 md:top-6 md:max-w-none md:gap-3">
|
||||
<div className="absolute right-4 top-4 z-30 flex max-w-[calc(100vw-5.5rem)] flex-wrap items-center justify-end gap-2 md:right-6 md:top-6 md:max-w-none">
|
||||
<ThemeToggle />
|
||||
|
||||
{_hasHydrated && (
|
||||
@@ -31,7 +31,7 @@ export default function TopHeader() {
|
||||
<>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] backdrop-blur-2xl transition-colors hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-accent)] sm:px-4"
|
||||
className="flex h-9 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] backdrop-blur-2xl transition-colors hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
<Settings size={16} />
|
||||
<span className="hidden sm:inline">관리자</span>
|
||||
@@ -39,7 +39,7 @@ export default function TopHeader() {
|
||||
|
||||
<Link
|
||||
href="/admin/posts/new"
|
||||
className="flex h-10 items-center gap-2 rounded-full bg-[var(--color-accent)] px-3 text-sm font-bold text-white shadow-[var(--shadow-control)] transition-all hover:brightness-105 sm:px-4"
|
||||
className="flex h-9 items-center gap-2 rounded-full bg-[var(--color-text)] px-3 text-sm font-semibold text-[var(--color-page)] shadow-[var(--shadow-control)] transition-all hover:opacity-90 dark:bg-white dark:text-black"
|
||||
>
|
||||
<PenLine size={16} />
|
||||
<span>새 글</span>
|
||||
@@ -49,7 +49,7 @@ export default function TopHeader() {
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm font-medium text-[var(--color-text-muted)] shadow-[var(--shadow-control)] backdrop-blur-2xl transition-colors hover:bg-[var(--color-surface-strong)] hover:text-red-500 sm:px-4"
|
||||
className="flex h-9 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm font-medium text-[var(--color-text-muted)] shadow-[var(--shadow-control)] backdrop-blur-2xl transition-colors hover:bg-[var(--color-surface-strong)] hover:text-red-500"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span className="hidden sm:inline">로그아웃</span>
|
||||
@@ -59,18 +59,18 @@ export default function TopHeader() {
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex h-10 items-center gap-2 rounded-full px-2 text-sm font-semibold text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)] sm:px-4"
|
||||
className="flex h-9 items-center gap-2 rounded-full border border-transparent px-2.5 text-sm font-semibold text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text)] sm:px-3"
|
||||
>
|
||||
<User size={18} />
|
||||
<span>로그인</span>
|
||||
<span className="hidden sm:inline">로그인</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/signup"
|
||||
className="flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm font-bold text-[var(--color-accent)] shadow-[var(--shadow-control)] backdrop-blur-2xl transition-all hover:bg-[var(--color-surface-strong)] sm:px-4"
|
||||
className="flex h-9 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text)] shadow-[var(--shadow-control)] backdrop-blur-2xl transition-all hover:bg-[var(--color-surface-strong)]"
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
<span>회원가입</span>
|
||||
<span className="hidden sm:inline">회원가입</span>
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function ThemeToggle() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex h-10 shrink-0 items-center rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-2xl"
|
||||
className="inline-flex h-9 shrink-0 items-center rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-2xl"
|
||||
role="group"
|
||||
aria-label="화면 테마"
|
||||
>
|
||||
@@ -37,14 +37,13 @@ export default function ThemeToggle() {
|
||||
title={`${option.label} 모드`}
|
||||
onClick={() => setMode(option.value)}
|
||||
className={clsx(
|
||||
'inline-flex h-8 min-w-8 items-center justify-center gap-1.5 rounded-full px-2 text-xs font-semibold transition duration-150 sm:min-w-10 lg:px-3',
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition duration-150 sm:w-8',
|
||||
isSelected
|
||||
? 'bg-[var(--color-surface-strong)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-subtle)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2.2} />
|
||||
<span className="hidden lg:inline">{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -20,8 +20,8 @@ export default function Surface({
|
||||
<Component
|
||||
className={clsx(
|
||||
'rounded-lg border border-[var(--color-line)] backdrop-blur-xl',
|
||||
strong ? 'bg-[var(--color-surface-strong)] shadow-[var(--shadow-panel)]' : 'bg-[var(--color-surface)] shadow-[var(--shadow-card)]',
|
||||
interactive && 'transition duration-150 hover:border-[var(--color-line)] hover:bg-[var(--color-surface-strong)] hover:shadow-[var(--shadow-panel)]',
|
||||
strong ? 'bg-[var(--color-surface-strong)] shadow-[var(--shadow-card)]' : 'bg-[var(--color-surface)] shadow-[var(--shadow-card)]',
|
||||
interactive && 'transition duration-150 hover:border-black/15 hover:bg-[var(--color-surface-strong)] hover:shadow-[var(--shadow-control)] dark:hover:border-white/20',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user