feat: add navigable table of contents to blog posts

This commit is contained in:
Lorenzo Iovino 2026-01-09 18:21:20 +01:00
parent 5c1d532386
commit 05ab51aa05
7 changed files with 484 additions and 151 deletions

View file

@ -0,0 +1,318 @@
---
export interface Props {
headings: { depth: number; slug: string; text: string }[];
variant?: "mobile" | "desktop";
}
const { headings, variant = "desktop" } = Astro.props;
// Filter to only show h2 and h3
const tocHeadings = headings.filter((h) => h.depth <= 3);
---
{
tocHeadings.length > 0 && (
<>
{/* Mobile TOC - Sticky Floating Button with Slide-out Panel */}
{variant === "mobile" && (
<>
{/* Floating Button */}
<button
id="mobile-toc-toggle"
class="fixed bottom-6 right-6 z-40 p-3 bg-primary dark:bg-emerald-500 text-gray-900 dark:text-gray-900 rounded-full shadow-lg hover:scale-110 transition-all duration-200 flex items-center gap-2"
aria-label="Toggle table of contents"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
{/* Slide-out Panel Overlay */}
<div
id="mobile-toc-overlay"
class="fixed inset-0 bg-black/50 z-40 opacity-0 pointer-events-none transition-opacity duration-300"
/>
{/* Slide-out Panel */}
<div
id="mobile-toc-panel"
class="fixed top-0 right-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-800 shadow-2xl z-50 transform translate-x-full transition-transform duration-300 overflow-y-auto"
>
<div class="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<svg
class="w-5 h-5 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
Table of Contents
</h2>
<button
id="mobile-toc-close"
class="p-2 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Close table of contents"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<nav class="p-4">
<ul class="space-y-1">
{tocHeadings.map((heading) => (
<li
class:list={[
{
"pl-0": heading.depth === 2,
"pl-6": heading.depth === 3,
},
]}
>
<a
href={`#${heading.slug}`}
class="mobile-toc-link block py-2.5 px-3 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-all duration-200"
data-mobile-heading={heading.slug}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
</div>
</>
)}
{/* Desktop TOC - Sticky Sidebar */}
{variant === "desktop" && (
<aside class="sticky top-24 self-start">
<nav class="toc-nav max-h-[calc(100vh-8rem)] overflow-y-auto">
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-4 uppercase tracking-wider">
On This Page
</h2>
<ul class="space-y-2 text-sm border-l-2 border-gray-200 dark:border-gray-700">
{tocHeadings.map((heading) => (
<li
class:list={[
"border-l-2 -ml-[2px]",
{
"pl-4": heading.depth === 2,
"pl-8": heading.depth === 3,
},
]}
>
<a
href={`#${heading.slug}`}
class="toc-link block py-1 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors duration-200"
data-heading={heading.slug}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
</aside>
)}
</>
)
}
<style>
aside {
width: 240px;
min-width: 240px;
}
.toc-nav {
padding-right: 1rem;
}
.toc-link.active {
color: rgb(46 129 255);
border-left-color: rgb(46 129 255);
font-weight: 500;
}
:global(.dark) .toc-link.active {
color: rgb(52 211 153);
border-left-color: rgb(52 211 153);
}
/* Custom scrollbar for desktop TOC */
.toc-nav::-webkit-scrollbar {
width: 4px;
}
.toc-nav::-webkit-scrollbar-track {
background: transparent;
}
.toc-nav::-webkit-scrollbar-thumb {
background: rgb(209 213 219);
border-radius: 2px;
}
:global(.dark) .toc-nav::-webkit-scrollbar-thumb {
background: rgb(55 65 81);
}
/* Mobile TOC active state */
.mobile-toc-link.active {
background-color: rgb(243 244 246);
color: rgb(46 129 255);
font-weight: 500;
}
:global(.dark) .mobile-toc-link.active {
background-color: rgb(55 65 81);
color: rgb(52 211 153);
}
/* Panel open states */
#mobile-toc-panel.open {
transform: translateX(0);
}
#mobile-toc-overlay.open {
opacity: 1;
pointer-events: auto;
}
</style>
<script>
function initTableOfContents() {
// Desktop TOC observer
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const id = entry.target.getAttribute("id");
const tocLink = document.querySelector(`.toc-link[data-heading="${id}"]`);
const mobileTocLink = document.querySelector(
`.mobile-toc-link[data-mobile-heading="${id}"]`
);
if (entry.isIntersecting) {
// Remove active class from all links
document.querySelectorAll(".toc-link").forEach((link) => {
link.classList.remove("active");
});
document.querySelectorAll(".mobile-toc-link").forEach((link) => {
link.classList.remove("active");
});
// Add active class to current link
if (tocLink) {
tocLink.classList.add("active");
}
if (mobileTocLink) {
mobileTocLink.classList.add("active");
}
}
});
},
{
rootMargin: "-100px 0px -66%",
threshold: 1.0,
}
);
// Observe all headings
document.querySelectorAll("h2[id], h3[id]").forEach((heading) => {
observer.observe(heading);
});
// Smooth scroll for desktop TOC links
document.querySelectorAll(".toc-link").forEach((link) => {
link.addEventListener("click", (e) => {
e.preventDefault();
const targetId = link.getAttribute("href")?.slice(1);
const targetElement = document.getElementById(targetId || "");
if (targetElement) {
const offset = 100;
const elementPosition = targetElement.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - offset;
window.scrollTo({
top: offsetPosition,
behavior: "smooth",
});
}
});
});
// Mobile TOC functionality
const toggleButton = document.getElementById("mobile-toc-toggle");
const closeButton = document.getElementById("mobile-toc-close");
const panel = document.getElementById("mobile-toc-panel");
const overlay = document.getElementById("mobile-toc-overlay");
function openPanel() {
panel?.classList.add("open");
overlay?.classList.add("open");
document.body.style.overflow = "hidden";
}
function closePanel() {
panel?.classList.remove("open");
overlay?.classList.remove("open");
document.body.style.overflow = "";
}
toggleButton?.addEventListener("click", openPanel);
closeButton?.addEventListener("click", closePanel);
overlay?.addEventListener("click", closePanel);
// Smooth scroll for mobile TOC links
document.querySelectorAll(".mobile-toc-link").forEach((link) => {
link.addEventListener("click", (e) => {
e.preventDefault();
const targetId = link.getAttribute("href")?.slice(1);
const targetElement = document.getElementById(targetId || "");
if (targetElement) {
const offset = 80;
const elementPosition = targetElement.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - offset;
window.scrollTo({
top: offsetPosition,
behavior: "smooth",
});
// Close the panel after clicking
closePanel();
}
});
});
// Close panel on escape key
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && panel?.classList.contains("open")) {
closePanel();
}
});
}
// Initialize on page load
initTableOfContents();
// Re-initialize after view transitions (if using Astro view transitions)
document.addEventListener("astro:after-swap", initTableOfContents);
</script>

View file

@ -31,7 +31,7 @@ This page is a small recap of my story. Nothing special, just me.
## Childhood Nostalgia
<div class="float-right img-medium">
<Image src={meBaby} alt="Super young geek with an Apple II" class="float-right img-medium" width={400} height={300} quality={85} format="webp" />
<Image src={meBaby} alt="Super young geek with an Apple II" class="float-right img-medium" width={380} height={450} quality={75} format="webp" />
<em class="text-sm block mt-2">Super young software geek with an Apple II</em>
</div>
@ -44,7 +44,7 @@ I grew up in Ispica, in the south of Sicily. My days were simple: school, videog
Ispica is slow, warm, and beautiful. When I was a kid I didnt see it that way. I wanted to escape. I was dreaming about big cities, more people, more things happening, more opportunities.
<div class="float-left img-medium">
<Image src={pokemon} alt="Pokemon Yellow and Game Boy Advance" class="float-left img-medium" width={400} height={300} quality={85} format="webp" />
<Image src={pokemon} alt="Pokemon Yellow and Game Boy Advance" class="float-left img-medium" width={430} height={300} quality={85} format="webp" />
<em class="text-sm block mt-2">Pokemon Yellow and Game Boy Advance</em>
</div>
@ -66,7 +66,7 @@ At 17 I also discovered Magic: The Gathering. It became another obsession for a
I studied Computer Science at the University of Pisa. It was not a straight path.
<div class="float-right img-medium">
<Image src={meCC} alt="Me burning out studying" class="float-right img-medium" width={400} height={300} quality={85} format="webp" />
<Image src={meCC} alt="Me burning out studying" class="float-right img-medium" width={400} height={450} quality={85} format="webp" />
<em class="text-sm block mt-2">Me burning out studying Computability and Complexity exam</em>
</div>
@ -90,7 +90,7 @@ That period also started my love for traveling. Seeing different cultures in rea
## Embarking on Hackathon Adventures
<div class="float-right img-small">
<Image src={meMoverio} alt="Me wearing moverio smart glasses" class="float-right img-small" width={300} height={300} quality={85} format="webp" />
<Image src={meMoverio} alt="Me wearing moverio smart glasses" class="float-right img-small" width={300} height={350} quality={85} format="webp" />
<em class="text-sm block mt-2">Me wearing moverio smart glasses</em>
</div>
@ -112,7 +112,7 @@ That experience made me addicted to hackathons. After that I joined other events
## Erasmus Project in Valencia
<div class="float-right img-large">
<Image src={valenciaTuria} alt="Beautiful sunny day in Valencia" class="float-right img-large" width={600} height={400} quality={80} format="webp" />
<Image src={valenciaTuria} alt="Beautiful sunny day in Valencia" class="float-right img-large" width={600} height={350} quality={80} format="webp" />
<em class="text-sm block mt-2">Beautiful sunny day in Valencia</em>
</div>
@ -131,7 +131,7 @@ At some point I decided to go back to Sicily.
Not as a “I give up” move. More like: I want a different balance.
<div class="float-left img-medium">
<Image src={remote} alt="Working remote watching the sea" class="float-left img-medium" width={300} height={225} quality={75} format="webp" />
<Image src={remote} alt="Working remote watching the sea" class="float-left img-medium" width={300} height={200} quality={75} format="webp" />
<em class="text-sm block mt-2">Working remote watching the sea</em>
</div>
@ -148,13 +148,14 @@ Time here feels different. You can actually breathe. You can have “nothing spe
Family is a big part of my life here. And I also started a side project with my sister (shes an agronomist): we planted a small vineyard near the sea and we started producing our own wine.
That project became [www.netum.it](https://netum.it/). Its small (1 hectare), limited bottles, but its something we built together and I love it.
<div class="float-right img-small">
<Image src={wine} alt="The wine produced Zia Lina" class="float-right img-small" width={300} height={300} quality={85} format="webp" />
<Image src={wine} alt="The wine produced Zia Lina" class="float-right img-small" width={300} height={400} quality={85} format="webp" />
<em class="text-sm block mt-2">The wine produced "Zia Lina"</em>
</div>
That project became [www.netum.it](https://netum.it/). Its small (1 hectare), limited bottles, but its something we built together and I love it.
And yes: food. Sicily is crazy for food. Its not even a “food culture”, its basically a religion.
*(No food photos here. Im not a food blogger. But trust me.)*

View file

@ -142,7 +142,7 @@ const initialPosts: BlogPost[] = sortedPosts.slice(0, 6);
<>
<span>•</span>
<div class="flex gap-2 flex-wrap">
{post.data.tags.slice(0, 3).map((tag: string) => (
{post.data.tags.map((tag: string) => (
<a
href={`/blog/tag/${tag}`}
class="px-2 py-1 bg-secondary/10 dark:bg-primary/20 text-gray-800 dark:text-gray-200 text-xs rounded-lg font-medium hover:bg-secondary hover:text-white dark:hover:bg-primary dark:hover:text-gray-900 transition-all duration-200"
@ -238,7 +238,6 @@ const initialPosts: BlogPost[] = sortedPosts.slice(0, 6);
<span>•</span>
<div class="flex gap-2 flex-wrap">
${post.data.tags
.slice(0, 3)
.map(
(tag) =>
`<a href="/blog/tag/${tag}" class="px-2 py-1 bg-secondary/10 dark:bg-primary/20 text-gray-800 dark:text-gray-200 text-xs rounded-lg font-medium hover:bg-secondary hover:text-white dark:hover:bg-primary dark:hover:text-gray-900 transition-all duration-200">${tag}</a>`

View file

@ -5,6 +5,7 @@ import { Image } from "astro:assets";
import BaseLayout from "../../layouts/BaseLayout.astro";
import Navbar from "../../components/Navbar.astro";
import Footer from "../../components/Footer.astro";
import TableOfContents from "../../components/TableOfContents.astro";
import "../../styles/prose.css";
import mePhoto from "../../assets/photos/me.png";
@ -19,7 +20,7 @@ export async function getStaticPaths() {
}
const { entry }: { entry: BlogPost } = Astro.props;
const { Content } = await entry.render();
const { Content, headings } = await entry.render();
// Extract image src for meta tags (Open Graph expects a URL string)
const heroImageSrc = entry.data.heroImage?.src || mePhoto.src;
@ -36,7 +37,9 @@ const heroImageSrc = entry.data.heroImage?.src || mePhoto.src;
>
<Navbar />
<div class="min-h-screen pt-16 bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
<article class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 md:py-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 md:py-12">
<div class="flex gap-8 items-start">
<article class="flex-1 max-w-4xl min-w-0">
<header class="mb-8 md:mb-12">
<time
datetime={entry.data.pubDate.toISOString()}
@ -94,6 +97,11 @@ const heroImageSrc = entry.data.heroImage?.src || mePhoto.src;
)
}
<!-- Mobile TOC -->
<div class="xl:hidden">
<TableOfContents headings={headings} variant="mobile" />
</div>
<div
class="bg-white dark:bg-gray-800 rounded-xl md:rounded-2xl shadow-xl p-6 sm:p-8 md:p-12 lg:p-16 transition-colors duration-200"
>
@ -120,9 +128,9 @@ const heroImageSrc = entry.data.heroImage?.src || mePhoto.src;
Lorenzo Iovino
</h3>
<p class="text-sm md:text-base text-gray-700 dark:text-gray-300 mb-4">
I write software and I also work on a small vineyard in Sicily. In code, trees grow
downward from the root. In real life, trees grow upward from the roots. I spend way
too much time thinking about this.
I write software and I also work on a small vineyard in Sicily. In code, trees
grow downward from the root. In real life, trees grow upward from the roots. I
spend way too much time thinking about this.
</p>
<div class="flex gap-4 justify-center sm:justify-start">
<a
@ -176,6 +184,13 @@ const heroImageSrc = entry.data.heroImage?.src || mePhoto.src;
</a>
</div>
</article>
<!-- Desktop TOC Sidebar -->
<div class="hidden xl:block flex-shrink-0 self-start sticky top-24">
<TableOfContents headings={headings} variant="desktop" />
</div>
</div>
</div>
</div>
<Footer />
</BaseLayout>

View file

@ -118,7 +118,7 @@ const displayTag: string = tag.charAt(0).toUpperCase() + tag.slice(1);
<>
<span>•</span>
<div class="flex gap-2 flex-wrap">
{post.data.tags.slice(0, 3).map((postTag: string) => (
{post.data.tags.map((postTag: string) => (
<a
href={`/blog/tag/${postTag}`}
class={`px-2 py-1 text-xs rounded-lg font-medium transition-all duration-200 ${

View file

@ -57,7 +57,7 @@
}
h3 {
@apply text-xl md:text-2xl lg:text-3xl;
@apply text-lg md:text-xl lg:text-2xl;
}
h4 {

View file

@ -50,7 +50,7 @@
}
.prose h3 {
font-size: 1.5rem !important;
font-size: 1.35rem !important;
font-weight: 700 !important;
color: #111827 !important;
margin-top: 3rem !important;
@ -65,7 +65,7 @@
@media (min-width: 768px) {
.prose h3 {
font-size: 1.875rem !important;
font-size: 1.5rem !important;
}
}