173 lines
6.3 KiB
TypeScript
173 lines
6.3 KiB
TypeScript
import config from "@payload-config";
|
|
import { getPayload } from "payload";
|
|
import { headers as nextHeaders } from "next/headers";
|
|
import { RichText } from "@payloadcms/richtext-lexical/react";
|
|
import type { StoryBeat, StoryBeatState } from "@/payload-types";
|
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
|
import { cn } from "@/lib/utils";
|
|
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 type { StoryReaction } from "@/lib/story/reactions";
|
|
|
|
export const metadata = {
|
|
title: "Chronicle",
|
|
};
|
|
|
|
type ReactionCounts = Record<StoryReaction, number>;
|
|
|
|
const DATE_FORMAT = new Intl.DateTimeFormat("en-US", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
export default async function StoryPage() {
|
|
const payloadConfig = await config;
|
|
const payload = await getPayload({ config: payloadConfig });
|
|
const headers = await nextHeaders();
|
|
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
|
|
|
if (!isPayloadUser(user)) return null;
|
|
|
|
const beatsResult = await payload.find({
|
|
collection: "story-beats",
|
|
where: { status: { equals: "published" } },
|
|
limit: 200,
|
|
depth: 1,
|
|
overrideAccess: false,
|
|
user,
|
|
});
|
|
const sorted = sortBeats(beatsResult.docs as StoryBeat[]);
|
|
|
|
const statesResult = await payload.find({
|
|
collection: "story-beat-states",
|
|
where: { user: { equals: user.id } },
|
|
limit: 500,
|
|
depth: 0,
|
|
overrideAccess: false,
|
|
user,
|
|
});
|
|
const stateByBeat = new Map<number, StoryBeatState>();
|
|
for (const state of statesResult.docs as StoryBeatState[]) {
|
|
const beatId = typeof state.beat === "number" ? state.beat : state.beat.id;
|
|
stateByBeat.set(beatId, state);
|
|
}
|
|
|
|
const beatIds = sorted.map((beat) => beat.id);
|
|
const counts = new Map<number, ReactionCounts>();
|
|
for (const id of beatIds) counts.set(id, emptyCounts());
|
|
if (beatIds.length > 0) {
|
|
const reactionDocs = await payload.find({
|
|
collection: "story-beat-states",
|
|
where: {
|
|
and: [{ beat: { in: beatIds } }, { reaction: { not_equals: null } }],
|
|
},
|
|
limit: 1000,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
for (const doc of reactionDocs.docs as StoryBeatState[]) {
|
|
const beatId = typeof doc.beat === "number" ? doc.beat : doc.beat.id;
|
|
const beatCounts = counts.get(beatId);
|
|
if (doc.reaction && beatCounts) beatCounts[doc.reaction] += 1;
|
|
}
|
|
}
|
|
|
|
const readerBeats: StoryViewerBeat[] = sorted.map((beat, index) => ({
|
|
id: beat.id,
|
|
title: beat.title,
|
|
chapter: index + 1,
|
|
dateLabel: beat.occurredAt ? DATE_FORMAT.format(new Date(beat.occurredAt)) : null,
|
|
body: beat.body,
|
|
initialRead: Boolean(stateByBeat.get(beat.id)?.readAt),
|
|
}));
|
|
|
|
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>
|
|
|
|
{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>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|