From f781e53dc12bbdbe9999d2eb15767ae17227bcbb Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Thu, 10 Sep 2026 17:56:35 -0400 Subject: [PATCH] feat(story): add chapter table of contents with hover reveal to chronicle page --- src/app/(frontend)/story/page.tsx | 146 ++++++++++-------- .../frontend/story/StoryTableOfContents.tsx | 122 +++++++++++++++ 2 files changed, 202 insertions(+), 66 deletions(-) create mode 100644 src/components/frontend/story/StoryTableOfContents.tsx 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 ( -
-
-

- The Unit Chronicle -

-

Story Chronicle

-

- Every operation leaves a mark. These are the beats of our story, told in - order. -

- {sorted.length > 0 && ( -
- -
- )} -
+
+
+
+
+

+ The Unit Chronicle +

+

Story Chronicle

+

+ Every operation leaves a mark. These are the beats of our story, told in + order. +

+ {sorted.length > 0 && ( +
+ +
+ )} +
- {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 ( -
  1. - -
    -

    - Chapter {index + 1} -

    - -
    -

    {beat.title}

    -
    - {beat.occurredAt && {DATE_FORMAT.format(new Date(beat.occurredAt))}} - {missionLabel && ( - - {missionLabel} - - )} -
    -
    - -
    - -
  2. - ); - })} -
- )} + {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 ( +
  1. + +
    +

    + Chapter {index + 1} +

    + +
    +

    {beat.title}

    +
    + {beat.occurredAt && ( + {DATE_FORMAT.format(new Date(beat.occurredAt))} + )} + {missionLabel && ( + + {missionLabel} + + )} +
    +
    + +
    + +
  2. + ); + })} +
+ )} +
+ + {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 ( + + ); +}