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 ## Childhood Nostalgia
<div class="float-right img-medium"> <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> <em class="text-sm block mt-2">Super young software geek with an Apple II</em>
</div> </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. 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"> <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> <em class="text-sm block mt-2">Pokemon Yellow and Game Boy Advance</em>
</div> </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. I studied Computer Science at the University of Pisa. It was not a straight path.
<div class="float-right img-medium"> <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> <em class="text-sm block mt-2">Me burning out studying Computability and Complexity exam</em>
</div> </div>
@ -90,7 +90,7 @@ That period also started my love for traveling. Seeing different cultures in rea
## Embarking on Hackathon Adventures ## Embarking on Hackathon Adventures
<div class="float-right img-small"> <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> <em class="text-sm block mt-2">Me wearing moverio smart glasses</em>
</div> </div>
@ -112,7 +112,7 @@ That experience made me addicted to hackathons. After that I joined other events
## Erasmus Project in Valencia ## Erasmus Project in Valencia
<div class="float-right img-large"> <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> <em class="text-sm block mt-2">Beautiful sunny day in Valencia</em>
</div> </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. Not as a “I give up” move. More like: I want a different balance.
<div class="float-left img-medium"> <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> <em class="text-sm block mt-2">Working remote watching the sea</em>
</div> </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. 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"> <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> <em class="text-sm block mt-2">The wine produced "Zia Lina"</em>
</div> </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. 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.)* *(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> <span>•</span>
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
{post.data.tags.slice(0, 3).map((tag: string) => ( {post.data.tags.map((tag: string) => (
<a <a
href={`/blog/tag/${tag}`} 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" 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> <span>•</span>
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
${post.data.tags ${post.data.tags
.slice(0, 3)
.map( .map(
(tag) => (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>` `<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 BaseLayout from "../../layouts/BaseLayout.astro";
import Navbar from "../../components/Navbar.astro"; import Navbar from "../../components/Navbar.astro";
import Footer from "../../components/Footer.astro"; import Footer from "../../components/Footer.astro";
import TableOfContents from "../../components/TableOfContents.astro";
import "../../styles/prose.css"; import "../../styles/prose.css";
import mePhoto from "../../assets/photos/me.png"; import mePhoto from "../../assets/photos/me.png";
@ -19,7 +20,7 @@ export async function getStaticPaths() {
} }
const { entry }: { entry: BlogPost } = Astro.props; 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) // Extract image src for meta tags (Open Graph expects a URL string)
const heroImageSrc = entry.data.heroImage?.src || mePhoto.src; const heroImageSrc = entry.data.heroImage?.src || mePhoto.src;
@ -36,146 +37,160 @@ const heroImageSrc = entry.data.heroImage?.src || mePhoto.src;
> >
<Navbar /> <Navbar />
<div class="min-h-screen pt-16 bg-gray-50 dark:bg-gray-900 transition-colors duration-200"> <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">
<header class="mb-8 md:mb-12"> <div class="flex gap-8 items-start">
<time <article class="flex-1 max-w-4xl min-w-0">
datetime={entry.data.pubDate.toISOString()} <header class="mb-8 md:mb-12">
class="block mb-3 text-sm text-gray-600 dark:text-gray-400" <time
> datetime={entry.data.pubDate.toISOString()}
{ class="block mb-3 text-sm text-gray-600 dark:text-gray-400"
entry.data.pubDate.toLocaleDateString("en-US", { >
year: "numeric", {
month: "long", entry.data.pubDate.toLocaleDateString("en-US", {
day: "numeric", year: "numeric",
}) month: "long",
} day: "numeric",
</time> })
{ }
entry.data.tags.length > 0 && ( </time>
<div class="flex gap-2 flex-wrap mb-4"> {
{entry.data.tags.map((tag: string) => ( entry.data.tags.length > 0 && (
<a <div class="flex gap-2 flex-wrap mb-4">
href={`/blog/tag/${tag}`} {entry.data.tags.map((tag: string) => (
class="px-3 py-1.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 text-xs rounded-lg font-medium hover:bg-gray-900 hover:text-white dark:hover:bg-primary dark:hover:text-gray-900 transition-all duration-200" <a
> href={`/blog/tag/${tag}`}
{tag} class="px-3 py-1.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 text-xs rounded-lg font-medium hover:bg-gray-900 hover:text-white dark:hover:bg-primary dark:hover:text-gray-900 transition-all duration-200"
</a> >
))} {tag}
</div> </a>
) ))}
} </div>
<h1 )
class="text-gray-900 dark:text-gray-100 mb-4 text-4xl md:text-5xl lg:text-5xl font-bold leading-normal pb-2" }
> <h1
{entry.data.title} class="text-gray-900 dark:text-gray-100 mb-4 text-4xl md:text-5xl lg:text-5xl font-bold leading-normal pb-2"
</h1> >
<p class="text-lg md:text-xl text-gray-600 dark:text-gray-400 leading-relaxed mb-6"> {entry.data.title}
{entry.data.description} </h1>
</p> <p class="text-lg md:text-xl text-gray-600 dark:text-gray-400 leading-relaxed mb-6">
</header> {entry.data.description}
{
entry.data.heroImage && (
<div class="mb-8 md:mb-12 -mx-4 sm:mx-0">
<div class="relative h-64 sm:h-80 md:h-96 overflow-hidden sm:rounded-2xl shadow-xl">
<Image
src={entry.data.heroImage}
alt={entry.data.title}
class="w-full h-full object-cover"
width={824}
height={618}
quality={80}
format="webp"
loading="eager"
fetchpriority="high"
/>
</div>
</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"
>
<div class="prose prose-xl max-w-none">
<Content />
</div>
</div>
<div
class="mt-8 md:mt-12 bg-white dark:bg-gray-800 rounded-xl md:rounded-2xl shadow-lg p-6 sm:p-8 transition-colors duration-200"
>
<div class="flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
<Image
src={mePhoto}
alt="Lorenzo Iovino"
class="w-20 h-20 rounded-full object-cover flex-shrink-0"
width={80}
height={80}
quality={90}
format="webp"
/>
<div class="text-center sm:text-left">
<h3 class="text-lg md:text-xl font-bold text-gray-900 dark:text-gray-100 mb-2">
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.
</p> </p>
<div class="flex gap-4 justify-center sm:justify-start"> </header>
<a
href="https://www.linkedin.com/in/lorenzoiovino/" {
target="_blank" entry.data.heroImage && (
rel="noopener noreferrer" <div class="mb-8 md:mb-12 -mx-4 sm:mx-0">
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors" <div class="relative h-64 sm:h-80 md:h-96 overflow-hidden sm:rounded-2xl shadow-xl">
aria-label="Visit Lorenzo Iovino's LinkedIn profile" <Image
> src={entry.data.heroImage}
<svg class="w-5 h-5 md:w-6 md:h-6" fill="currentColor" viewBox="0 0 24 24"> alt={entry.data.title}
<path class="w-full h-full object-cover"
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" width={824}
></path> height={618}
</svg> quality={80}
</a> format="webp"
<a loading="eager"
href="https://github.com/thisloke" fetchpriority="high"
target="_blank" />
rel="noopener noreferrer" </div>
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors" </div>
aria-label="Visit Lorenzo Iovino's GitHub profile" )
> }
<svg class="w-5 h-5 md:w-6 md:h-6" fill="currentColor" viewBox="0 0 24 24">
<path <!-- Mobile TOC -->
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" <div class="xl:hidden">
></path> <TableOfContents headings={headings} variant="mobile" />
</svg> </div>
</a>
<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"
>
<div class="prose prose-xl max-w-none">
<Content />
</div> </div>
</div> </div>
<div
class="mt-8 md:mt-12 bg-white dark:bg-gray-800 rounded-xl md:rounded-2xl shadow-lg p-6 sm:p-8 transition-colors duration-200"
>
<div class="flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
<Image
src={mePhoto}
alt="Lorenzo Iovino"
class="w-20 h-20 rounded-full object-cover flex-shrink-0"
width={80}
height={80}
quality={90}
format="webp"
/>
<div class="text-center sm:text-left">
<h3 class="text-lg md:text-xl font-bold text-gray-900 dark:text-gray-100 mb-2">
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.
</p>
<div class="flex gap-4 justify-center sm:justify-start">
<a
href="https://www.linkedin.com/in/lorenzoiovino/"
target="_blank"
rel="noopener noreferrer"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors"
aria-label="Visit Lorenzo Iovino's LinkedIn profile"
>
<svg class="w-5 h-5 md:w-6 md:h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"
></path>
</svg>
</a>
<a
href="https://github.com/thisloke"
target="_blank"
rel="noopener noreferrer"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors"
aria-label="Visit Lorenzo Iovino's GitHub profile"
>
<svg class="w-5 h-5 md:w-6 md:h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
></path>
</svg>
</a>
</div>
</div>
</div>
</div>
<div class="mt-8 md:mt-12 text-center">
<a
href="/blog"
class="inline-flex items-center px-5 py-2.5 md:px-6 md:py-3 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100 font-medium rounded-lg md:rounded-xl transition-all duration-200 shadow-md hover:shadow-lg border border-gray-200 dark:border-gray-700"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M9.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L7.414 9H15a1 1 0 110 2H7.414l2.293 2.293a1 1 0 010 1.414z"
clip-rule="evenodd"></path>
</svg>
Back to Blog
</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>
</div>
<div class="mt-8 md:mt-12 text-center">
<a
href="/blog"
class="inline-flex items-center px-5 py-2.5 md:px-6 md:py-3 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100 font-medium rounded-lg md:rounded-xl transition-all duration-200 shadow-md hover:shadow-lg border border-gray-200 dark:border-gray-700"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M9.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L7.414 9H15a1 1 0 110 2H7.414l2.293 2.293a1 1 0 010 1.414z"
clip-rule="evenodd"></path>
</svg>
Back to Blog
</a>
</div>
</article>
</div> </div>
<Footer /> <Footer />
</BaseLayout> </BaseLayout>

View file

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

View file

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

View file

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