Files
gitlab-instance-0a899031_no…/components/ReadSidebar.tsx
2026-05-20 18:29:19 -05:00

162 lines
5.1 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { tableOfContents } from "@/content/ebook";
export function ReadSidebar() {
const [activeId, setActiveId] = useState(tableOfContents[0]?.id ?? "");
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
const sections = tableOfContents
.map((item) => document.getElementById(item.id))
.filter(Boolean) as HTMLElement[];
if (sections.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
if (visible[0]?.target.id) {
setActiveId(visible[0].target.id);
}
},
{ rootMargin: "-30% 0px -55% 0px", threshold: [0, 0.25, 0.5, 1] }
);
sections.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!mobileOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [mobileOpen]);
const tocList = (
<ul className="space-y-0.5">
{tableOfContents.map((item, index) => {
const isActive = activeId === item.id;
return (
<li key={item.id}>
<a
href={`#${item.id}`}
onClick={() => setMobileOpen(false)}
className={`toc-link group flex gap-3 rounded-xl px-3 py-2.5 text-sm transition-colors ${
isActive
? "toc-link--active"
: "text-[var(--muted)] hover:bg-[var(--gradient-1)]/5 hover:text-[var(--foreground)]"
}`}
>
<span
className={`toc-index flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-xs font-bold ${
isActive
? ""
: "bg-[var(--gradient-1)]/15 text-[var(--accent)]"
}`}
>
{index + 1}
</span>
<span className="min-w-0 leading-snug">
<span className="block font-medium">{item.title}</span>
{item.summary ? (
<span className="mt-0.5 block text-xs font-normal opacity-80 line-clamp-2">
{item.summary}
</span>
) : null}
</span>
</a>
</li>
);
})}
</ul>
);
return (
<>
<aside className="hidden w-64 shrink-0 lg:block">
<nav
className="glass-card sticky top-[calc(var(--header-h)+1.25rem)] max-h-[calc(100vh-var(--header-h)-2.5rem)] overflow-y-auto rounded-2xl p-5"
aria-label="Chapter navigation"
>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--accent)]">
Chapters
</p>
<div className="mt-4">{tocList}</div>
</nav>
</aside>
<div className="fixed bottom-5 right-4 z-40 lg:hidden">
<button
type="button"
onClick={() => setMobileOpen(true)}
className="flex h-12 items-center gap-2 rounded-full border border-[var(--border)] bg-[var(--surface-solid)] px-4 text-sm font-semibold text-[var(--foreground)] shadow-lg"
aria-expanded={mobileOpen}
aria-controls="mobile-toc-panel"
>
<ListIcon />
Chapters
</button>
</div>
{mobileOpen ? (
<div
className="fixed inset-0 z-50 lg:hidden"
role="dialog"
aria-modal="true"
aria-labelledby="mobile-toc-title"
>
<button
type="button"
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
aria-label="Close chapter menu"
onClick={() => setMobileOpen(false)}
/>
<div
id="mobile-toc-panel"
className="absolute bottom-0 left-0 right-0 max-h-[80vh] overflow-hidden rounded-t-3xl border border-[var(--border)] bg-[var(--surface-solid)] shadow-2xl"
>
<div className="flex items-center justify-between border-b border-[var(--border)] px-5 py-4">
<p
id="mobile-toc-title"
className="font-display text-sm font-bold"
>
Jump to chapter
</p>
<button
type="button"
onClick={() => setMobileOpen(false)}
className="rounded-full px-3 py-1 text-sm text-[var(--muted)] hover:text-[var(--foreground)]"
>
Close
</button>
</div>
<div className="overflow-y-auto px-3 py-4 pb-8">{tocList}</div>
</div>
</div>
) : null}
</>
);
}
function ListIcon() {
return (
<svg
className="h-4 w-4 text-[var(--accent)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden
>
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" strokeLinecap="round" />
</svg>
);
}