Skip to content

Commit 30283d5

Browse files
committed
feat: 목차, 스크롤바 추가
1 parent 1788043 commit 30283d5

File tree

4 files changed

+137
-35
lines changed

4 files changed

+137
-35
lines changed

components/ScrollProgressBar.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useEffect, useState } from 'react';
2+
3+
export default function ScrollProgressBar() {
4+
const [scrollProgress, setScrollProgress] = useState(0);
5+
6+
useEffect(() => {
7+
const handleScroll = () => {
8+
const scrollTop = window.scrollY;
9+
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
10+
const scrolled = (scrollTop / docHeight) * 100;
11+
setScrollProgress(scrolled);
12+
};
13+
14+
window.addEventListener("scroll", handleScroll);
15+
return () => window.removeEventListener("scroll", handleScroll);
16+
}, []);
17+
18+
return (
19+
<div className="fixed top-0 left-0 w-full h-1 z-50 bg-transparent">
20+
<div
21+
className="h-full bg-gradient-to-r from-black via-blue-500 to-teal-400 transition-all duration-150"
22+
style={{ width: `${scrollProgress}%` }}
23+
/>
24+
</div>
25+
);
26+
}

hooks/useTocObserver.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,37 @@ import { useEffect, useState } from 'react';
33
type TocItem = {
44
id: string;
55
text: string;
6+
level: number;
67
};
78

89
export function useTocObserver(): [TocItem[], string] {
910
const [toc, setToc] = useState<TocItem[]>([]);
1011
const [activeId, setActiveId] = useState<string>('');
1112

1213
useEffect(() => {
13-
const headers = Array.from(document.querySelectorAll('h3[id]')) as HTMLHeadingElement[];
14+
const headers = Array.from(document.querySelectorAll('h2[id], h3[id]')) as HTMLHeadingElement[];
1415

1516
const items = headers.map((h) => ({
1617
id: h.id,
1718
text: h.textContent ?? '',
19+
level: Number(h.tagName[1]),
1820
}));
1921
setToc(items);
2022

21-
const handleObserver = (entries: IntersectionObserverEntry[]) => {
22-
const visible = entries.find((entry) => entry.isIntersecting);
23-
if (visible?.target.id) {
24-
setActiveId(visible.target.id);
23+
const observer = new IntersectionObserver(
24+
(entries) => {
25+
const visible = entries.find((entry) => entry.isIntersecting);
26+
if (visible?.target.id) {
27+
setActiveId(visible.target.id);
28+
}
29+
},
30+
{
31+
rootMargin: '0px 0px -80% 0px',
32+
threshold: 1.0,
2533
}
26-
};
27-
28-
const observer = new IntersectionObserver(handleObserver, {
29-
rootMargin: '0px 0px -80% 0px',
30-
threshold: 1.0,
31-
});
34+
);
3235

3336
headers.forEach((h) => observer.observe(h));
34-
3537
return () => observer.disconnect();
3638
}, []);
3739

pages/post/[id].tsx

Lines changed: 75 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1-
import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from 'next';
1+
import {
2+
GetStaticPaths,
3+
GetStaticProps,
4+
InferGetStaticPropsType
5+
} from 'next';
26
import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote';
37
import { getAllPostIds, getPostData } from '../../lib/posts';
48
import Header from '../../components/Header';
59
import Panel from '../../components/Panel';
610
import { BlueText, RedText, GreenText } from '../../components/Highlight';
7-
import {useTocObserver} from '../../hooks/useTocObserver';
11+
import { useTocObserver } from '../../hooks/useTocObserver';
12+
import ScrollProgressBar from '../../components/ScrollProgressBar';
13+
import { useEffect, useRef, useState } from 'react';
14+
import Link from 'next/link';
15+
import { List } from 'lucide-react';
16+
import { ArrowDownCircle } from 'lucide-react';
817

918
type PostData = {
1019
id: string;
@@ -26,7 +35,23 @@ export const getStaticProps: GetStaticProps<{ postData: PostData }> = async ({ p
2635
export default function Post({ postData }: InferGetStaticPropsType<typeof getStaticProps>) {
2736
const [toc, activeId] = useTocObserver();
2837

29-
console.log(activeId)
38+
const scrollRef = useRef<HTMLDivElement>(null);
39+
const [showScrollHint, setShowScrollHint] = useState(false);
40+
41+
useEffect(() => {
42+
const el = scrollRef.current;
43+
if (el && el.scrollHeight > el.clientHeight) {
44+
setShowScrollHint(true);
45+
}
46+
}, [toc]);
47+
48+
const handleScroll = () => {
49+
const el = scrollRef.current;
50+
if (!el) return;
51+
52+
const isAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 10;
53+
setShowScrollHint(!isAtBottom);
54+
};
3055

3156
const components = {
3257
info: (props: any) => <Panel type="info" {...props} />,
@@ -44,38 +69,65 @@ export default function Post({ postData }: InferGetStaticPropsType<typeof getSta
4469

4570
return (
4671
<>
72+
<ScrollProgressBar />
4773
<Header isDark={false} />
48-
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-12 py-20 flex">
74+
<div className="max-w-[90rem] mx-auto px-6 py-20 flex gap-16">
4975
{/* Main content */}
50-
<main className="flex-1 prose prose-xl max-w-4xl">
76+
<main className="flex-1 max-w-3xl prose prose-xl mr-[17rem]">
5177
<h1 className="text-4xl font-bold mb-4">{postData.title}</h1>
5278
<p className="text-gray-500 text-sm mb-8">{postData.date}</p>
5379
<article>
54-
<MDXRemote {...postData.mdxSource} components={components} />
80+
<MDXRemote {...postData.mdxSource} components={components}/>
5581
</article>
5682
</main>
5783

5884
{/* Table of contents */}
59-
<aside className="hidden lg:block w-48 pl-6">
60-
<div className="sticky top-24 text-sm leading-relaxed space-y-2">
61-
<h2 className="text-lg font-bold mb-3">📑 목차</h2>
62-
<ul className="space-y-1 text-sm text-gray-700">
63-
{toc.map((item) => (
64-
<li key={item.id}>
65-
<a
66-
href={`#${item.id}`}
67-
className={`hover:text-blue-600 hover:underline ${
68-
(item.id !== activeId ? '' : 'text-blue-600 font-bold')
69-
}`}
70-
>
71-
{item.text}
72-
</a>
73-
</li>
74-
))}
75-
</ul>
85+
<aside
86+
className="hidden lg:block fixed right-10 top-[96px] w-60 h-[calc(100vh-96px)] pl-4 z-40 pb-[120px]"
87+
>
88+
<div className="relative h-full">
89+
{/* Scrollable 목차 영역 */}
90+
<div
91+
ref={scrollRef}
92+
className="overflow-y-auto h-full pr-2 scrollbar-hide"
93+
onScroll={handleScroll}
94+
>
95+
<h2 className="text-sm font-semibold mb-2">🧾 목차</h2>
96+
<ul className="space-y-1 text-xs sm:text-sm text-gray-700 pb-12">
97+
{toc.map((item) => (
98+
<li key={item.id} className={item.level === 3 ? 'ml-4' : ''}>
99+
<a
100+
href={`#${item.id}`}
101+
className={`hover:text-blue-600 hover:underline ${
102+
item.id === activeId ? 'text-blue-600 font-semibold' : ''
103+
}`}
104+
>
105+
{item.text}
106+
</a>
107+
</li>
108+
))}
109+
</ul>
110+
</div>
111+
112+
{/* 스크롤 힌트 */}
113+
{showScrollHint && (
114+
<div
115+
className="absolute bottom-2 left-1/2 -translate-x-1/2 bg-blue-500 text-white px-2 py-2 rounded-full shadow animate-bounce z-10">
116+
<ArrowDownCircle className="w-5 h-5"/>
117+
</div>
118+
)}
76119
</div>
77120
</aside>
121+
122+
78123
</div>
124+
<Link
125+
href="/post"
126+
className="fixed bottom-6 right-6 z-50 bg-white border border-gray-300 rounded-lg shadow-md p-3 hover:shadow-lg transition"
127+
aria-label="목록으로 가기"
128+
>
129+
<List className="w-6 h-6 text-gray-800"/>
130+
</Link>
79131
</>
80132
);
81133
}

styles/globals.css

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,26 @@ body {
4747
line-height: 1.5 !important;
4848
}
4949

50+
.custom-quote {
51+
@apply border-l-4 border-gray-300 pl-4 italic text-gray-800;
52+
}
53+
54+
.custom-quote footer {
55+
@apply text-right mt-2 font-medium text-sm text-gray-500;
56+
}
57+
58+
.scrollbar-hide {
59+
-ms-overflow-style: none; /* IE/Edge */
60+
scrollbar-width: none; /* Firefox */
61+
}
62+
.scrollbar-hide::-webkit-scrollbar {
63+
display: none; /* Chrome/Safari */
64+
}
65+
66+
@layer utilities {
67+
.fix-matching-spacing {
68+
margin-top: -1px;
69+
letter-spacing: -0.01em;
70+
}
71+
}
5072

0 commit comments

Comments
 (0)