Skip to content

Commit 0649243

Browse files
committed
feat: add table of contents and improve blog post page layout
1 parent 92f3a21 commit 0649243

File tree

4 files changed

+252
-226
lines changed

4 files changed

+252
-226
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
interface Heading {
5+
id: string;
6+
text: string;
7+
level: number;
8+
}
9+
10+
export function TableOfContents() {
11+
const [headings, setHeadings] = useState<Heading[]>([]);
12+
const [activeId, setActiveId] = useState<string>();
13+
14+
useEffect(() => {
15+
const elements = Array.from(document.querySelectorAll("h1, h2, h3"))
16+
.filter((element) => element.id)
17+
.map((element) => ({
18+
id: element.id,
19+
text: element.textContent || "",
20+
level: Number(element.tagName.charAt(1)),
21+
}));
22+
23+
setHeadings(elements);
24+
25+
const observer = new IntersectionObserver(
26+
(entries) => {
27+
for (const entry of entries) {
28+
if (entry.isIntersecting) {
29+
setActiveId(entry.target.id);
30+
}
31+
}
32+
},
33+
{ rootMargin: "-100px 0px -66%" },
34+
);
35+
36+
for (const { id } of elements) {
37+
const element = document.getElementById(id);
38+
if (element) {
39+
observer.observe(element);
40+
}
41+
}
42+
43+
return () => observer.disconnect();
44+
}, []);
45+
46+
return (
47+
<nav className="space-y-2 text-sm">
48+
<p className="font-medium mb-4">Table of Contents</p>
49+
<ul className="space-y-2">
50+
{headings.map((heading) => (
51+
<li
52+
key={heading.id}
53+
style={{ paddingLeft: `${(heading.level - 1) * 1}rem` }}
54+
>
55+
<a
56+
href={`#${heading.id}`}
57+
onClick={(e) => {
58+
e.preventDefault();
59+
document.getElementById(heading.id)?.scrollIntoView({
60+
behavior: "smooth",
61+
});
62+
}}
63+
className={`hover:text-primary transition-colors block ${
64+
activeId === heading.id
65+
? "text-primary font-medium"
66+
: "text-muted-foreground"
67+
}`}
68+
>
69+
{heading.text}
70+
</a>
71+
</li>
72+
))}
73+
</ul>
74+
</nav>
75+
);
76+
}

apps/website/app/[locale]/blog/[slug]/page.tsx

Lines changed: 130 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@ import ReactMarkdown from "react-markdown";
1212
import type { Components } from "react-markdown";
1313
import rehypeRaw from "rehype-raw";
1414
import remarkGfm from "remark-gfm";
15+
import remarkToc from "remark-toc";
1516
import { codeToHtml } from "shiki";
1617
import type { BundledLanguage } from "shiki/bundle/web";
18+
import slugify from "slugify";
1719
import TurndownService from "turndown";
1820
// @ts-ignore
1921
import * as turndownPluginGfm from "turndown-plugin-gfm";
22+
import { TableOfContents } from "./components/TableOfContents";
2023
import { ZoomableImage } from "./components/ZoomableImage";
2124

2225
type Props = {
@@ -158,7 +161,7 @@ export default async function BlogPostPage({ params }: Props) {
158161
const gfm = turndownPluginGfm.gfm;
159162
const tables = turndownPluginGfm.tables;
160163
const strikethrough = turndownPluginGfm.strikethrough;
161-
turndownService.use([tables, strikethrough, gfm]);
164+
turndownService.use([tables, strikethrough, gfm, remarkToc]);
162165

163166
const markdown = turndownService.turndown(post.html);
164167

@@ -169,18 +172,45 @@ export default async function BlogPostPage({ params }: Props) {
169172
});
170173

171174
const components: Partial<Components> = {
172-
h1: ({ node, ...props }) => (
173-
<h1
174-
className="text-xl md:text-2xl xl:text-3xl text-primary font-bold mt-8 mb-4"
175-
{...props}
176-
/>
177-
),
178-
h2: ({ node, ...props }) => (
179-
<h2 className="text-2xl text-primary/90 font-bold mt-6 mb-3" {...props} />
180-
),
181-
h3: ({ node, ...props }) => (
182-
<h3 className="text-xl text-primary/90 font-bold mt-4 mb-2" {...props} />
183-
),
175+
h1: ({ node, ...props }) => {
176+
const id = slugify(props.children?.toString() || "", {
177+
lower: true,
178+
strict: true,
179+
});
180+
return (
181+
<h1
182+
id={id}
183+
className="text-xl md:text-2xl xl:text-3xl text-primary font-bold mt-8 mb-4"
184+
{...props}
185+
/>
186+
);
187+
},
188+
h2: ({ node, ...props }) => {
189+
const id = slugify(props.children?.toString() || "", {
190+
lower: true,
191+
strict: true,
192+
});
193+
return (
194+
<h2
195+
id={id}
196+
className="text-2xl text-primary/90 font-semibold mt-6 mb-3"
197+
{...props}
198+
/>
199+
);
200+
},
201+
h3: ({ node, ...props }) => {
202+
const id = slugify(props.children?.toString() || "", {
203+
lower: true,
204+
strict: true,
205+
});
206+
return (
207+
<h3
208+
id={id}
209+
className="text-xl text-primary/90 font-semibold mt-4 mb-2"
210+
{...props}
211+
/>
212+
);
213+
},
184214
p: ({ node, children, ...props }) => {
185215
return (
186216
<p
@@ -254,7 +284,7 @@ export default async function BlogPostPage({ params }: Props) {
254284
};
255285

256286
return (
257-
<article className="container mx-auto px-4 pb-12 max-w-5xl">
287+
<article className="mx-auto px-4 sm:px-6 lg:px-8 pb-12 max-w-7xl w-full">
258288
<Link
259289
href="/blog"
260290
className="inline-flex items-center mb-8 text-primary hover:text-primary/80 transition-colors"
@@ -274,95 +304,106 @@ export default async function BlogPostPage({ params }: Props) {
274304
{t("backToBlog")}
275305
</Link>
276306

277-
<div className=" rounded-lg p-8 shadow-lg border border-border">
278-
<header className="mb-8">
279-
<h1 className="text-xl md:text-2xl xl:text-3xl font-bold mb-4">
280-
{post.title}
281-
</h1>
282-
<div className="flex items-center mb-6">
283-
{post.primary_author?.profile_image && (
284-
<div className="relative h-12 w-12 rounded-full overflow-hidden mr-4">
285-
{post.primary_author.twitter ? (
286-
<a
287-
href={`https://twitter.com/${post.primary_author.twitter}`}
288-
target="_blank"
289-
rel="noopener noreferrer"
290-
className="block cursor-pointer transition-opacity hover:opacity-90"
291-
>
307+
<div className="grid grid-cols-1 lg:grid-cols-[1fr_250px] gap-8">
308+
<div className="rounded-lg p-8 shadow-lg border border-border">
309+
<header className="mb-8">
310+
<h1 className="text-xl md:text-2xl xl:text-3xl font-bold mb-4">
311+
{post.title}
312+
</h1>
313+
<div className="flex items-center mb-6">
314+
{post.primary_author?.profile_image && (
315+
<div className="relative h-12 w-12 rounded-full overflow-hidden mr-4">
316+
{post.primary_author.twitter ? (
317+
<a
318+
href={`https://twitter.com/${post.primary_author.twitter}`}
319+
target="_blank"
320+
rel="noopener noreferrer"
321+
className="block cursor-pointer transition-opacity hover:opacity-90"
322+
>
323+
<Image
324+
src={post.primary_author.profile_image}
325+
alt={post.primary_author.name}
326+
fill
327+
className="object-cover"
328+
/>
329+
</a>
330+
) : (
292331
<Image
293332
src={post.primary_author.profile_image}
294333
alt={post.primary_author.name}
295334
fill
296335
className="object-cover"
297336
/>
298-
</a>
299-
) : (
300-
<Image
301-
src={post.primary_author.profile_image}
302-
alt={post.primary_author.name}
303-
fill
304-
className="object-cover"
305-
/>
306-
)}
337+
)}
338+
</div>
339+
)}
340+
<div>
341+
<p className="font-medium">
342+
{post.primary_author?.twitter ? (
343+
<a
344+
href={`https://twitter.com/${post.primary_author.twitter}`}
345+
target="_blank"
346+
rel="noopener noreferrer"
347+
className="hover:text-primary transition-colors"
348+
>
349+
{post.primary_author.name || "Unknown Author"}
350+
</a>
351+
) : (
352+
post.primary_author?.name || "Unknown Author"
353+
)}
354+
</p>
355+
<p className="text-sm text-muted-foreground">
356+
{formattedDate}{post.reading_time} min read
357+
</p>
307358
</div>
308-
)}
309-
<div>
310-
<p className="font-medium">
311-
{post.primary_author?.twitter ? (
312-
<a
313-
href={`https://twitter.com/${post.primary_author.twitter}`}
314-
target="_blank"
315-
rel="noopener noreferrer"
316-
className="hover:text-primary transition-colors"
317-
>
318-
{post.primary_author.name || "Unknown Author"}
319-
</a>
320-
) : (
321-
post.primary_author?.name || "Unknown Author"
322-
)}
323-
</p>
324-
<p className="text-sm text-muted-foreground">
325-
{formattedDate}{post.reading_time} min read
326-
</p>
327359
</div>
360+
{post.feature_image && (
361+
<div className="relative w-full h-[400px] mb-8">
362+
<ZoomableImage
363+
src={post.feature_image}
364+
alt={post.title}
365+
className="rounded-lg h-full w-full object-cover"
366+
/>
367+
</div>
368+
)}
369+
</header>
370+
371+
<div className="prose prose-lg max-w-none">
372+
<ReactMarkdown
373+
remarkPlugins={[
374+
remarkGfm,
375+
[remarkToc, { tight: true, maxDepth: 3 }],
376+
]}
377+
rehypePlugins={[rehypeRaw]}
378+
components={components}
379+
>
380+
{markdown}
381+
</ReactMarkdown>
328382
</div>
329-
{post.feature_image && (
330-
<div className="relative w-full h-[400px] mb-8">
331-
<ZoomableImage
332-
src={post.feature_image}
333-
alt={post.title}
334-
className="rounded-lg h-full w-full object-cover"
335-
/>
383+
384+
{post.tags && post.tags.length > 0 && (
385+
<div className="mt-12 pt-6 border-t border-border">
386+
<h2 className="text-xl font-semibold mb-4">{t("tags")}</h2>
387+
<div className="flex flex-wrap gap-2">
388+
{post.tags.map((tag) => (
389+
<Link
390+
key={tag.id}
391+
href={`/blog/tag/${tag.slug}`}
392+
className="px-4 py-2 bg-muted hover:bg-muted/80 rounded-full text-sm transition-colors"
393+
>
394+
{tag.name}
395+
</Link>
396+
))}
397+
</div>
336398
</div>
337399
)}
338-
</header>
339-
340-
<div className="prose prose-lg max-w-none">
341-
<ReactMarkdown
342-
remarkPlugins={[remarkGfm]}
343-
rehypePlugins={[rehypeRaw]}
344-
components={components}
345-
>
346-
{markdown}
347-
</ReactMarkdown>
348400
</div>
349401

350-
{post.tags && post.tags.length > 0 && (
351-
<div className="mt-12 pt-6 border-t border-border">
352-
<h2 className="text-xl font-semibold mb-4">{t("tags")}</h2>
353-
<div className="flex flex-wrap gap-2">
354-
{post.tags.map((tag) => (
355-
<Link
356-
key={tag.id}
357-
href={`/blog/tag/${tag.slug}`}
358-
className="px-4 py-2 bg-muted hover:bg-muted/80 rounded-full text-sm transition-colors"
359-
>
360-
{tag.name}
361-
</Link>
362-
))}
363-
</div>
402+
<div className="hidden lg:block max-w-[16rem]">
403+
<div className="sticky top-4">
404+
<TableOfContents />
364405
</div>
365-
)}
406+
</div>
366407
</div>
367408

368409
{relatedPosts.length > 0 && (

apps/website/package.json

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,20 +37,21 @@
3737
"react": "18.2.0",
3838
"react-dom": "18.2.0",
3939
"react-ga4": "^2.1.0",
40+
"react-markdown": "^10.0.0",
4041
"react-photo-view": "^1.2.7",
42+
"rehype-raw": "^7.0.0",
43+
"remark-gfm": "^4.0.1",
44+
"remark-toc": "^9.0.0",
45+
"satori": "^0.12.1",
46+
"sharp": "^0.33.5",
4147
"shiki": "1.22.2",
48+
"slugify": "^1.6.6",
4249
"tailwind-merge": "^2.2.2",
4350
"tailwindcss": "^3.4.1",
4451
"tailwindcss-animate": "^1.0.7",
4552
"turndown": "^7.2.0",
4653
"turndown-plugin-gfm": "^1.0.2",
47-
"typescript": "5.1.6",
48-
"react-markdown": "^10.0.0",
49-
"rehype-raw": "^7.0.0",
50-
"remark-gfm": "^4.0.1",
51-
"@resvg/resvg-js": "^2.6.2",
52-
"satori": "^0.12.1",
53-
"sharp": "^0.33.5"
54+
"typescript": "5.1.6"
5455
},
5556
"devDependencies": {
5657
"@babel/core": "^7.26.9",

0 commit comments

Comments
 (0)