1
0
Fork 0
polaris-task-force/src/app/(frontend)/story/page.tsx

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>
);
}