1
0
Fork 0

Compare commits

..

No commits in common. "2ed90d9f093a003827e03c460c1d47cda7d442fd" and "cbb320cb71e2159f81ebf4d48c24b2d72b1616e6" have entirely different histories.

3 changed files with 66 additions and 202 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "polaris-task-force", "name": "polaris-task-force",
"version": "0.2.29", "version": "0.2.28",
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.", "description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View file

@ -9,7 +9,6 @@ import { BEAT_BODY_CLASSES } from "@/lib/story/richtextClasses";
import { BeatReactions } from "@/components/frontend/story/BeatReactions"; import { BeatReactions } from "@/components/frontend/story/BeatReactions";
import { BeatReadMarker } from "@/components/frontend/story/BeatReadMarker"; import { BeatReadMarker } from "@/components/frontend/story/BeatReadMarker";
import { StoryViewer, type StoryViewerBeat } from "@/components/frontend/story/StoryViewer"; import { StoryViewer, type StoryViewerBeat } from "@/components/frontend/story/StoryViewer";
import { StoryTableOfContents } from "@/components/frontend/story/StoryTableOfContents";
import type { StoryReaction } from "@/lib/story/reactions"; import type { StoryReaction } from "@/lib/story/reactions";
export const metadata = { export const metadata = {
@ -102,9 +101,7 @@ export default async function StoryPage() {
})); }));
return ( return (
<div className="mx-auto max-w-3xl px-4 py-10 xl:max-w-screen-2xl"> <div className="mx-auto max-w-3xl px-4 py-10">
<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"> <header className="mb-12 text-center">
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground"> <p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">
The Unit Chronicle The Unit Chronicle
@ -139,9 +136,8 @@ export default async function StoryPage() {
return ( return (
<li <li
key={beat.id} key={beat.id}
id={`beat-${beat.id}`}
data-beat-id={beat.id} data-beat-id={beat.id}
className="relative scroll-mt-20 border-l border-border pl-8" 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" /> <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"> <div className="flex items-center justify-between gap-4">
@ -152,9 +148,7 @@ export default async function StoryPage() {
</div> </div>
<h2 className="mt-2 font-serif text-2xl font-semibold">{beat.title}</h2> <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"> <div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
{beat.occurredAt && ( {beat.occurredAt && <span>{DATE_FORMAT.format(new Date(beat.occurredAt))}</span>}
<span>{DATE_FORMAT.format(new Date(beat.occurredAt))}</span>
)}
{missionLabel && ( {missionLabel && (
<span className="rounded border border-border px-2 py-0.5"> <span className="rounded border border-border px-2 py-0.5">
{missionLabel} {missionLabel}
@ -175,13 +169,5 @@ export default async function StoryPage() {
</ol> </ol>
)} )}
</div> </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>
); );
} }

View file

@ -1,122 +0,0 @@
"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>
);
}