- Introduced voiceover relationship to StoryBeats with AI-generated flag. - Added BeatVoiceover component for playing audio narrations with a minimal interface. - Updated StoryPage, StoryViewer, and StoryTableOfContents to display and handle voiceovers. - Created Voiceovers collection for uploading and managing audio narrations for beats. - Included migration script to support voiceover functionality in the database.
132 lines
4.8 KiB
TypeScript
132 lines
4.8 KiB
TypeScript
"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<number | null>(beats[0]?.id ?? null);
|
|
|
|
useEffect(() => {
|
|
if (beats.length === 0) return;
|
|
|
|
const elements = beats
|
|
.map(({ id }) => document.querySelector<HTMLElement>(`[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<HTMLElement>(`[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 (
|
|
<nav aria-label="Chronicle chapters" className="sticky top-6 overflow-visible">
|
|
<p className="mb-3 px-3 text-xs font-mono uppercase tracking-widest text-muted-foreground">
|
|
Chapters
|
|
</p>
|
|
<ol className="border-l border-border">
|
|
{beats.map((beat) => {
|
|
const active = activeBeatId === beat.id;
|
|
const readLabel = beat.initialRead ? "Read" : "Unread";
|
|
const ariaLabel = beat.voiceoverUrl
|
|
? `Chapter ${beat.chapter}: ${beat.title}. ${readLabel}. Has audio.`
|
|
: `Chapter ${beat.chapter}: ${beat.title}. ${readLabel}.`;
|
|
|
|
return (
|
|
<li key={beat.id}>
|
|
<button
|
|
type="button"
|
|
aria-current={active ? "location" : undefined}
|
|
aria-label={ariaLabel}
|
|
onClick={() => scrollToBeat(beat.id)}
|
|
className={cn(
|
|
"group relative -ml-px flex w-full items-start gap-2 border-l-2 px-2 py-2 text-left text-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
active
|
|
? "border-primary text-foreground"
|
|
: "border-transparent text-muted-foreground hover:text-foreground",
|
|
)}
|
|
>
|
|
<span className="shrink-0 font-mono tabular-nums">{beat.chapter.toString().padStart(2, "0")}</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span
|
|
className={cn(
|
|
"-mx-1 -my-0.5 inline-block max-w-full truncate align-bottom rounded-sm px-1 py-0.5",
|
|
"group-hover:relative group-hover:z-10 group-hover:max-w-none group-hover:overflow-visible group-hover:whitespace-nowrap group-hover:bg-muted/60 group-hover:text-foreground",
|
|
)}
|
|
>
|
|
{beat.title}
|
|
</span>
|
|
</span>
|
|
{beat.voiceoverUrl && (
|
|
<Headphones
|
|
aria-hidden="true"
|
|
className="mt-1.5 size-3 shrink-0 text-muted-foreground"
|
|
/>
|
|
)}
|
|
<span
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"mt-1.5 size-1.5 shrink-0 rounded-full",
|
|
beat.initialRead ? "bg-emerald-500" : "bg-amber-500",
|
|
)}
|
|
/>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
</nav>
|
|
);
|
|
}
|