116 lines
4 KiB
TypeScript
116 lines
4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { BookOpen, X } from "lucide-react";
|
|
import { RichText } from "@payloadcms/richtext-lexical/react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { BeatReadMarker } from "@/components/frontend/story/BeatReadMarker";
|
|
import type { StoryBeat } from "@/payload-types";
|
|
import { cn } from "@/lib/utils";
|
|
import { READER_BODY_CLASSES } from "@/lib/story/richtextClasses";
|
|
|
|
export type StoryViewerBeat = {
|
|
readonly id: number;
|
|
readonly title: string;
|
|
readonly chapter: number;
|
|
readonly dateLabel: string | null;
|
|
readonly body: StoryBeat["body"];
|
|
readonly initialRead: boolean;
|
|
};
|
|
|
|
export function StoryViewer({ beats }: { readonly beats: readonly StoryViewerBeat[] }) {
|
|
const [open, setOpen] = useState(false);
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const previousOverflow = document.body.style.overflow;
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") setOpen(false);
|
|
};
|
|
|
|
document.body.style.overflow = "hidden";
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
closeButtonRef.current?.focus();
|
|
|
|
return () => {
|
|
document.body.style.overflow = previousOverflow;
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
triggerRef.current?.focus();
|
|
};
|
|
}, [open]);
|
|
|
|
if (beats.length === 0) return null;
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
ref={triggerRef}
|
|
type="button"
|
|
variant="outline"
|
|
aria-label="Open immersive story reader"
|
|
onClick={() => setOpen(true)}
|
|
className="gap-2"
|
|
>
|
|
<BookOpen className="size-4" />
|
|
Immersive view
|
|
</Button>
|
|
|
|
{open && (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="story-reader-title"
|
|
className="fixed inset-0 z-50 overflow-y-auto overscroll-contain bg-black text-white"
|
|
>
|
|
<div className="pointer-events-none fixed inset-x-0 top-0 z-10 flex justify-end p-4 sm:p-6">
|
|
<Button
|
|
ref={closeButtonRef}
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label="Exit reader mode"
|
|
onClick={() => setOpen(false)}
|
|
className="pointer-events-auto text-white hover:bg-white/10 hover:text-white"
|
|
>
|
|
<X className="size-5" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="min-h-[100dvh] px-6 pb-24 pt-24 sm:px-8 sm:pt-28">
|
|
<div className="mx-auto max-w-3xl">
|
|
<h1 id="story-reader-title" className="sr-only">
|
|
The Unit Chronicle
|
|
</h1>
|
|
<ol className="flex flex-col gap-24">
|
|
{beats.map((beat) => (
|
|
<li key={beat.id} className="scroll-mt-24">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<p className="text-xs uppercase tracking-[0.3em] text-neutral-500">
|
|
Chapter {beat.chapter}
|
|
</p>
|
|
<div className="[&_.text-muted-foreground]:text-neutral-500">
|
|
<BeatReadMarker beatId={beat.id} initialRead={beat.initialRead} />
|
|
</div>
|
|
</div>
|
|
<h2 className="mt-6 font-serif text-3xl font-semibold leading-tight text-white sm:text-4xl">
|
|
{beat.title}
|
|
</h2>
|
|
{beat.dateLabel && (
|
|
<p className="mt-3 text-sm text-neutral-500">{beat.dateLabel}</p>
|
|
)}
|
|
<div className="mt-8">
|
|
<RichText data={beat.body} className={cn(READER_BODY_CLASSES)} />
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|