162 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
}
|