"use client"; import { useEffect, useState } from "react"; import { Headphones } from "lucide-react"; import type { StoryViewerBeat } from "@/components/frontend/story/StoryViewer"; import { cn } from "@/lib/utils"; export type StoryTableOfContentsBeat = Pick< StoryViewerBeat, "id" | "title" | "chapter" | "initialRead" | "voiceoverUrl" >; export function StoryTableOfContents({ beats, }: { readonly beats: readonly StoryTableOfContentsBeat[]; }) { const [activeBeatId, setActiveBeatId] = useState(beats[0]?.id ?? null); useEffect(() => { if (beats.length === 0) return; const elements = beats .map(({ id }) => document.querySelector(`[data-beat-id="${id}"]`)) .filter((element): element is HTMLElement => element !== null); if (elements.length === 0 || !("IntersectionObserver" in window)) return; const updateActiveBeat = () => { const viewportMarker = window.innerHeight * 0.3; const visibleBeats = elements .map((element) => ({ id: Number(element.dataset.beatId), rect: element.getBoundingClientRect(), })) .filter( ({ id, rect }) => Number.isFinite(id) && rect.bottom > 0 && rect.top < window.innerHeight, ); const activeBeat = visibleBeats .filter(({ rect }) => rect.top <= viewportMarker) .sort((a, b) => b.rect.top - a.rect.top)[0] ?? visibleBeats.sort((a, b) => a.rect.top - b.rect.top)[0]; if (activeBeat) setActiveBeatId(activeBeat.id); }; const observer = new IntersectionObserver(updateActiveBeat, { rootMargin: "-20% 0px -60% 0px", threshold: 0, }); elements.forEach((element) => observer.observe(element)); updateActiveBeat(); return () => observer.disconnect(); }, [beats]); if (beats.length === 0) return null; function scrollToBeat(beatId: number) { const element = document.querySelector(`[data-beat-id="${beatId}"]`); if (!element) return; setActiveBeatId(beatId); element.scrollIntoView({ behavior: window.matchMedia("(prefers-reduced-motion: no-preference)").matches ? "smooth" : "auto", block: "start", }); } return ( ); }