feat(story): add chapter table of contents with hover reveal to chronicle page
This commit is contained in:
parent
cbb320cb71
commit
f781e53dc1
2 changed files with 202 additions and 66 deletions
|
|
@ -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 (
|
||||
<div className="mx-auto max-w-3xl px-4 py-10">
|
||||
<header className="mb-12 text-center">
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">
|
||||
The Unit Chronicle
|
||||
</p>
|
||||
<h1 className="mt-2 font-serif text-4xl font-bold">Story Chronicle</h1>
|
||||
<p className="mt-3 text-muted-foreground">
|
||||
Every operation leaves a mark. These are the beats of our story, told in
|
||||
order.
|
||||
</p>
|
||||
{sorted.length > 0 && (
|
||||
<div className="mt-5 flex justify-center">
|
||||
<StoryViewer beats={readerBeats} />
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<div className="mx-auto max-w-3xl px-4 py-10 xl:max-w-screen-2xl">
|
||||
<div className="xl:grid xl:grid-cols-[minmax(0,1fr)_minmax(0,48rem)_minmax(0,1fr)] xl:gap-8">
|
||||
<div className="min-w-0 xl:col-start-2">
|
||||
<header className="mb-12 text-center">
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">
|
||||
The Unit Chronicle
|
||||
</p>
|
||||
<h1 className="mt-2 font-serif text-4xl font-bold">Story Chronicle</h1>
|
||||
<p className="mt-3 text-muted-foreground">
|
||||
Every operation leaves a mark. These are the beats of our story, told in
|
||||
order.
|
||||
</p>
|
||||
{sorted.length > 0 && (
|
||||
<div className="mt-5 flex justify-center">
|
||||
<StoryViewer beats={readerBeats} />
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground">
|
||||
No published chapters yet. Check back soon.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="space-y-16">
|
||||
{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 (
|
||||
<li
|
||||
key={beat.id}
|
||||
data-beat-id={beat.id}
|
||||
className="relative border-l border-border pl-8"
|
||||
>
|
||||
<span className="absolute -left-[9px] top-1.5 size-4 rounded-full border-2 border-background bg-primary" />
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-xs uppercase tracking-widest text-muted-foreground">
|
||||
Chapter {index + 1}
|
||||
</p>
|
||||
<BeatReadMarker beatId={beat.id} initialRead={Boolean(state?.readAt)} />
|
||||
</div>
|
||||
<h2 className="mt-2 font-serif text-2xl font-semibold">{beat.title}</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
{beat.occurredAt && <span>{DATE_FORMAT.format(new Date(beat.occurredAt))}</span>}
|
||||
{missionLabel && (
|
||||
<span className="rounded border border-border px-2 py-0.5">
|
||||
{missionLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<RichText data={beat.body} className={cn(BEAT_BODY_CLASSES)} />
|
||||
</div>
|
||||
<BeatReactions
|
||||
beatId={beat.id}
|
||||
initialReaction={state?.reaction ?? null}
|
||||
initialCounts={counts.get(beat.id) ?? emptyCounts()}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
{sorted.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground">
|
||||
No published chapters yet. Check back soon.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="space-y-16">
|
||||
{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 (
|
||||
<li
|
||||
key={beat.id}
|
||||
id={`beat-${beat.id}`}
|
||||
data-beat-id={beat.id}
|
||||
className="relative scroll-mt-20 border-l border-border pl-8"
|
||||
>
|
||||
<span className="absolute -left-[9px] top-1.5 size-4 rounded-full border-2 border-background bg-primary" />
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-xs uppercase tracking-widest text-muted-foreground">
|
||||
Chapter {index + 1}
|
||||
</p>
|
||||
<BeatReadMarker beatId={beat.id} initialRead={Boolean(state?.readAt)} />
|
||||
</div>
|
||||
<h2 className="mt-2 font-serif text-2xl font-semibold">{beat.title}</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
{beat.occurredAt && (
|
||||
<span>{DATE_FORMAT.format(new Date(beat.occurredAt))}</span>
|
||||
)}
|
||||
{missionLabel && (
|
||||
<span className="rounded border border-border px-2 py-0.5">
|
||||
{missionLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<RichText data={beat.body} className={cn(BEAT_BODY_CLASSES)} />
|
||||
</div>
|
||||
<BeatReactions
|
||||
beatId={beat.id}
|
||||
initialReaction={state?.reaction ?? null}
|
||||
initialCounts={counts.get(beat.id) ?? emptyCounts()}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sorted.length > 0 && (
|
||||
<aside className="hidden xl:col-start-1 xl:row-start-1 xl:block xl:pt-48 xl:min-w-max">
|
||||
<StoryTableOfContents beats={readerBeats} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
122
src/components/frontend/story/StoryTableOfContents.tsx
Normal file
122
src/components/frontend/story/StoryTableOfContents.tsx
Normal file
|
|
@ -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<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";
|
||||
|
||||
return (
|
||||
<li key={beat.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={active ? "location" : undefined}
|
||||
aria-label={`Chapter ${beat.chapter}: ${beat.title}. ${readLabel}.`}
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue