feat: add table of contents and improve blog post page layout

This commit is contained in:
Mauricio Siu
2025-03-02 19:18:43 -06:00
parent 92f3a2117f
commit 064924316b
4 changed files with 252 additions and 226 deletions

View File

@@ -0,0 +1,76 @@
"use client";
import { useEffect, useState } from "react";
interface Heading {
id: string;
text: string;
level: number;
}
export function TableOfContents() {
const [headings, setHeadings] = useState<Heading[]>([]);
const [activeId, setActiveId] = useState<string>();
useEffect(() => {
const elements = Array.from(document.querySelectorAll("h1, h2, h3"))
.filter((element) => element.id)
.map((element) => ({
id: element.id,
text: element.textContent || "",
level: Number(element.tagName.charAt(1)),
}));
setHeadings(elements);
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
}
},
{ rootMargin: "-100px 0px -66%" },
);
for (const { id } of elements) {
const element = document.getElementById(id);
if (element) {
observer.observe(element);
}
}
return () => observer.disconnect();
}, []);
return (
<nav className="space-y-2 text-sm">
<p className="font-medium mb-4">Table of Contents</p>
<ul className="space-y-2">
{headings.map((heading) => (
<li
key={heading.id}
style={{ paddingLeft: `${(heading.level - 1) * 1}rem` }}
>
<a
href={`#${heading.id}`}
onClick={(e) => {
e.preventDefault();
document.getElementById(heading.id)?.scrollIntoView({
behavior: "smooth",
});
}}
className={`hover:text-primary transition-colors block ${
activeId === heading.id
? "text-primary font-medium"
: "text-muted-foreground"
}`}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
);
}

View File

@@ -12,11 +12,14 @@ import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import remarkToc from "remark-toc";
import { codeToHtml } from "shiki";
import type { BundledLanguage } from "shiki/bundle/web";
import slugify from "slugify";
import TurndownService from "turndown";
// @ts-ignore
import * as turndownPluginGfm from "turndown-plugin-gfm";
import { TableOfContents } from "./components/TableOfContents";
import { ZoomableImage } from "./components/ZoomableImage";
type Props = {
@@ -158,7 +161,7 @@ export default async function BlogPostPage({ params }: Props) {
const gfm = turndownPluginGfm.gfm;
const tables = turndownPluginGfm.tables;
const strikethrough = turndownPluginGfm.strikethrough;
turndownService.use([tables, strikethrough, gfm]);
turndownService.use([tables, strikethrough, gfm, remarkToc]);
const markdown = turndownService.turndown(post.html);
@@ -169,18 +172,45 @@ export default async function BlogPostPage({ params }: Props) {
});
const components: Partial<Components> = {
h1: ({ node, ...props }) => (
<h1
className="text-xl md:text-2xl xl:text-3xl text-primary font-bold mt-8 mb-4"
{...props}
/>
),
h2: ({ node, ...props }) => (
<h2 className="text-2xl text-primary/90 font-bold mt-6 mb-3" {...props} />
),
h3: ({ node, ...props }) => (
<h3 className="text-xl text-primary/90 font-bold mt-4 mb-2" {...props} />
),
h1: ({ node, ...props }) => {
const id = slugify(props.children?.toString() || "", {
lower: true,
strict: true,
});
return (
<h1
id={id}
className="text-xl md:text-2xl xl:text-3xl text-primary font-bold mt-8 mb-4"
{...props}
/>
);
},
h2: ({ node, ...props }) => {
const id = slugify(props.children?.toString() || "", {
lower: true,
strict: true,
});
return (
<h2
id={id}
className="text-2xl text-primary/90 font-semibold mt-6 mb-3"
{...props}
/>
);
},
h3: ({ node, ...props }) => {
const id = slugify(props.children?.toString() || "", {
lower: true,
strict: true,
});
return (
<h3
id={id}
className="text-xl text-primary/90 font-semibold mt-4 mb-2"
{...props}
/>
);
},
p: ({ node, children, ...props }) => {
return (
<p
@@ -254,7 +284,7 @@ export default async function BlogPostPage({ params }: Props) {
};
return (
<article className="container mx-auto px-4 pb-12 max-w-5xl">
<article className="mx-auto px-4 sm:px-6 lg:px-8 pb-12 max-w-7xl w-full">
<Link
href="/blog"
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) {
{t("backToBlog")}
</Link>
<div className=" rounded-lg p-8 shadow-lg border border-border">
<header className="mb-8">
<h1 className="text-xl md:text-2xl xl:text-3xl font-bold mb-4">
{post.title}
</h1>
<div className="flex items-center mb-6">
{post.primary_author?.profile_image && (
<div className="relative h-12 w-12 rounded-full overflow-hidden mr-4">
{post.primary_author.twitter ? (
<a
href={`https://twitter.com/${post.primary_author.twitter}`}
target="_blank"
rel="noopener noreferrer"
className="block cursor-pointer transition-opacity hover:opacity-90"
>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_250px] gap-8">
<div className="rounded-lg p-8 shadow-lg border border-border">
<header className="mb-8">
<h1 className="text-xl md:text-2xl xl:text-3xl font-bold mb-4">
{post.title}
</h1>
<div className="flex items-center mb-6">
{post.primary_author?.profile_image && (
<div className="relative h-12 w-12 rounded-full overflow-hidden mr-4">
{post.primary_author.twitter ? (
<a
href={`https://twitter.com/${post.primary_author.twitter}`}
target="_blank"
rel="noopener noreferrer"
className="block cursor-pointer transition-opacity hover:opacity-90"
>
<Image
src={post.primary_author.profile_image}
alt={post.primary_author.name}
fill
className="object-cover"
/>
</a>
) : (
<Image
src={post.primary_author.profile_image}
alt={post.primary_author.name}
fill
className="object-cover"
/>
</a>
) : (
<Image
src={post.primary_author.profile_image}
alt={post.primary_author.name}
fill
className="object-cover"
/>
)}
)}
</div>
)}
<div>
<p className="font-medium">
{post.primary_author?.twitter ? (
<a
href={`https://twitter.com/${post.primary_author.twitter}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary transition-colors"
>
{post.primary_author.name || "Unknown Author"}
</a>
) : (
post.primary_author?.name || "Unknown Author"
)}
</p>
<p className="text-sm text-muted-foreground">
{formattedDate} {post.reading_time} min read
</p>
</div>
</div>
{post.feature_image && (
<div className="relative w-full h-[400px] mb-8">
<ZoomableImage
src={post.feature_image}
alt={post.title}
className="rounded-lg h-full w-full object-cover"
/>
</div>
)}
<div>
<p className="font-medium">
{post.primary_author?.twitter ? (
<a
href={`https://twitter.com/${post.primary_author.twitter}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary transition-colors"
>
{post.primary_author.name || "Unknown Author"}
</a>
) : (
post.primary_author?.name || "Unknown Author"
)}
</p>
<p className="text-sm text-muted-foreground">
{formattedDate} {post.reading_time} min read
</p>
</div>
</header>
<div className="prose prose-lg max-w-none">
<ReactMarkdown
remarkPlugins={[
remarkGfm,
[remarkToc, { tight: true, maxDepth: 3 }],
]}
rehypePlugins={[rehypeRaw]}
components={components}
>
{markdown}
</ReactMarkdown>
</div>
{post.feature_image && (
<div className="relative w-full h-[400px] mb-8">
<ZoomableImage
src={post.feature_image}
alt={post.title}
className="rounded-lg h-full w-full object-cover"
/>
{post.tags && post.tags.length > 0 && (
<div className="mt-12 pt-6 border-t border-border">
<h2 className="text-xl font-semibold mb-4">{t("tags")}</h2>
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<Link
key={tag.id}
href={`/blog/tag/${tag.slug}`}
className="px-4 py-2 bg-muted hover:bg-muted/80 rounded-full text-sm transition-colors"
>
{tag.name}
</Link>
))}
</div>
</div>
)}
</header>
<div className="prose prose-lg max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={components}
>
{markdown}
</ReactMarkdown>
</div>
{post.tags && post.tags.length > 0 && (
<div className="mt-12 pt-6 border-t border-border">
<h2 className="text-xl font-semibold mb-4">{t("tags")}</h2>
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<Link
key={tag.id}
href={`/blog/tag/${tag.slug}`}
className="px-4 py-2 bg-muted hover:bg-muted/80 rounded-full text-sm transition-colors"
>
{tag.name}
</Link>
))}
</div>
<div className="hidden lg:block max-w-[16rem]">
<div className="sticky top-4">
<TableOfContents />
</div>
)}
</div>
</div>
{relatedPosts.length > 0 && (

View File

@@ -37,20 +37,21 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-ga4": "^2.1.0",
"react-markdown": "^10.0.0",
"react-photo-view": "^1.2.7",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-toc": "^9.0.0",
"satori": "^0.12.1",
"sharp": "^0.33.5",
"shiki": "1.22.2",
"slugify": "^1.6.6",
"tailwind-merge": "^2.2.2",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",
"typescript": "5.1.6",
"react-markdown": "^10.0.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"@resvg/resvg-js": "^2.6.2",
"satori": "^0.12.1",
"sharp": "^0.33.5"
"typescript": "5.1.6"
},
"devDependencies": {
"@babel/core": "^7.26.9",