Compare commits
No commits in common. "72e47190ea7bd7ca6db29c3c05db70c996eb74e6" and "2b554ec8a3bc60cd322533fcc1022e5cade245c8" have entirely different histories.
72e47190ea
...
2b554ec8a3
10 changed files with 27 additions and 507 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -1,17 +1,5 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
# v0.2.54
|
|
||||||
|
|
||||||
The Chronicle becomes a proper reading room.
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **Book view**: a new two-page, flip-style reader next to Immersive view. Chapters always appear in reading order (Chapter 1 first), pages turn with chevrons or arrow keys, there is no vertical scrolling, and narrations keep playing while you flip.
|
|
||||||
- **Chapter order**: chapters now number by when they were written rather than their in-story dates, so new entries no longer shuffle the sequence. The story page shows the newest chapter on top, and setting the Order field on any beat switches it to exact hand-sequenced book mode.
|
|
||||||
- **Readable pages**: book pages carry even margins on every side, prose is left-aligned like a real book, and the two pages sit close together at the spine on large screens.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# v0.2.53
|
# v0.2.53
|
||||||
|
|
||||||
The Chronicle goes audio: chapters can now have narrations you can listen to.
|
The Chronicle goes audio: chapters can now have narrations you can listen to.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.54",
|
"version": "0.2.53",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,8 @@ 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 { BeatVoiceover } from "@/components/frontend/story/BeatVoiceover";
|
import { BeatVoiceover } from "@/components/frontend/story/BeatVoiceover";
|
||||||
import { BookView } from "@/components/frontend/story/BookView";
|
|
||||||
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 { StoryTableOfContents } from "@/components/frontend/story/StoryTableOfContents";
|
||||||
import { chronicleDisplayOrder } from "@/lib/story/sortBeats";
|
|
||||||
import type { StoryReaction } from "@/lib/story/reactions";
|
import type { StoryReaction } from "@/lib/story/reactions";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
|
|
@ -31,6 +29,18 @@ function emptyCounts(): ReactionCounts {
|
||||||
return { like: 0, heart: 0, salute: 0 };
|
return { like: 0, heart: 0, salute: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortBeats(beats: StoryBeat[]): StoryBeat[] {
|
||||||
|
return [...beats].sort((a, b) => {
|
||||||
|
const timeA = a.occurredAt ? Date.parse(a.occurredAt) : Number.POSITIVE_INFINITY;
|
||||||
|
const timeB = b.occurredAt ? Date.parse(b.occurredAt) : Number.POSITIVE_INFINITY;
|
||||||
|
if (timeA !== timeB) return timeA - timeB;
|
||||||
|
const orderA = a.order ?? Number.POSITIVE_INFINITY;
|
||||||
|
const orderB = b.order ?? Number.POSITIVE_INFINITY;
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function voiceoverDoc(beat: StoryBeat): StoryVoiceover | null {
|
function voiceoverDoc(beat: StoryBeat): StoryVoiceover | null {
|
||||||
return beat.voiceover && typeof beat.voiceover !== "number" ? beat.voiceover : null;
|
return beat.voiceover && typeof beat.voiceover !== "number" ? beat.voiceover : null;
|
||||||
}
|
}
|
||||||
|
|
@ -51,9 +61,7 @@ export default async function StoryPage() {
|
||||||
overrideAccess: false,
|
overrideAccess: false,
|
||||||
user,
|
user,
|
||||||
});
|
});
|
||||||
const { beats: displayBeats, chapterById } = chronicleDisplayOrder(
|
const sorted = sortBeats(beatsResult.docs as StoryBeat[]);
|
||||||
beatsResult.docs as StoryBeat[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const statesResult = await payload.find({
|
const statesResult = await payload.find({
|
||||||
collection: "story-beat-states",
|
collection: "story-beat-states",
|
||||||
|
|
@ -69,7 +77,7 @@ export default async function StoryPage() {
|
||||||
stateByBeat.set(beatId, state);
|
stateByBeat.set(beatId, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
const beatIds = displayBeats.map((beat) => beat.id);
|
const beatIds = sorted.map((beat) => beat.id);
|
||||||
const counts = new Map<number, ReactionCounts>();
|
const counts = new Map<number, ReactionCounts>();
|
||||||
for (const id of beatIds) counts.set(id, emptyCounts());
|
for (const id of beatIds) counts.set(id, emptyCounts());
|
||||||
if (beatIds.length > 0) {
|
if (beatIds.length > 0) {
|
||||||
|
|
@ -89,10 +97,10 @@ export default async function StoryPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const readerBeats: StoryViewerBeat[] = displayBeats.map((beat) => ({
|
const readerBeats: StoryViewerBeat[] = sorted.map((beat, index) => ({
|
||||||
id: beat.id,
|
id: beat.id,
|
||||||
title: beat.title,
|
title: beat.title,
|
||||||
chapter: chapterById.get(beat.id) ?? 0,
|
chapter: index + 1,
|
||||||
dateLabel: beat.occurredAt ? DATE_FORMAT.format(new Date(beat.occurredAt)) : null,
|
dateLabel: beat.occurredAt ? DATE_FORMAT.format(new Date(beat.occurredAt)) : null,
|
||||||
body: beat.body,
|
body: beat.body,
|
||||||
initialRead: Boolean(stateByBeat.get(beat.id)?.readAt),
|
initialRead: Boolean(stateByBeat.get(beat.id)?.readAt),
|
||||||
|
|
@ -112,21 +120,20 @@ export default async function StoryPage() {
|
||||||
<p className="mt-3 text-muted-foreground">
|
<p className="mt-3 text-muted-foreground">
|
||||||
Every operation leaves a mark. These are the beats of our story, told in order.
|
Every operation leaves a mark. These are the beats of our story, told in order.
|
||||||
</p>
|
</p>
|
||||||
{displayBeats.length > 0 && (
|
{sorted.length > 0 && (
|
||||||
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
<div className="mt-5 flex justify-center">
|
||||||
<StoryViewer beats={readerBeats} />
|
<StoryViewer beats={readerBeats} />
|
||||||
<BookView beats={readerBeats} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{displayBeats.length === 0 ? (
|
{sorted.length === 0 ? (
|
||||||
<p className="text-center text-muted-foreground">
|
<p className="text-center text-muted-foreground">
|
||||||
No published chapters yet. Check back soon.
|
No published chapters yet. Check back soon.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ol className="space-y-16">
|
<ol className="space-y-16">
|
||||||
{displayBeats.map((beat) => {
|
{sorted.map((beat, index) => {
|
||||||
const state = stateByBeat.get(beat.id);
|
const state = stateByBeat.get(beat.id);
|
||||||
const mission =
|
const mission =
|
||||||
beat.mission && typeof beat.mission !== "number" ? beat.mission : null;
|
beat.mission && typeof beat.mission !== "number" ? beat.mission : null;
|
||||||
|
|
@ -147,7 +154,7 @@ export default async function StoryPage() {
|
||||||
<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">
|
||||||
<p className="text-xs uppercase tracking-widest text-muted-foreground">
|
<p className="text-xs uppercase tracking-widest text-muted-foreground">
|
||||||
Chapter {chapterById.get(beat.id) ?? 0}
|
Chapter {index + 1}
|
||||||
</p>
|
</p>
|
||||||
<BeatReadMarker beatId={beat.id} initialRead={Boolean(state?.readAt)} />
|
<BeatReadMarker beatId={beat.id} initialRead={Boolean(state?.readAt)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -186,7 +193,7 @@ export default async function StoryPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{displayBeats.length > 0 && (
|
{sorted.length > 0 && (
|
||||||
<aside className="hidden xl:col-start-1 xl:row-start-1 xl:block xl:pt-48 xl:min-w-max">
|
<aside className="hidden xl:col-start-1 xl:row-start-1 xl:block xl:pt-48 xl:min-w-max">
|
||||||
<StoryTableOfContents beats={readerBeats} />
|
<StoryTableOfContents beats={readerBeats} />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -3582,13 +3582,3 @@
|
||||||
z-index: 1300 !important;
|
z-index: 1300 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Book view (story reader): body prose reads left-aligned like a real book.
|
|
||||||
Center alignment authored on paragraphs in the Lexical editor renders as
|
|
||||||
inline text-align styles, which only an !important rule can override, so
|
|
||||||
the rule is scoped to the paginated Book view body (the immersive reader
|
|
||||||
keeps the authored alignment). */
|
|
||||||
.book-view-body,
|
|
||||||
.book-view-body [style*="text-align"] {
|
|
||||||
text-align: left !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,16 +38,14 @@ export const StoryBeats: CollectionConfig = {
|
||||||
name: "occurredAt",
|
name: "occurredAt",
|
||||||
type: "date",
|
type: "date",
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
description: "In-story timestamp used to order the chronicle.",
|
||||||
"In-story timestamp shown with the chapter on /story. Does not affect chapter order.",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "order",
|
name: "order",
|
||||||
type: "number",
|
type: "number",
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
description: "Tie-breaker for beats that share the same timestamp.",
|
||||||
"Manual chapter sequence: if any beat has an order, /story displays beats in that exact order (ascending, first at top). With no order values, the newest beat shows first and chapter numbers follow creation date.",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { BookOpenText, ChevronLeft, ChevronRight, 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 { BeatVoiceover } from "@/components/frontend/story/BeatVoiceover";
|
|
||||||
import type { StoryViewerBeat } from "@/components/frontend/story/StoryViewer";
|
|
||||||
import {
|
|
||||||
BOOK_COLUMN_GAP,
|
|
||||||
BOOK_MAX_WIDTH,
|
|
||||||
bookColumnCount,
|
|
||||||
bookColumnOffset,
|
|
||||||
bookPageCount,
|
|
||||||
} from "@/lib/story/bookLayout";
|
|
||||||
import { READER_BODY_CLASSES } from "@/lib/story/richtextClasses";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Two-page, flip-style reader: fullscreen like the immersive view but
|
|
||||||
* paginated horizontally (no vertical scroll). Chapters always appear in
|
|
||||||
* their actual order, Chapter 1 first, regardless of the /story display
|
|
||||||
* order. Long chapters flow across pages via CSS columns; each chapter
|
|
||||||
* starts on a fresh page.
|
|
||||||
*/
|
|
||||||
export function BookView({ beats }: { readonly beats: readonly StoryViewerBeat[] }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [pageIndex, setPageIndex] = useState(0);
|
|
||||||
const [pageCount, setPageCount] = useState(1);
|
|
||||||
const [columnCount, setColumnCount] = useState(2);
|
|
||||||
const [viewportWidth, setViewportWidth] = useState(0);
|
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const chapters = useMemo(() => [...beats].sort((a, b) => a.chapter - b.chapter), [beats]);
|
|
||||||
|
|
||||||
const measure = useCallback(() => {
|
|
||||||
const content = contentRef.current;
|
|
||||||
if (!content) return;
|
|
||||||
// The multicol element carries page margins as its own padding, so the
|
|
||||||
// paginated area is its content box and its columns start after padLeft.
|
|
||||||
const style = getComputedStyle(content);
|
|
||||||
const padLeft = parseFloat(style.paddingLeft) || 0;
|
|
||||||
const padRight = parseFloat(style.paddingRight) || 0;
|
|
||||||
const width = content.clientWidth - padLeft - padRight;
|
|
||||||
if (width <= 0) return;
|
|
||||||
const columns = bookColumnCount(width);
|
|
||||||
setColumnCount(columns);
|
|
||||||
setViewportWidth(width);
|
|
||||||
setPageCount(bookPageCount(content.scrollWidth - padLeft, width, columns, BOOK_COLUMN_GAP));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Focus + body scroll lock tied strictly to open/close, not to paging.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
const previousOverflow = document.body.style.overflow;
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
closeButtonRef.current?.focus();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = previousOverflow;
|
|
||||||
triggerRef.current?.focus();
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
// Arrow keys flip pages; re-registered per page so handlers see fresh state.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") setOpen(false);
|
|
||||||
if (event.key === "ArrowRight") {
|
|
||||||
setPageIndex((index) => Math.min(index + 1, Math.max(pageCount - 1, 0)));
|
|
||||||
}
|
|
||||||
if (event.key === "ArrowLeft") {
|
|
||||||
setPageIndex((index) => Math.max(index - 1, 0));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [open, pageCount]);
|
|
||||||
|
|
||||||
// Measure pages while open: viewport resize, font load, and content changes.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
measure();
|
|
||||||
const observer = new ResizeObserver(measure);
|
|
||||||
if (contentRef.current) observer.observe(contentRef.current);
|
|
||||||
window.addEventListener("resize", measure);
|
|
||||||
document.fonts?.ready.then(measure).catch(() => {});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
window.removeEventListener("resize", measure);
|
|
||||||
};
|
|
||||||
}, [open, measure]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Keep the current page valid after resizes change the page count.
|
|
||||||
setPageIndex((index) => Math.min(index, Math.max(pageCount - 1, 0)));
|
|
||||||
}, [pageCount]);
|
|
||||||
|
|
||||||
if (chapters.length === 0) return null;
|
|
||||||
|
|
||||||
const offset = bookColumnOffset(pageIndex, viewportWidth, columnCount, BOOK_COLUMN_GAP);
|
|
||||||
const canFlipBack = pageIndex > 0;
|
|
||||||
const canFlipForward = pageIndex < pageCount - 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
ref={triggerRef}
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
aria-label="Open book view"
|
|
||||||
onClick={() => {
|
|
||||||
setPageIndex(0);
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<BookOpenText className="size-4" />
|
|
||||||
Book view
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="book-view-title"
|
|
||||||
className="fixed inset-0 z-50 overflow-hidden 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 book view"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="pointer-events-auto text-white hover:bg-white/10 hover:text-white"
|
|
||||||
>
|
|
||||||
<X className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="mx-auto h-[100dvh] w-full overflow-hidden"
|
|
||||||
style={{ maxWidth: BOOK_MAX_WIDTH }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={contentRef}
|
|
||||||
className="h-full w-full px-8 py-14 motion-reduce:transition-none sm:py-16"
|
|
||||||
style={{
|
|
||||||
columns: columnCount,
|
|
||||||
columnGap: BOOK_COLUMN_GAP,
|
|
||||||
columnFill: "auto",
|
|
||||||
transform: `translateX(-${offset}px)`,
|
|
||||||
transition: "transform 400ms ease",
|
|
||||||
willChange: "transform",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{chapters.map((beat, index) => (
|
|
||||||
<article
|
|
||||||
key={beat.id}
|
|
||||||
data-beat-id={beat.id}
|
|
||||||
className="mx-auto max-w-2xl"
|
|
||||||
style={{ breakBefore: index > 0 ? "column" : "auto" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<p className="text-xs uppercase tracking-[0.3em] text-neutral-500">
|
|
||||||
Chapter {beat.chapter}
|
|
||||||
</p>
|
|
||||||
<BeatReadMarker beatId={beat.id} initialRead={beat.initialRead} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<h2 className="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>
|
|
||||||
{beat.voiceoverUrl && (
|
|
||||||
<div className="mt-6 [&_.text-muted-foreground]:text-neutral-500">
|
|
||||||
<BeatVoiceover
|
|
||||||
src={beat.voiceoverUrl}
|
|
||||||
label={beat.title}
|
|
||||||
aiGenerated={beat.voiceoverAiGenerated}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="book-view-body mt-8">
|
|
||||||
<RichText data={beat.body} className={cn(READER_BODY_CLASSES)} />
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="fixed inset-x-0 bottom-0 z-10 flex items-center justify-center gap-4 p-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Previous page"
|
|
||||||
disabled={!canFlipBack}
|
|
||||||
onClick={() => setPageIndex((index) => Math.max(index - 1, 0))}
|
|
||||||
className="text-white hover:bg-white/10 hover:text-white disabled:text-white/30"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-5" />
|
|
||||||
</Button>
|
|
||||||
<p aria-live="polite" className="font-mono text-xs tabular-nums text-neutral-500">
|
|
||||||
Page {pageIndex + 1} of {pageCount}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Next page"
|
|
||||||
disabled={!canFlipForward}
|
|
||||||
onClick={() => setPageIndex((index) => Math.min(index + 1, pageCount - 1))}
|
|
||||||
className="text-white hover:bg-white/10 hover:text-white disabled:text-white/30"
|
|
||||||
>
|
|
||||||
<ChevronRight className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
/**
|
|
||||||
* Layout math for the BookView two-page reader. Pure, so the flip logic is
|
|
||||||
* unit-testable and the component only does DOM measurement.
|
|
||||||
*
|
|
||||||
* The book renders one column per page. A spread shows `columnCount` pages
|
|
||||||
* side by side; flipping advances one page (column) at a time by translating
|
|
||||||
* the multicol content horizontally.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Horizontal gap (px) between book pages: the book's spine. Keep it equal to
|
|
||||||
* or larger than the reader's horizontal padding so adjacent pages never peek
|
|
||||||
* into the page margins mid-flip. */
|
|
||||||
export const BOOK_COLUMN_GAP = 32;
|
|
||||||
|
|
||||||
/** Maximum width (px) of the whole two-page book window. On larger viewports
|
|
||||||
* the book stops growing and centers itself, so the capped text blocks stay
|
|
||||||
* close to the spine instead of drifting apart with huge gutters. Choose a
|
|
||||||
* value whose page width (content/2) stays close to the text cap. */
|
|
||||||
export const BOOK_MAX_WIDTH = 1536;
|
|
||||||
|
|
||||||
/** Viewport width (px) below which the book falls back to a single page. */
|
|
||||||
export const BOOK_SINGLE_COLUMN_BREAKPOINT = 768;
|
|
||||||
|
|
||||||
/** Pages shown side by side at the given viewport width. */
|
|
||||||
export function bookColumnCount(viewportWidth: number): number {
|
|
||||||
return viewportWidth > 0 && viewportWidth < BOOK_SINGLE_COLUMN_BREAKPOINT ? 1 : 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Distance between the left edges of consecutive pages. */
|
|
||||||
export function bookPagePitch(
|
|
||||||
viewportWidth: number,
|
|
||||||
columnCount: number,
|
|
||||||
gap = BOOK_COLUMN_GAP,
|
|
||||||
): number {
|
|
||||||
return (viewportWidth + gap) / columnCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Total number of pages the content spans, given the measured scrollWidth. */
|
|
||||||
export function bookPageCount(
|
|
||||||
scrollWidth: number,
|
|
||||||
viewportWidth: number,
|
|
||||||
columnCount: number,
|
|
||||||
gap = BOOK_COLUMN_GAP,
|
|
||||||
): number {
|
|
||||||
if (viewportWidth <= 0 || scrollWidth <= 0) return 1;
|
|
||||||
const pitch = bookPagePitch(viewportWidth, columnCount, gap);
|
|
||||||
return Math.max(1, Math.ceil((scrollWidth + gap) / pitch));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Horizontal translation (positive px) that brings pageIndex's page into view. */
|
|
||||||
export function bookColumnOffset(
|
|
||||||
pageIndex: number,
|
|
||||||
viewportWidth: number,
|
|
||||||
columnCount: number,
|
|
||||||
gap = BOOK_COLUMN_GAP,
|
|
||||||
): number {
|
|
||||||
return pageIndex * bookPagePitch(viewportWidth, columnCount, gap);
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
import type { StoryBeat } from "@/payload-types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chronicle chapter order (canonical reading order).
|
|
||||||
*
|
|
||||||
* Default: beats sort by their real creation date, ascending, so chapters
|
|
||||||
* number in the order they were written/released. In-story timestamps
|
|
||||||
* (`occurredAt`) are display-only and never affect ordering.
|
|
||||||
*
|
|
||||||
* Manual override: a beat with an explicit `order` value is curated. Curated
|
|
||||||
* beats come first, sorted by `order` ascending (ties broken by creation
|
|
||||||
* date); everything else follows in creation order. To fully hand-sequence a
|
|
||||||
* chronicle, set `order` on every beat.
|
|
||||||
*
|
|
||||||
* Pure and non-mutating: returns a new sorted array.
|
|
||||||
*/
|
|
||||||
export function sortBeats(beats: StoryBeat[]): StoryBeat[] {
|
|
||||||
return [...beats].sort((a, b) => {
|
|
||||||
const orderA = a.order ?? null;
|
|
||||||
const orderB = b.order ?? null;
|
|
||||||
|
|
||||||
if (orderA !== null && orderB !== null && orderA !== orderB) return orderA - orderB;
|
|
||||||
if (orderA !== null) return -1;
|
|
||||||
if (orderB !== null) return 1;
|
|
||||||
|
|
||||||
const createdA = Date.parse(a.createdAt);
|
|
||||||
const createdB = Date.parse(b.createdAt);
|
|
||||||
if (createdA !== createdB) return createdA - createdB;
|
|
||||||
|
|
||||||
const occurredA = a.occurredAt ? Date.parse(a.occurredAt) : Number.POSITIVE_INFINITY;
|
|
||||||
const occurredB = b.occurredAt ? Date.parse(b.occurredAt) : Number.POSITIVE_INFINITY;
|
|
||||||
return occurredA - occurredB;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How /story displays the chronicle.
|
|
||||||
*
|
|
||||||
* Default: newest chapter first (blog style), while chapter numbers still
|
|
||||||
* count from the oldest beat (Chapter 1 = first written). If any beat is
|
|
||||||
* hand-ordered with an explicit `order`, the page switches to book mode:
|
|
||||||
* beats display in that exact sequence with Chapter 1 at the top.
|
|
||||||
*
|
|
||||||
* Returns the display-ordered beats plus each beat id's chapter number.
|
|
||||||
*/
|
|
||||||
export function chronicleDisplayOrder(beats: StoryBeat[]): {
|
|
||||||
beats: StoryBeat[];
|
|
||||||
chapterById: Map<number, number>;
|
|
||||||
} {
|
|
||||||
const sorted = sortBeats(beats);
|
|
||||||
const chapterById = new Map<number, number>(sorted.map((beat, index) => [beat.id, index + 1]));
|
|
||||||
const curated = sorted.some((beat) => (beat.order ?? null) !== null);
|
|
||||||
return { beats: curated ? sorted : [...sorted].reverse(), chapterById };
|
|
||||||
}
|
|
||||||
|
|
@ -4278,11 +4278,11 @@ export interface StoryBeat {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
/**
|
/**
|
||||||
* In-story timestamp shown with the chapter on /story. Does not affect chapter order.
|
* In-story timestamp used to order the chronicle.
|
||||||
*/
|
*/
|
||||||
occurredAt?: string | null;
|
occurredAt?: string | null;
|
||||||
/**
|
/**
|
||||||
* Manual chapter sequence: if any beat has an order, /story displays beats in that exact order (ascending, first at top). With no order values, the newest beat shows first and chapter numbers follow creation date.
|
* Tie-breaker for beats that share the same timestamp.
|
||||||
*/
|
*/
|
||||||
order?: number | null;
|
order?: number | null;
|
||||||
status: 'draft' | 'published';
|
status: 'draft' | 'published';
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import config from "@/payload.config";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import type { StoryBeat, StoryBeatState, StoryVoiceover, User } from "@/payload-types";
|
import type { StoryBeat, StoryBeatState, StoryVoiceover, User } from "@/payload-types";
|
||||||
import { markBeatRead, setBeatReaction } from "@/lib/story";
|
import { markBeatRead, setBeatReaction } from "@/lib/story";
|
||||||
import { sortBeats, chronicleDisplayOrder } from "@/lib/story/sortBeats";
|
|
||||||
import { bookColumnOffset, bookPageCount } from "@/lib/story/bookLayout";
|
|
||||||
|
|
||||||
let payload: Payload;
|
let payload: Payload;
|
||||||
|
|
||||||
|
|
@ -471,115 +469,3 @@ describe("Story beats", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sortBeats (chapter ordering)", () => {
|
|
||||||
const baseBeat = {
|
|
||||||
status: "published" as const,
|
|
||||||
body: {} as StoryBeat["body"],
|
|
||||||
};
|
|
||||||
|
|
||||||
function beat(overrides: Partial<StoryBeat> & { id: number }): StoryBeat {
|
|
||||||
return { ...baseBeat, ...overrides } as unknown as StoryBeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("defaults to real creation date ascending, ignoring in-story timestamps", () => {
|
|
||||||
// Fictional in-story dates deliberately out of release order.
|
|
||||||
const prologue = beat({
|
|
||||||
id: 1,
|
|
||||||
title: "Little Green Light",
|
|
||||||
createdAt: "2026-09-20T10:00:00Z",
|
|
||||||
occurredAt: new Date("2156-03-18T08:00:00Z").toISOString(),
|
|
||||||
});
|
|
||||||
const followUp = beat({
|
|
||||||
id: 2,
|
|
||||||
title: "What Makes Us Human",
|
|
||||||
createdAt: "2026-09-24T10:00:00Z",
|
|
||||||
occurredAt: new Date("2034-06-12T08:00:00Z").toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const sorted = sortBeats([followUp, prologue]);
|
|
||||||
expect(sorted.map((beat) => beat.id)).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("puts explicitly ordered beats first, sorted by their order value", () => {
|
|
||||||
const old = beat({ id: 1, createdAt: "2026-01-01T00:00:00Z" });
|
|
||||||
const newer = beat({ id: 2, createdAt: "2026-02-01T00:00:00Z", order: 1 });
|
|
||||||
const pinnedLast = beat({ id: 3, createdAt: "2026-03-01T00:00:00Z", order: 2 });
|
|
||||||
// order = 0 is a valid curation value and must still lead the list.
|
|
||||||
const pinnedFirst = beat({ id: 4, createdAt: "2026-04-01T00:00:00Z", order: 0 });
|
|
||||||
|
|
||||||
const sorted = sortBeats([old, pinnedFirst, newer, pinnedLast]);
|
|
||||||
expect(sorted.map((beat) => beat.id)).toEqual([4, 2, 3, 1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not mutate the input array", () => {
|
|
||||||
const a = beat({ id: 1, createdAt: "2026-01-01T00:00:00Z" });
|
|
||||||
const b = beat({ id: 2, createdAt: "2026-02-01T00:00:00Z" });
|
|
||||||
const input = [b, a];
|
|
||||||
|
|
||||||
sortBeats(input);
|
|
||||||
expect(input.map((beat) => beat.id)).toEqual([2, 1]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("chronicleDisplayOrder (page display)", () => {
|
|
||||||
const baseBeat = {
|
|
||||||
status: "published" as const,
|
|
||||||
body: {} as StoryBeat["body"],
|
|
||||||
};
|
|
||||||
|
|
||||||
function beat(overrides: Partial<StoryBeat> & { id: number }): StoryBeat {
|
|
||||||
return { ...baseBeat, ...overrides } as unknown as StoryBeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("shows the newest chapter first by default, with chapter numbers still ascending", () => {
|
|
||||||
const ch1 = beat({ id: 1, createdAt: "2026-09-20T10:00:00Z" });
|
|
||||||
const ch2 = beat({ id: 2, createdAt: "2026-09-21T10:00:00Z" });
|
|
||||||
const ch3 = beat({ id: 3, createdAt: "2026-09-22T10:00:00Z" });
|
|
||||||
|
|
||||||
const { beats, chapterById } = chronicleDisplayOrder([ch1, ch2, ch3]);
|
|
||||||
|
|
||||||
expect(beats.map((beat) => beat.id)).toEqual([3, 2, 1]);
|
|
||||||
expect(chapterById.get(1)).toBe(1);
|
|
||||||
expect(chapterById.get(2)).toBe(2);
|
|
||||||
expect(chapterById.get(3)).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches to book mode when any beat is hand-ordered", () => {
|
|
||||||
const a = beat({ id: 1, createdAt: "2026-09-20T10:00:00Z" });
|
|
||||||
const b = beat({ id: 2, createdAt: "2026-09-21T10:00:00Z" });
|
|
||||||
const c = beat({ id: 3, createdAt: "2026-09-22T10:00:00Z", order: 1 });
|
|
||||||
|
|
||||||
const { beats, chapterById } = chronicleDisplayOrder([a, b, c]);
|
|
||||||
|
|
||||||
expect(beats.map((beat) => beat.id)).toEqual([3, 1, 2]);
|
|
||||||
expect(chapterById.get(3)).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("bookLayout (Book view paging)", () => {
|
|
||||||
it("counts one page when content fits the viewport", () => {
|
|
||||||
// One 2-up page: scrollWidth = column width = (1200 - 32) / 2 = 584.
|
|
||||||
expect(bookPageCount(584, 1200, 2)).toBe(1);
|
|
||||||
// scrollWidth == viewport width means both visible pages are exactly full: 2 pages.
|
|
||||||
expect(bookPageCount(1200, 1200, 2)).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("counts pages from the measured multicol scrollWidth", () => {
|
|
||||||
// 3 pages at 2-up: scrollWidth = 3 * pitch - gap = 3 * 616 - 32 = 1816.
|
|
||||||
expect(bookPageCount(1816, 1200, 2)).toBe(3);
|
|
||||||
// A lone right page (2.5 pages of content) still counts as its own page.
|
|
||||||
expect(bookPageCount(1816 + 300, 1200, 2)).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pays the gap once per page in single-column mode", () => {
|
|
||||||
// 2 pages at 1-up, viewport 700: scrollWidth = 2 * 732 - 32 = 1432.
|
|
||||||
expect(bookPageCount(1432, 700, 1)).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("computes the flip offset as pageIndex * pitch", () => {
|
|
||||||
expect(bookColumnOffset(0, 1200, 2)).toBe(0);
|
|
||||||
expect(bookColumnOffset(2, 1200, 2)).toBe(1232);
|
|
||||||
expect(bookColumnOffset(1, 700, 1)).toBe(732);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue