feat: add navigable table of contents to blog posts
This commit is contained in:
parent
5c1d532386
commit
05ab51aa05
7 changed files with 484 additions and 151 deletions
318
src/components/TableOfContents.astro
Normal file
318
src/components/TableOfContents.astro
Normal 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>
|
||||
|
|
@ -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 didn’t 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 (she’s 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/). It’s small (1 hectare), limited bottles, but it’s 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/). It’s small (1 hectare), limited bottles, but it’s something we built together and I love it.
|
||||
|
||||
|
||||
And yes: food. Sicily is crazy for food. It’s not even a “food culture”, it’s basically a religion.
|
||||
|
||||
*(No food photos here. I’m not a food blogger. But trust me.)*
|
||||
|
|
|
|||
|
|
@ -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>`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ${
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@
|
|||
}
|
||||
|
||||
h3 {
|
||||
@apply text-xl md:text-2xl lg:text-3xl;
|
||||
@apply text-lg md:text-xl lg:text-2xl;
|
||||
}
|
||||
|
||||
h4 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue