diff --git a/src/app/(frontend)/story/page.tsx b/src/app/(frontend)/story/page.tsx index 827d777..b694391 100644 --- a/src/app/(frontend)/story/page.tsx +++ b/src/app/(frontend)/story/page.tsx @@ -9,8 +9,10 @@ import { BEAT_BODY_CLASSES } from "@/lib/story/richtextClasses"; import { BeatReactions } from "@/components/frontend/story/BeatReactions"; import { BeatReadMarker } from "@/components/frontend/story/BeatReadMarker"; import { BeatVoiceover } from "@/components/frontend/story/BeatVoiceover"; +import { BookView } from "@/components/frontend/story/BookView"; import { StoryViewer, type StoryViewerBeat } from "@/components/frontend/story/StoryViewer"; import { StoryTableOfContents } from "@/components/frontend/story/StoryTableOfContents"; +import { chronicleDisplayOrder } from "@/lib/story/sortBeats"; import type { StoryReaction } from "@/lib/story/reactions"; export const metadata = { @@ -29,18 +31,6 @@ function emptyCounts(): ReactionCounts { 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 { return beat.voiceover && typeof beat.voiceover !== "number" ? beat.voiceover : null; } @@ -61,7 +51,9 @@ export default async function StoryPage() { overrideAccess: false, user, }); - const sorted = sortBeats(beatsResult.docs as StoryBeat[]); + const { beats: displayBeats, chapterById } = chronicleDisplayOrder( + beatsResult.docs as StoryBeat[], + ); const statesResult = await payload.find({ collection: "story-beat-states", @@ -77,7 +69,7 @@ export default async function StoryPage() { stateByBeat.set(beatId, state); } - const beatIds = sorted.map((beat) => beat.id); + const beatIds = displayBeats.map((beat) => beat.id); const counts = new Map(); for (const id of beatIds) counts.set(id, emptyCounts()); if (beatIds.length > 0) { @@ -97,10 +89,10 @@ export default async function StoryPage() { } } - const readerBeats: StoryViewerBeat[] = sorted.map((beat, index) => ({ + const readerBeats: StoryViewerBeat[] = displayBeats.map((beat) => ({ id: beat.id, title: beat.title, - chapter: index + 1, + chapter: chapterById.get(beat.id) ?? 0, dateLabel: beat.occurredAt ? DATE_FORMAT.format(new Date(beat.occurredAt)) : null, body: beat.body, initialRead: Boolean(stateByBeat.get(beat.id)?.readAt), @@ -120,20 +112,21 @@ export default async function StoryPage() {

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

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

No published chapters yet. Check back soon.

) : (
    - {sorted.map((beat, index) => { + {displayBeats.map((beat) => { const state = stateByBeat.get(beat.id); const mission = beat.mission && typeof beat.mission !== "number" ? beat.mission : null; @@ -154,7 +147,7 @@ export default async function StoryPage() {

    - Chapter {index + 1} + Chapter {chapterById.get(beat.id) ?? 0}

    @@ -193,7 +186,7 @@ export default async function StoryPage() { )}
- {sorted.length > 0 && ( + {displayBeats.length > 0 && ( diff --git a/src/collections/story/StoryBeats.ts b/src/collections/story/StoryBeats.ts index 61fa686..0709651 100644 --- a/src/collections/story/StoryBeats.ts +++ b/src/collections/story/StoryBeats.ts @@ -38,14 +38,16 @@ export const StoryBeats: CollectionConfig = { name: "occurredAt", type: "date", admin: { - description: "In-story timestamp used to order the chronicle.", + description: + "In-story timestamp shown with the chapter on /story. Does not affect chapter order.", }, }, { name: "order", type: "number", admin: { - description: "Tie-breaker for beats that share the same timestamp.", + description: + "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.", }, }, { diff --git a/src/lib/story/sortBeats.ts b/src/lib/story/sortBeats.ts new file mode 100644 index 0000000..5a72aa8 --- /dev/null +++ b/src/lib/story/sortBeats.ts @@ -0,0 +1,54 @@ +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; +} { + const sorted = sortBeats(beats); + const chapterById = new Map(sorted.map((beat, index) => [beat.id, index + 1])); + const curated = sorted.some((beat) => (beat.order ?? null) !== null); + return { beats: curated ? sorted : [...sorted].reverse(), chapterById }; +} diff --git a/src/payload-types.ts b/src/payload-types.ts index a9b67fb..0a0595c 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -4278,11 +4278,11 @@ export interface StoryBeat { id: number; title: string; /** - * In-story timestamp used to order the chronicle. + * In-story timestamp shown with the chapter on /story. Does not affect chapter order. */ occurredAt?: string | null; /** - * 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. */ order?: number | null; status: 'draft' | 'published';