diff --git a/src/app/(frontend)/story/page.tsx b/src/app/(frontend)/story/page.tsx
index d7b99f7..1626075 100644
--- a/src/app/(frontend)/story/page.tsx
+++ b/src/app/(frontend)/story/page.tsx
@@ -9,6 +9,7 @@ import { BEAT_BODY_CLASSES } from "@/lib/story/richtextClasses";
import { BeatReactions } from "@/components/frontend/story/BeatReactions";
import { BeatReadMarker } from "@/components/frontend/story/BeatReadMarker";
import { StoryViewer, type StoryViewerBeat } from "@/components/frontend/story/StoryViewer";
+import { StoryTableOfContents } from "@/components/frontend/story/StoryTableOfContents";
import type { StoryReaction } from "@/lib/story/reactions";
export const metadata = {
@@ -101,73 +102,86 @@ export default async function StoryPage() {
}));
return (
-
-
+
+
+
+
- {sorted.length === 0 ? (
-
- No published chapters yet. Check back soon.
-
- ) : (
-
- {sorted.map((beat, index) => {
- const state = stateByBeat.get(beat.id);
- const mission =
- beat.mission && typeof beat.mission !== "number" ? beat.mission : null;
- const missionLabel = mission
- ? mission.codeName
- ? `${mission.codeName} · ${mission.name}`
- : mission.name
- : null;
- return (
- -
-
-
-
- Chapter {index + 1}
-
-
-
- {beat.title}
-
- {beat.occurredAt && {DATE_FORMAT.format(new Date(beat.occurredAt))}}
- {missionLabel && (
-
- {missionLabel}
-
- )}
-
-
-
-
-
-
- );
- })}
-
- )}
+ {sorted.length === 0 ? (
+
+ No published chapters yet. Check back soon.
+
+ ) : (
+
+ {sorted.map((beat, index) => {
+ const state = stateByBeat.get(beat.id);
+ const mission =
+ beat.mission && typeof beat.mission !== "number" ? beat.mission : null;
+ const missionLabel = mission
+ ? mission.codeName
+ ? `${mission.codeName} · ${mission.name}`
+ : mission.name
+ : null;
+ return (
+ -
+
+
+
+ Chapter {index + 1}
+
+
+
+ {beat.title}
+
+ {beat.occurredAt && (
+ {DATE_FORMAT.format(new Date(beat.occurredAt))}
+ )}
+ {missionLabel && (
+
+ {missionLabel}
+
+ )}
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {sorted.length > 0 && (
+
+ )}
+
);
}
diff --git a/src/components/frontend/story/StoryTableOfContents.tsx b/src/components/frontend/story/StoryTableOfContents.tsx
new file mode 100644
index 0000000..4f0efd2
--- /dev/null
+++ b/src/components/frontend/story/StoryTableOfContents.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { StoryViewerBeat } from "@/components/frontend/story/StoryViewer";
+import { cn } from "@/lib/utils";
+
+export type StoryTableOfContentsBeat = Pick<
+ StoryViewerBeat,
+ "id" | "title" | "chapter" | "initialRead"
+>;
+
+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 (
+
+ );
+}