feat(story): add Chronicle story beats system with reader states and reactions
This commit is contained in:
parent
bf5c53249a
commit
caca11a60b
24 changed files with 48753 additions and 2 deletions
50
src/app/(frontend)/story/actions.ts
Normal file
50
src/app/(frontend)/story/actions.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import type { StoryBeatState } from "@/payload-types";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { markBeatRead, setBeatReaction, type StoryReaction } from "@/lib/story";
|
||||
|
||||
async function authenticate() {
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
const { headers } = await import("next/headers");
|
||||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||||
if (!isPayloadUser(user)) throw new Error("Unauthorized");
|
||||
return { payload, user };
|
||||
}
|
||||
|
||||
export async function markStoryBeatRead(
|
||||
beatId: number,
|
||||
): Promise<{ success: boolean; error?: string; data?: StoryBeatState }> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const state = await markBeatRead(payload, { user, beatId });
|
||||
return { success: true, data: state };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
if (message === "Unauthorized") {
|
||||
return { success: false, error: "You must be logged in." };
|
||||
}
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function setStoryBeatReaction(
|
||||
beatId: number,
|
||||
reaction: StoryReaction | null,
|
||||
): Promise<{ success: boolean; error?: string; data?: { reaction: StoryReaction | null } }> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const result = await setBeatReaction(payload, { user, beatId, reaction });
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
if (message === "Unauthorized") {
|
||||
return { success: false, error: "You must be logged in." };
|
||||
}
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
173
src/app/(frontend)/story/page.tsx
Normal file
173
src/app/(frontend)/story/page.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,26 @@ import { default as default_4b88a624e9f0b376cffcc6814d459adb } from '@/component
|
|||
import { default as default_0edde40a6ccd30c893b1227360f69360 } from '@/components/static/TickDurationDescription'
|
||||
import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/components/static/WarningMessage'
|
||||
import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor'
|
||||
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SelectField as SelectField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
||||
import { PromptEditorField as PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
||||
import { default as default_b16af798585cda7cfed92f7b1dd0c707 } from '@/components/admin/shims/ShimContentField'
|
||||
|
|
@ -39,6 +59,26 @@ export const importMap = {
|
|||
"@/components/static/TickDurationDescription#default": default_0edde40a6ccd30c893b1227360f69360,
|
||||
"@/components/static/WarningMessage#default": default_2d468cc5cf0b647ffca3201592c20825,
|
||||
"@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759,
|
||||
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@ai-stack/payloadcms/client#SelectField": SelectField_4490b89d4413c1ffaecdacfe72efaf73,
|
||||
"@ai-stack/payloadcms/client#PromptEditorField": PromptEditorField_4490b89d4413c1ffaecdacfe72efaf73,
|
||||
"@/components/admin/shims/ShimContentField#default": default_b16af798585cda7cfed92f7b1dd0c707,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ const TARGET_COLLECTIONS = [
|
|||
{ label: "Assignment Transfers", value: "assignment-transfers" },
|
||||
{ label: "Voice Scripts", value: "voice-scripts" },
|
||||
{ label: "Voice Submissions", value: "voice-submissions" },
|
||||
{ label: "Story Beats", value: "story-beats" },
|
||||
{ label: "Story Beat States", value: "story-beat-states" },
|
||||
] as const;
|
||||
|
||||
export const GameEventLogs: CollectionConfig = {
|
||||
|
|
|
|||
73
src/collections/story/StoryBeatStates.ts
Normal file
73
src/collections/story/StoryBeatStates.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { CollectionConfig } from "payload";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
|
||||
function isStaff(user: Parameters<typeof hasRoles>[1]): boolean {
|
||||
return hasRoles(["admin", "developer"], user);
|
||||
}
|
||||
|
||||
const STAFF_ROLES = ["admin", "developer"];
|
||||
|
||||
export const StoryBeatStates: CollectionConfig = {
|
||||
slug: "story-beat-states",
|
||||
admin: {
|
||||
group: "Story",
|
||||
useAsTitle: "beat",
|
||||
defaultColumns: ["user", "beat", "readAt", "reaction"],
|
||||
description: "Per-user read markers and reactions for story beats.",
|
||||
// The admin-panel `user` is a client-side user type, so check its roles
|
||||
// structurally rather than through hasRoles.
|
||||
hidden: ({ user }) =>
|
||||
user?.collection !== "users" ||
|
||||
!STAFF_ROLES.some((role) => (user.roles ?? []).includes(role)),
|
||||
},
|
||||
access: {
|
||||
// Users can read/update only their own state; staff see everything.
|
||||
read: ({ req }) => {
|
||||
if (!req.user) return false;
|
||||
if (isStaff(req.user)) return true;
|
||||
return { user: { equals: req.user.id } };
|
||||
},
|
||||
create: ({ req }) => !!req.user,
|
||||
update: ({ req }) => {
|
||||
if (!req.user) return false;
|
||||
if (isStaff(req.user)) return true;
|
||||
return { user: { equals: req.user.id } };
|
||||
},
|
||||
delete: ({ req }) => isStaff(req.user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "user",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "beat",
|
||||
type: "relationship",
|
||||
relationTo: "story-beats",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "readAt",
|
||||
type: "date",
|
||||
admin: {
|
||||
description: "When the user first read the beat.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reaction",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Like", value: "like" },
|
||||
{ label: "Heart", value: "heart" },
|
||||
{ label: "Salute", value: "salute" },
|
||||
],
|
||||
admin: {
|
||||
description: "The user's current reaction to the beat, if any.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
82
src/collections/story/StoryBeats.ts
Normal file
82
src/collections/story/StoryBeats.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import type { CollectionConfig } from "payload";
|
||||
import { FixedToolbarFeature, lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
|
||||
function isStoryAuthor(user: Parameters<typeof hasRoles>[1]): boolean {
|
||||
return hasRoles(["admin", "developer"], user);
|
||||
}
|
||||
|
||||
export const StoryBeats: CollectionConfig = {
|
||||
slug: "story-beats",
|
||||
admin: {
|
||||
group: "Story",
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "status", "occurredAt", "order", "mission"],
|
||||
listSearchableFields: ["title", "body"],
|
||||
description:
|
||||
"Chronicle story beats. Drafts are visible to staff only; published beats are readable by any logged-in user on /story.",
|
||||
},
|
||||
access: {
|
||||
// Guests see nothing; admin/developer see everything; any other logged-in
|
||||
// user sees only published beats.
|
||||
read: ({ req }) => {
|
||||
if (!req.user) return false;
|
||||
if (isStoryAuthor(req.user)) return true;
|
||||
return { status: { equals: "published" } };
|
||||
},
|
||||
create: ({ req }) => isStoryAuthor(req.user),
|
||||
update: ({ req }) => isStoryAuthor(req.user),
|
||||
delete: ({ req }) => isStoryAuthor(req.user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "occurredAt",
|
||||
type: "date",
|
||||
admin: {
|
||||
description: "In-story timestamp used to order the chronicle.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "order",
|
||||
type: "number",
|
||||
admin: {
|
||||
description: "Tie-breaker for beats that share the same timestamp.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "draft",
|
||||
index: true,
|
||||
options: [
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Published", value: "published" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "body",
|
||||
type: "richText",
|
||||
required: true,
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [...defaultFeatures, FixedToolbarFeature()],
|
||||
}),
|
||||
admin: {
|
||||
description: "Rich text body of the beat, rendered on /story with the Lexical renderer.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mission",
|
||||
type: "relationship",
|
||||
relationTo: "missions",
|
||||
admin: {
|
||||
description: "Optional mission this beat is tied to. Beats may stand alone.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -155,6 +155,7 @@ export function AppSidebar({
|
|||
{hasLogistics && (
|
||||
<NavCore label="Infrastructure" items={data.navLogistics} />
|
||||
)}
|
||||
<NavCore label="Chronicle" items={data.navStory} />
|
||||
<NavCore label="Helpdesk" items={data.navHelpdesk} />
|
||||
<NavCore label="Commerce" items={data.navCommerce} />
|
||||
<NavCore label="Personal" items={data.navPersonal} />
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Kanban,
|
||||
LifeBuoy,
|
||||
Package,
|
||||
ScrollText,
|
||||
Search,
|
||||
Send,
|
||||
ShoppingBag,
|
||||
|
|
@ -149,6 +150,13 @@ export const sidebarData = {
|
|||
icon: UserCog,
|
||||
},
|
||||
],
|
||||
navStory: [
|
||||
{
|
||||
title: "Chronicle",
|
||||
url: "/story",
|
||||
icon: ScrollText,
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: "Feedback",
|
||||
|
|
|
|||
87
src/components/frontend/story/BeatReactions.tsx
Normal file
87
src/components/frontend/story/BeatReactions.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Flag, Heart, ThumbsUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { setStoryBeatReaction } from "@/app/(frontend)/story/actions";
|
||||
import {
|
||||
REACTION_LABELS,
|
||||
STORY_REACTIONS,
|
||||
type StoryReaction,
|
||||
} from "@/lib/story/reactions";
|
||||
|
||||
const REACTION_ICONS: Record<StoryReaction, typeof ThumbsUp> = {
|
||||
like: ThumbsUp,
|
||||
heart: Heart,
|
||||
salute: Flag,
|
||||
};
|
||||
|
||||
type ReactionCounts = Record<StoryReaction, number>;
|
||||
|
||||
export function BeatReactions({
|
||||
beatId,
|
||||
initialReaction,
|
||||
initialCounts,
|
||||
}: {
|
||||
beatId: number;
|
||||
initialReaction: StoryReaction | null;
|
||||
initialCounts: ReactionCounts;
|
||||
}) {
|
||||
const [current, setCurrent] = useState<StoryReaction | null>(initialReaction);
|
||||
const [counts, setCounts] = useState<ReactionCounts>(initialCounts);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function toggle(reaction: StoryReaction) {
|
||||
if (pending) return;
|
||||
const next: StoryReaction | null = current === reaction ? null : reaction;
|
||||
const previous = current;
|
||||
startTransition(async () => {
|
||||
setCounts((prev) => {
|
||||
const nextCounts = { ...prev };
|
||||
if (previous) nextCounts[previous] = Math.max(0, nextCounts[previous] - 1);
|
||||
if (next) nextCounts[next] = nextCounts[next] + 1;
|
||||
return nextCounts;
|
||||
});
|
||||
setCurrent(next);
|
||||
const result = await setStoryBeatReaction(beatId, next);
|
||||
if (!result.success || !result.data) {
|
||||
setCounts((prev) => {
|
||||
const reverted = { ...prev };
|
||||
if (next) reverted[next] = Math.max(0, reverted[next] - 1);
|
||||
if (previous) reverted[previous] = reverted[previous] + 1;
|
||||
return reverted;
|
||||
});
|
||||
setCurrent(previous);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2">
|
||||
{STORY_REACTIONS.map((reaction) => {
|
||||
const Icon = REACTION_ICONS[reaction];
|
||||
const active = current === reaction;
|
||||
return (
|
||||
<Button
|
||||
key={reaction}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
aria-pressed={active}
|
||||
aria-label={`${REACTION_LABELS[reaction]} (${counts[reaction]})`}
|
||||
onClick={() => toggle(reaction)}
|
||||
className={cn(
|
||||
"h-8 gap-1.5 text-xs",
|
||||
active && "border-primary bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
<span>{counts[reaction]}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/components/frontend/story/BeatReadMarker.tsx
Normal file
58
src/components/frontend/story/BeatReadMarker.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { markStoryBeatRead } from "@/app/(frontend)/story/actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function BeatReadMarker({
|
||||
beatId,
|
||||
initialRead,
|
||||
}: {
|
||||
beatId: number;
|
||||
initialRead: boolean;
|
||||
}) {
|
||||
const [read, setRead] = useState(initialRead);
|
||||
const markerRef = useRef<HTMLDivElement>(null);
|
||||
const markedRef = useRef(initialRead);
|
||||
|
||||
useEffect(() => {
|
||||
if (markedRef.current) return;
|
||||
const el = markerRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting || markedRef.current) continue;
|
||||
markedRef.current = true;
|
||||
void markStoryBeatRead(beatId).then((result) => {
|
||||
if (result.success) {
|
||||
setRead(true);
|
||||
observer.disconnect();
|
||||
} else {
|
||||
markedRef.current = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [beatId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={markerRef}
|
||||
className="flex items-center gap-1.5"
|
||||
aria-label={read ? "Read" : "Unread"}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full",
|
||||
read ? "bg-emerald-500" : "animate-pulse bg-amber-500",
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{read ? "Read" : "Unread"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
src/components/frontend/story/StoryViewer.tsx
Normal file
116
src/components/frontend/story/StoryViewer.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
src/lib/story/index.ts
Normal file
130
src/lib/story/index.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import type { Payload } from "payload";
|
||||
import type { StoryBeatState, User } from "@/payload-types";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { STORY_REACTIONS, type StoryReaction } from "./reactions";
|
||||
|
||||
export { STORY_REACTIONS, REACTION_LABELS, type StoryReaction } from "./reactions";
|
||||
|
||||
async function findBeatState(
|
||||
payload: Payload,
|
||||
userId: number,
|
||||
beatId: number,
|
||||
): Promise<StoryBeatState | null> {
|
||||
const result = await payload.find({
|
||||
collection: "story-beat-states",
|
||||
where: {
|
||||
and: [{ user: { equals: userId } }, { beat: { equals: beatId } }],
|
||||
},
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return (result.docs[0] as StoryBeatState | undefined) ?? null;
|
||||
}
|
||||
|
||||
async function findOrCreateBeatState(
|
||||
payload: Payload,
|
||||
userId: number,
|
||||
beatId: number,
|
||||
): Promise<StoryBeatState> {
|
||||
const existing = await findBeatState(payload, userId, beatId);
|
||||
if (existing) return existing;
|
||||
const created = await payload.create({
|
||||
collection: "story-beat-states",
|
||||
data: {
|
||||
user: userId,
|
||||
beat: beatId,
|
||||
readAt: null,
|
||||
reaction: null,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
return created as StoryBeatState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a story beat as read for the given user. The beat's read access gate
|
||||
* (published only, staff see all) is enforced via overrideAccess: false, so
|
||||
* marking a draft read as a normal user throws.
|
||||
*
|
||||
* Idempotent: a second call returns the existing state without changing readAt.
|
||||
*/
|
||||
export async function markBeatRead(
|
||||
payload: Payload,
|
||||
{ user, beatId }: { user: User; beatId: number },
|
||||
): Promise<StoryBeatState> {
|
||||
const beat = await payload.findByID({
|
||||
collection: "story-beats",
|
||||
id: beatId,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
depth: 0,
|
||||
});
|
||||
const state = await findOrCreateBeatState(payload, user.id, beatId);
|
||||
if (state.readAt) return state;
|
||||
const readAt = new Date().toISOString();
|
||||
const updated = await payload.update({
|
||||
collection: "story-beat-states",
|
||||
id: state.id,
|
||||
data: { readAt },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
emitGameEvent(payload, {
|
||||
type: EventTypes.StoryBeatRead,
|
||||
message: `${user.displayName || user.username} read the story beat "${beat.title}"`,
|
||||
actor: user.id,
|
||||
targetCollection: "story-beats",
|
||||
targetId: beat.id,
|
||||
data: { beatId: beat.id },
|
||||
});
|
||||
return updated as StoryBeatState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or toggle off) the user's reaction on a story beat. Passing the user's
|
||||
* current reaction clears it; passing a different one replaces it.
|
||||
*
|
||||
* The beat's read access gate is enforced via overrideAccess: false.
|
||||
*/
|
||||
export async function setBeatReaction(
|
||||
payload: Payload,
|
||||
{
|
||||
user,
|
||||
beatId,
|
||||
reaction,
|
||||
}: { user: User; beatId: number; reaction: StoryReaction | null },
|
||||
): Promise<{ reaction: StoryReaction | null }> {
|
||||
if (reaction !== null && !STORY_REACTIONS.includes(reaction)) {
|
||||
throw new Error("Invalid reaction.");
|
||||
}
|
||||
const beat = await payload.findByID({
|
||||
collection: "story-beats",
|
||||
id: beatId,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
depth: 0,
|
||||
});
|
||||
const state = await findOrCreateBeatState(payload, user.id, beatId);
|
||||
const next: StoryReaction | null = state.reaction === reaction ? null : reaction;
|
||||
await payload.update({
|
||||
collection: "story-beat-states",
|
||||
id: state.id,
|
||||
data: { reaction: next },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
if (next !== null) {
|
||||
emitGameEvent(payload, {
|
||||
type: EventTypes.StoryBeatReact,
|
||||
message: `${user.displayName || user.username} reacted ${next} to the story beat "${beat.title}"`,
|
||||
actor: user.id,
|
||||
targetCollection: "story-beats",
|
||||
targetId: beat.id,
|
||||
data: { beatId: beat.id, reaction: next },
|
||||
});
|
||||
}
|
||||
return { reaction: next };
|
||||
}
|
||||
9
src/lib/story/reactions.ts
Normal file
9
src/lib/story/reactions.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const STORY_REACTIONS = ["like", "heart", "salute"] as const;
|
||||
|
||||
export type StoryReaction = (typeof STORY_REACTIONS)[number];
|
||||
|
||||
export const REACTION_LABELS: Record<StoryReaction, string> = {
|
||||
like: "Like",
|
||||
heart: "Heart",
|
||||
salute: "Salute",
|
||||
};
|
||||
44
src/lib/story/richtextClasses.ts
Normal file
44
src/lib/story/richtextClasses.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export const BEAT_BODY_CLASSES = [
|
||||
"max-w-none",
|
||||
"[&_h1]:mt-6 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold",
|
||||
"[&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:text-lg [&_h2]:font-semibold",
|
||||
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold",
|
||||
"[&_h4]:mt-4 [&_h4]:mb-2 [&_h4]:text-sm [&_h4]:font-semibold",
|
||||
"[&_p]:mb-4 [&_p]:leading-7",
|
||||
"[&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6",
|
||||
"[&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6",
|
||||
"[&_li]:mb-1",
|
||||
"[&_blockquote]:mb-4 [&_blockquote]:border-l-2 [&_blockquote]:border-primary/50 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground",
|
||||
"[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4",
|
||||
"[&_strong]:font-semibold",
|
||||
"[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-sm",
|
||||
"[&_pre]:mb-4 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-muted [&_pre]:p-4 [&_pre]:text-sm",
|
||||
"[&_hr]:my-6 [&_hr]:border-border",
|
||||
"[&_img]:my-4 [&_img]:rounded",
|
||||
"[&_table]:mb-4 [&_table]:w-full [&_table]:text-sm",
|
||||
"[&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left",
|
||||
"[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1",
|
||||
] as const;
|
||||
|
||||
export const READER_BODY_CLASSES = [
|
||||
"text-neutral-200",
|
||||
"max-w-none",
|
||||
"[&_h1]:mt-6 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:text-white",
|
||||
"[&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-white",
|
||||
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-white",
|
||||
"[&_h4]:mt-4 [&_h4]:mb-2 [&_h4]:text-sm [&_h4]:font-semibold [&_h4]:text-neutral-100",
|
||||
"[&_p]:mb-4 [&_p]:leading-8",
|
||||
"[&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6",
|
||||
"[&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6",
|
||||
"[&_li]:mb-1 [&_li]:marker:text-neutral-500",
|
||||
"[&_blockquote]:mb-4 [&_blockquote]:border-l-2 [&_blockquote]:border-white/30 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-neutral-300",
|
||||
"[&_a]:text-white [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-neutral-300",
|
||||
"[&_strong]:font-semibold [&_strong]:text-white",
|
||||
"[&_code]:rounded [&_code]:bg-neutral-900 [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-sm [&_code]:text-neutral-100",
|
||||
"[&_pre]:mb-4 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-neutral-900 [&_pre]:p-4 [&_pre]:text-sm [&_pre]:text-neutral-100",
|
||||
"[&_hr]:my-6 [&_hr]:border-white/20",
|
||||
"[&_img]:my-4 [&_img]:rounded",
|
||||
"[&_table]:mb-4 [&_table]:w-full [&_table]:text-sm",
|
||||
"[&_th]:border [&_th]:border-white/20 [&_th]:bg-neutral-900 [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:text-white",
|
||||
"[&_td]:border [&_td]:border-white/20 [&_td]:px-2 [&_td]:py-1",
|
||||
] as const;
|
||||
23624
src/migrations/20260907_120754_add_story_beats.json
Normal file
23624
src/migrations/20260907_120754_add_story_beats.json
Normal file
File diff suppressed because it is too large
Load diff
69
src/migrations/20260907_120754_add_story_beats.ts
Normal file
69
src/migrations/20260907_120754_add_story_beats.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_story_beats_status" AS ENUM('draft', 'published');
|
||||
CREATE TYPE "public"."enum_story_beat_states_reaction" AS ENUM('like', 'heart', 'salute');
|
||||
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'story-beats';
|
||||
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'story-beat-states';
|
||||
CREATE TABLE "story_beats" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar NOT NULL,
|
||||
"occurred_at" timestamp(3) with time zone,
|
||||
"order" numeric,
|
||||
"status" "enum_story_beats_status" DEFAULT 'draft' NOT NULL,
|
||||
"body" varchar NOT NULL,
|
||||
"mission_id" integer,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "story_beat_states" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"beat_id" integer NOT NULL,
|
||||
"read_at" timestamp(3) with time zone,
|
||||
"reaction" "enum_story_beat_states_reaction",
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "story_beats_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "story_beat_states_id" integer;
|
||||
ALTER TABLE "story_beats" ADD CONSTRAINT "story_beats_mission_id_missions_id_fk" FOREIGN KEY ("mission_id") REFERENCES "public"."missions"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "story_beat_states" ADD CONSTRAINT "story_beat_states_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "story_beat_states" ADD CONSTRAINT "story_beat_states_beat_id_story_beats_id_fk" FOREIGN KEY ("beat_id") REFERENCES "public"."story_beats"("id") ON DELETE set null ON UPDATE no action;
|
||||
CREATE INDEX "story_beats_mission_idx" ON "story_beats" USING btree ("mission_id");
|
||||
CREATE INDEX "story_beats_updated_at_idx" ON "story_beats" USING btree ("updated_at");
|
||||
CREATE INDEX "story_beats_created_at_idx" ON "story_beats" USING btree ("created_at");
|
||||
CREATE INDEX "story_beat_states_user_idx" ON "story_beat_states" USING btree ("user_id");
|
||||
CREATE INDEX "story_beat_states_beat_idx" ON "story_beat_states" USING btree ("beat_id");
|
||||
CREATE INDEX "story_beat_states_updated_at_idx" ON "story_beat_states" USING btree ("updated_at");
|
||||
CREATE INDEX "story_beat_states_created_at_idx" ON "story_beat_states" USING btree ("created_at");
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_story_beats_fk" FOREIGN KEY ("story_beats_id") REFERENCES "public"."story_beats"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_story_beat_states_fk" FOREIGN KEY ("story_beat_states_id") REFERENCES "public"."story_beat_states"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "payload_locked_documents_rels_story_beats_id_idx" ON "payload_locked_documents_rels" USING btree ("story_beats_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_story_beat_states_id_idx" ON "payload_locked_documents_rels" USING btree ("story_beat_states_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "story_beats" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "story_beat_states" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "story_beats" CASCADE;
|
||||
DROP TABLE "story_beat_states" CASCADE;
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_story_beats_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_story_beat_states_fk";
|
||||
|
||||
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE text;
|
||||
DROP TYPE "public"."enum_game_event_logs_target_collection";
|
||||
CREATE TYPE "public"."enum_game_event_logs_target_collection" AS ENUM('game-structures', 'game-vehicles', 'game-npcs', 'npc-staffing', 'structures', 'resources', 'assets', 'vehicles', 'factions', 'missions', 'campaigns', 'users', 'technologies', 'maps', 'shipments', 'bank-accounts', 'bank-transactions', 'ledger-entries', 'locker-storages', 'loadouts', 'market-listings', 'market-negotiations', 'market-state', 'evaluations', 'tickets', 'wiki-pages', 'wiki-revisions', 'wiki-templates', 'game-servers', 'assignment-transfers', 'voice-scripts', 'voice-submissions');
|
||||
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE "public"."enum_game_event_logs_target_collection" USING "target_collection"::"public"."enum_game_event_logs_target_collection";
|
||||
DROP INDEX "payload_locked_documents_rels_story_beats_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_story_beat_states_id_idx";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "story_beats_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "story_beat_states_id";
|
||||
DROP TYPE "public"."enum_story_beats_status";
|
||||
DROP TYPE "public"."enum_story_beat_states_reaction";`)
|
||||
}
|
||||
23639
src/migrations/20260907_124024_convert_story_beats_body_to_lexical.json
Normal file
23639
src/migrations/20260907_124024_convert_story_beats_body_to_lexical.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,93 @@
|
|||
import {
|
||||
convertLexicalToMarkdown,
|
||||
convertMarkdownToLexical,
|
||||
sanitizeServerEditorConfig,
|
||||
} from "@payloadcms/richtext-lexical";
|
||||
import type {
|
||||
FeatureProviderServer,
|
||||
SanitizedServerEditorConfig,
|
||||
ServerEditorConfig,
|
||||
} from "@payloadcms/richtext-lexical";
|
||||
import { MigrateDownArgs, MigrateUpArgs, sql } from "@payloadcms/db-postgres";
|
||||
|
||||
type RichTextFieldLike = {
|
||||
editor?: {
|
||||
features?: FeatureProviderServer[];
|
||||
editorConfig?: { lexical?: ServerEditorConfig["lexical"] };
|
||||
};
|
||||
};
|
||||
|
||||
async function resolveEditorConfig(
|
||||
payload: MigrateUpArgs["payload"],
|
||||
): Promise<SanitizedServerEditorConfig> {
|
||||
const fields = payload.collections["story-beats"].config.fields;
|
||||
const bodyField = fields.find(
|
||||
(field) => "name" in field && (field as { name?: string }).name === "body",
|
||||
) as RichTextFieldLike | undefined;
|
||||
const editor = bodyField?.editor;
|
||||
const features = editor?.features;
|
||||
if (!editor || !features?.length) {
|
||||
throw new Error("story-beats body richText editor features not found in sanitized config");
|
||||
}
|
||||
return sanitizeServerEditorConfig(
|
||||
{ features, lexical: editor.editorConfig?.lexical },
|
||||
payload.config,
|
||||
);
|
||||
}
|
||||
|
||||
async function readBeatBodies(
|
||||
db: MigrateUpArgs["db"],
|
||||
): Promise<Array<{ id: number; raw: unknown }>> {
|
||||
const result = await db.execute(sql`SELECT "id", "body"::text AS body FROM "story_beats"`);
|
||||
const rows = result.rows as Array<{ id: number; body: string | null }>;
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
raw: row.body == null ? null : (JSON.parse(row.body) as unknown),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function up({ db, payload }: MigrateUpArgs): Promise<void> {
|
||||
// Stored markdown strings are not valid JSON, so wrap every value as a JSON
|
||||
// string scalar while switching the column type to jsonb.
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "story_beats" ALTER COLUMN "body" SET DATA TYPE jsonb USING to_jsonb("body"::text);
|
||||
CREATE INDEX "story_beats_status_idx" ON "story_beats" USING btree ("status");`);
|
||||
|
||||
const editorConfig = await resolveEditorConfig(payload);
|
||||
const beats = await readBeatBodies(db);
|
||||
|
||||
for (const beat of beats) {
|
||||
// Already a lexical editor state object; nothing to convert.
|
||||
if (beat.raw && typeof beat.raw === "object") continue;
|
||||
const markdown = typeof beat.raw === "string" ? beat.raw : "";
|
||||
const lexical = convertMarkdownToLexical({ editorConfig, markdown });
|
||||
await db.execute(
|
||||
sql`UPDATE "story_beats" SET "body" = ${JSON.stringify(lexical)}::jsonb WHERE "id" = ${beat.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function down({ db, payload }: MigrateDownArgs): Promise<void> {
|
||||
const editorConfig = await resolveEditorConfig(payload);
|
||||
const beats = await readBeatBodies(db);
|
||||
|
||||
for (const beat of beats) {
|
||||
let markdown = "";
|
||||
if (beat.raw && typeof beat.raw === "object") {
|
||||
markdown = convertLexicalToMarkdown({
|
||||
data: beat.raw as Parameters<typeof convertLexicalToMarkdown>[0]["data"],
|
||||
editorConfig,
|
||||
});
|
||||
} else if (typeof beat.raw === "string") {
|
||||
markdown = beat.raw;
|
||||
}
|
||||
// Store as a jsonb string scalar so the column type change below can extract it.
|
||||
await db.execute(
|
||||
sql`UPDATE "story_beats" SET "body" = ${JSON.stringify(markdown)}::jsonb WHERE "id" = ${beat.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
await db.execute(sql`
|
||||
DROP INDEX "story_beats_status_idx";
|
||||
ALTER TABLE "story_beats" ALTER COLUMN "body" SET DATA TYPE varchar USING "body" #>> '{}';`);
|
||||
}
|
||||
|
|
@ -42,6 +42,8 @@ import * as migration_20260907_043339_add_voice_script_description from './20260
|
|||
import * as migration_20260907_044934_add_missions_discord_modlist_hash from './20260907_044934_add_missions_discord_modlist_hash';
|
||||
import * as migration_20260907_083010_add_voice_submission_description from './20260907_083010_add_voice_submission_description';
|
||||
import * as migration_20260907_090245_add_mission_ready_reminder_fields from './20260907_090245_add_mission_ready_reminder_fields';
|
||||
import * as migration_20260907_120754_add_story_beats from './20260907_120754_add_story_beats';
|
||||
import * as migration_20260907_124024_convert_story_beats_body_to_lexical from './20260907_124024_convert_story_beats_body_to_lexical';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -262,6 +264,16 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260907_090245_add_mission_ready_reminder_fields.up,
|
||||
down: migration_20260907_090245_add_mission_ready_reminder_fields.down,
|
||||
name: '20260907_090245_add_mission_ready_reminder_fields'
|
||||
name: '20260907_090245_add_mission_ready_reminder_fields',
|
||||
},
|
||||
{
|
||||
up: migration_20260907_120754_add_story_beats.up,
|
||||
down: migration_20260907_120754_add_story_beats.down,
|
||||
name: '20260907_120754_add_story_beats',
|
||||
},
|
||||
{
|
||||
up: migration_20260907_124024_convert_story_beats_body_to_lexical.up,
|
||||
down: migration_20260907_124024_convert_story_beats_body_to_lexical.down,
|
||||
name: '20260907_124024_convert_story_beats_body_to_lexical'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -118,6 +118,8 @@ export interface Config {
|
|||
labels: Label;
|
||||
maps: Map;
|
||||
'narrative-events': NarrativeEvent;
|
||||
'story-beats': StoryBeat;
|
||||
'story-beat-states': StoryBeatState;
|
||||
'mission-files': MissionFile;
|
||||
'mod-lists': ModList;
|
||||
'game-servers': GameServer;
|
||||
|
|
@ -189,6 +191,8 @@ export interface Config {
|
|||
labels: LabelsSelect<false> | LabelsSelect<true>;
|
||||
maps: MapsSelect<false> | MapsSelect<true>;
|
||||
'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>;
|
||||
'story-beats': StoryBeatsSelect<false> | StoryBeatsSelect<true>;
|
||||
'story-beat-states': StoryBeatStatesSelect<false> | StoryBeatStatesSelect<true>;
|
||||
'mission-files': MissionFilesSelect<false> | MissionFilesSelect<true>;
|
||||
'mod-lists': ModListsSelect<false> | ModListsSelect<true>;
|
||||
'game-servers': GameServersSelect<false> | GameServersSelect<true>;
|
||||
|
|
@ -1882,6 +1886,8 @@ export interface GameEventLog {
|
|||
| 'assignment-transfers'
|
||||
| 'voice-scripts'
|
||||
| 'voice-submissions'
|
||||
| 'story-beats'
|
||||
| 'story-beat-states'
|
||||
)
|
||||
| null;
|
||||
targetId?: number | null;
|
||||
|
|
@ -3535,6 +3541,70 @@ export interface NarrativeEvent {
|
|||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Chronicle story beats. Drafts are visible to staff only; published beats are readable by any logged-in user on /story.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "story-beats".
|
||||
*/
|
||||
export interface StoryBeat {
|
||||
id: number;
|
||||
title: string;
|
||||
/**
|
||||
* In-story timestamp used to order the chronicle.
|
||||
*/
|
||||
occurredAt?: string | null;
|
||||
/**
|
||||
* Tie-breaker for beats that share the same timestamp.
|
||||
*/
|
||||
order?: number | null;
|
||||
status: 'draft' | 'published';
|
||||
/**
|
||||
* Rich text body of the beat, rendered on /story with the Lexical renderer.
|
||||
*/
|
||||
body: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* Optional mission this beat is tied to. Beats may stand alone.
|
||||
*/
|
||||
mission?: (number | null) | Mission;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Per-user read markers and reactions for story beats.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "story-beat-states".
|
||||
*/
|
||||
export interface StoryBeatState {
|
||||
id: number;
|
||||
user: number | User;
|
||||
beat: number | StoryBeat;
|
||||
/**
|
||||
* When the user first read the beat.
|
||||
*/
|
||||
readAt?: string | null;
|
||||
/**
|
||||
* The user's current reaction to the beat, if any.
|
||||
*/
|
||||
reaction?: ('like' | 'heart' | 'salute') | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "game-servers".
|
||||
|
|
@ -3905,6 +3975,14 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'narrative-events';
|
||||
value: number | NarrativeEvent;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'story-beats';
|
||||
value: number | StoryBeat;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'story-beat-states';
|
||||
value: number | StoryBeatState;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'mission-files';
|
||||
value: number | MissionFile;
|
||||
|
|
@ -5446,6 +5524,32 @@ export interface NarrativeEventsSelect<T extends boolean = true> {
|
|||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "story-beats_select".
|
||||
*/
|
||||
export interface StoryBeatsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
occurredAt?: T;
|
||||
order?: T;
|
||||
status?: T;
|
||||
body?: T;
|
||||
mission?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "story-beat-states_select".
|
||||
*/
|
||||
export interface StoryBeatStatesSelect<T extends boolean = true> {
|
||||
user?: T;
|
||||
beat?: T;
|
||||
readAt?: T;
|
||||
reaction?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "mission-files_select".
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ import { Projects } from "@/collections/projects/Projects";
|
|||
import { Sprints } from "@/collections/projects/Sprints";
|
||||
import { Releases } from "@/collections/projects/Releases";
|
||||
import { Labels } from "@/collections/projects/Labels";
|
||||
import { StoryBeats } from "@/collections/story/StoryBeats";
|
||||
import { StoryBeatStates } from "@/collections/story/StoryBeatStates";
|
||||
import { migrations } from "./migrations";
|
||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||
import nodemailer from "nodemailer";
|
||||
|
|
@ -224,6 +226,10 @@ const collections = [
|
|||
Maps,
|
||||
NarrativeEvents,
|
||||
|
||||
// Story
|
||||
StoryBeats,
|
||||
StoryBeatStates,
|
||||
|
||||
// Server
|
||||
MissionFiles,
|
||||
ModLists,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ interface GameEventInput {
|
|||
| "assignment-transfers"
|
||||
| "market-state"
|
||||
| "voice-scripts"
|
||||
| "voice-submissions";
|
||||
| "voice-submissions"
|
||||
| "story-beats"
|
||||
| "story-beat-states";
|
||||
targetId?: number;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,6 +152,10 @@ export const EventTypes = {
|
|||
WikiPageUnlock: "wiki:page-unlock",
|
||||
WikiPageDelete: "wiki:page-delete",
|
||||
|
||||
// Story
|
||||
StoryBeatRead: "story:beat-read",
|
||||
StoryBeatReact: "story:beat-react",
|
||||
|
||||
// Projects
|
||||
ProjectCreate: "project:create",
|
||||
ProjectUpdate: "project:update",
|
||||
|
|
|
|||
325
tests/int/story-beats.int.spec.ts
Normal file
325
tests/int/story-beats.int.spec.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { StoryBeat, StoryBeatState, User } from "@/payload-types";
|
||||
import { markBeatRead, setBeatReaction } from "@/lib/story";
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
const RUN = `story-${Date.now().toString(36)}`;
|
||||
|
||||
function lexicalBody(text: string): StoryBeat["body"] {
|
||||
return {
|
||||
root: {
|
||||
type: "root",
|
||||
format: "",
|
||||
indent: 0,
|
||||
version: 1,
|
||||
direction: null,
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
format: "",
|
||||
indent: 0,
|
||||
version: 1,
|
||||
direction: null,
|
||||
textFormat: 0,
|
||||
textStyle: "",
|
||||
children: [
|
||||
{ type: "text", format: 0, version: 1, mode: "normal", style: "", text, detail: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as StoryBeat["body"];
|
||||
}
|
||||
|
||||
describe("Story beats", () => {
|
||||
let admin: User;
|
||||
let adminId: number;
|
||||
let user: User;
|
||||
let userId: number;
|
||||
let otherUser: User;
|
||||
let otherUserId: number;
|
||||
const beatIds: number[] = [];
|
||||
const stateIds: number[] = [];
|
||||
let draftBeatId: number;
|
||||
let publishedBeatId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
|
||||
const createTestUser = async (name: string, roles: User["roles"]) => {
|
||||
return (await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username: `${RUN}-${name}`,
|
||||
discordUsername: `${RUN}-${name}`,
|
||||
displayName: name.toUpperCase(),
|
||||
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||||
password: "Test123",
|
||||
roles,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as User;
|
||||
};
|
||||
|
||||
admin = await createTestUser("admin", ["admin"]);
|
||||
adminId = admin.id;
|
||||
user = await createTestUser("user", ["user"]);
|
||||
userId = user.id;
|
||||
otherUser = await createTestUser("other", ["user"]);
|
||||
otherUserId = otherUser.id;
|
||||
|
||||
const draftBeat = (await payload.create({
|
||||
collection: "story-beats",
|
||||
data: {
|
||||
title: `${RUN} Draft beat`,
|
||||
status: "draft",
|
||||
body: lexicalBody("A draft beat that should never be public."),
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as StoryBeat;
|
||||
draftBeatId = draftBeat.id;
|
||||
beatIds.push(draftBeatId);
|
||||
|
||||
const publishedBeat = (await payload.create({
|
||||
collection: "story-beats",
|
||||
data: {
|
||||
title: `${RUN} Published beat`,
|
||||
status: "published",
|
||||
body: lexicalBody("A published beat body."),
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as StoryBeat;
|
||||
publishedBeatId = publishedBeat.id;
|
||||
beatIds.push(publishedBeatId);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of stateIds) {
|
||||
await payload
|
||||
.delete({ collection: "story-beat-states", id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
for (const id of beatIds) {
|
||||
await payload
|
||||
.delete({ collection: "story-beats", id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
for (const id of [adminId, userId, otherUserId]) {
|
||||
const accounts = await payload
|
||||
.find({
|
||||
collection: "bank-accounts",
|
||||
where: { ownerUser: { equals: id } },
|
||||
limit: 5,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const account of accounts?.docs ?? []) {
|
||||
await payload
|
||||
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
const profiles = await payload
|
||||
.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: id } },
|
||||
limit: 5,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const profile of profiles?.docs ?? []) {
|
||||
await payload
|
||||
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
await payload
|
||||
.delete({ collection: "users", id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
describe("visibility gate", () => {
|
||||
it("hides all beats from guests", async () => {
|
||||
await expect(
|
||||
payload.find({ collection: "story-beats", overrideAccess: false }),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
payload.findByID({
|
||||
collection: "story-beats",
|
||||
id: publishedBeatId,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("shows only published beats to normal users", async () => {
|
||||
const result = (await payload.find({
|
||||
collection: "story-beats",
|
||||
where: { title: { like: RUN } },
|
||||
overrideAccess: false,
|
||||
user,
|
||||
depth: 0,
|
||||
})) as unknown as { docs: StoryBeat[] };
|
||||
expect(result.docs).toHaveLength(1);
|
||||
expect(result.docs[0]?.id).toBe(publishedBeatId);
|
||||
expect(result.docs[0]?.status).toBe("published");
|
||||
|
||||
await expect(
|
||||
payload.findByID({
|
||||
collection: "story-beats",
|
||||
id: draftBeatId,
|
||||
overrideAccess: false,
|
||||
user,
|
||||
depth: 0,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("shows all beats to admin/developer authors", async () => {
|
||||
const result = (await payload.find({
|
||||
collection: "story-beats",
|
||||
where: { title: { like: RUN } },
|
||||
overrideAccess: false,
|
||||
user: admin,
|
||||
depth: 0,
|
||||
})) as unknown as { docs: StoryBeat[] };
|
||||
const ids = result.docs.map((beat) => beat.id).sort();
|
||||
expect(ids).toEqual([draftBeatId, publishedBeatId].sort());
|
||||
});
|
||||
|
||||
it("blocks normal users from creating beats", async () => {
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: "story-beats",
|
||||
data: { title: "nope", status: "draft", body: lexicalBody("nope") },
|
||||
overrideAccess: false,
|
||||
user,
|
||||
depth: 0,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("allows admin authors to create and publish beats", async () => {
|
||||
const created = (await payload.create({
|
||||
collection: "story-beats",
|
||||
data: { title: `${RUN} Authoring check`, status: "draft", body: lexicalBody("wip") },
|
||||
overrideAccess: false,
|
||||
user: admin,
|
||||
depth: 0,
|
||||
})) as unknown as StoryBeat;
|
||||
beatIds.push(created.id);
|
||||
const published = (await payload.update({
|
||||
collection: "story-beats",
|
||||
id: created.id,
|
||||
data: { status: "published" },
|
||||
overrideAccess: false,
|
||||
user: admin,
|
||||
depth: 0,
|
||||
})) as unknown as StoryBeat;
|
||||
expect(published.status).toBe("published");
|
||||
});
|
||||
});
|
||||
|
||||
describe("read state", () => {
|
||||
it("marks a published beat read for a normal user", async () => {
|
||||
const state = await markBeatRead(payload, { user, beatId: publishedBeatId });
|
||||
stateIds.push(state.id);
|
||||
expect(state.readAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("is idempotent: a second read does not change readAt", async () => {
|
||||
const first = await markBeatRead(payload, { user, beatId: publishedBeatId });
|
||||
const second = await markBeatRead(payload, { user, beatId: publishedBeatId });
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.readAt).toBe(first.readAt);
|
||||
});
|
||||
|
||||
it("refuses to mark a draft beat read for a normal user", async () => {
|
||||
await expect(
|
||||
markBeatRead(payload, { user, beatId: draftBeatId }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("scopes state docs to their owner", async () => {
|
||||
const result = (await payload.find({
|
||||
collection: "story-beat-states",
|
||||
where: { id: { in: stateIds } },
|
||||
overrideAccess: false,
|
||||
user: otherUser,
|
||||
depth: 0,
|
||||
})) as unknown as { docs: StoryBeatState[] };
|
||||
expect(result.docs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reactions", () => {
|
||||
it("sets and replaces reactions", async () => {
|
||||
const heart = await setBeatReaction(payload, {
|
||||
user,
|
||||
beatId: publishedBeatId,
|
||||
reaction: "heart",
|
||||
});
|
||||
expect(heart.reaction).toBe("heart");
|
||||
const salute = await setBeatReaction(payload, {
|
||||
user,
|
||||
beatId: publishedBeatId,
|
||||
reaction: "salute",
|
||||
});
|
||||
expect(salute.reaction).toBe("salute");
|
||||
});
|
||||
|
||||
it("toggles the same reaction off", async () => {
|
||||
const set = await setBeatReaction(payload, {
|
||||
user,
|
||||
beatId: publishedBeatId,
|
||||
reaction: "like",
|
||||
});
|
||||
expect(set.reaction).toBe("like");
|
||||
const cleared = await setBeatReaction(payload, {
|
||||
user,
|
||||
beatId: publishedBeatId,
|
||||
reaction: "like",
|
||||
});
|
||||
expect(cleared.reaction).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid reaction values", async () => {
|
||||
await expect(
|
||||
setBeatReaction(payload, {
|
||||
user,
|
||||
beatId: publishedBeatId,
|
||||
reaction: "fire" as never,
|
||||
}),
|
||||
).rejects.toThrow("Invalid reaction.");
|
||||
});
|
||||
|
||||
it("refuses reactions on draft beats for normal users", async () => {
|
||||
await expect(
|
||||
setBeatReaction(payload, { user, beatId: draftBeatId, reaction: "heart" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("persists reaction state on the user's beat-state doc", async () => {
|
||||
await setBeatReaction(payload, { user, beatId: publishedBeatId, reaction: "salute" });
|
||||
const result = (await payload.find({
|
||||
collection: "story-beat-states",
|
||||
where: { and: [{ user: { equals: userId } }, { beat: { equals: publishedBeatId } }] },
|
||||
overrideAccess: false,
|
||||
user,
|
||||
depth: 0,
|
||||
})) as unknown as { docs: StoryBeatState[] };
|
||||
expect(result.docs).toHaveLength(1);
|
||||
expect(result.docs[0]?.reaction).toBe("salute");
|
||||
expect(result.docs[0]?.readAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue