1
0
Fork 0

Compare commits

...

3 commits

Author SHA1 Message Date
d00a5980a5 feat(story): add voiceover support for story beats
- Introduced voiceover relationship to StoryBeats with AI-generated flag.
- Added BeatVoiceover component for playing audio narrations with a minimal interface.
- Updated StoryPage, StoryViewer, and StoryTableOfContents to display and handle voiceovers.
- Created Voiceovers collection for uploading and managing audio narrations for beats.
- Included migration script to support voiceover functionality in the database.
2026-09-24 17:35:17 -04:00
2837518b3b fix(styles): improve text wrapping and layout for tooltips and captions
- Added `overflow-wrap: anywhere` to prevent text overflow in tooltips
- Adjusted tooltip width and wrapping for better responsiveness
2026-09-24 13:44:07 -04:00
1e1fc33b6a fix(session): dismiss expiry warning toast when signed out or extended
The one-shot heads-up toast used duration:Infinity with no id, so it
lingered after auto sign-out (when the banner unmounts) and its Extend
button 403'd on the expired token. Give the toast a stable id and
dismiss it when the banner leaves the warn window or unmounts, matching
the MissionReminderToasts pattern.
2026-09-24 12:04:58 -04:00
17 changed files with 70214 additions and 15 deletions

View file

@ -2,12 +2,13 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import type { StoryBeat, StoryBeatState } from "@/payload-types"; import type { StoryBeat, StoryBeatState, StoryVoiceover } from "@/payload-types";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BEAT_BODY_CLASSES } from "@/lib/story/richtextClasses"; 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 { 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 type { StoryReaction } from "@/lib/story/reactions"; import type { StoryReaction } from "@/lib/story/reactions";
@ -40,6 +41,10 @@ function sortBeats(beats: StoryBeat[]): StoryBeat[] {
}); });
} }
function voiceoverDoc(beat: StoryBeat): StoryVoiceover | null {
return beat.voiceover && typeof beat.voiceover !== "number" ? beat.voiceover : null;
}
export default async function StoryPage() { export default async function StoryPage() {
const payloadConfig = await config; const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig }); const payload = await getPayload({ config: payloadConfig });
@ -99,6 +104,8 @@ export default async function StoryPage() {
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),
voiceoverUrl: voiceoverDoc(beat)?.url ?? null,
voiceoverAiGenerated: voiceoverDoc(beat)?.aiGenerated ?? false,
})); }));
return ( return (
@ -111,8 +118,7 @@ export default async function StoryPage() {
</p> </p>
<h1 className="mt-2 font-serif text-4xl font-bold">Story Chronicle</h1> <h1 className="mt-2 font-serif text-4xl font-bold">Story Chronicle</h1>
<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 Every operation leaves a mark. These are the beats of our story, told in order.
order.
</p> </p>
{sorted.length > 0 && ( {sorted.length > 0 && (
<div className="mt-5 flex justify-center"> <div className="mt-5 flex justify-center">
@ -136,6 +142,8 @@ export default async function StoryPage() {
? `${mission.codeName} · ${mission.name}` ? `${mission.codeName} · ${mission.name}`
: mission.name : mission.name
: null; : null;
const voiceoverDocForBeat = voiceoverDoc(beat);
const voiceoverUrl = voiceoverDocForBeat?.url ?? null;
return ( return (
<li <li
key={beat.id} key={beat.id}
@ -161,6 +169,15 @@ export default async function StoryPage() {
</span> </span>
)} )}
</div> </div>
{voiceoverUrl && (
<div className="mt-4">
<BeatVoiceover
src={voiceoverUrl}
label={beat.title}
aiGenerated={voiceoverDocForBeat?.aiGenerated ?? false}
/>
</div>
)}
<div className="mt-4"> <div className="mt-4">
<RichText data={beat.body} className={cn(BEAT_BODY_CLASSES)} /> <RichText data={beat.body} className={cn(BEAT_BODY_CLASSES)} />
</div> </div>

View file

@ -3304,6 +3304,7 @@
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: rgb(255 255 255 / 95%); color: rgb(255 255 255 / 95%);
overflow-wrap: anywhere;
} }
/* Must out-rank leaflet.css, which styles the SAME selector later (it loads /* Must out-rank leaflet.css, which styles the SAME selector later (it loads
@ -3410,6 +3411,10 @@
letter-spacing: 0.16em; letter-spacing: 0.16em;
padding: 4px 8px; padding: 4px 8px;
text-transform: uppercase; text-transform: uppercase;
width: max-content;
max-width: min(280px, 60vw);
white-space: normal;
overflow-wrap: anywhere;
} }
.leaflet-container .map-tooltip-dossier.leaflet-tooltip-top::before { .leaflet-container .map-tooltip-dossier.leaflet-tooltip-top::before {

View file

@ -30,6 +30,8 @@ collections/
projects/ 4 files # Projects, Labels, Releases, Sprints (Jira-like) projects/ 4 files # Projects, Labels, Releases, Sprints (Jira-like)
tickets/ 2 files # Tickets (Lexical rich text), TicketVotes tickets/ 2 files # Tickets (Lexical rich text), TicketVotes
wiki/ 3 files # WikiPages, WikiRevisions, WikiTemplates wiki/ 3 files # WikiPages, WikiRevisions, WikiTemplates
story/ 3 files # StoryBeats (Chronicle chapters), StoryBeatStates (read markers/reactions),
# Voiceovers (audio narration uploads for the Chronicle, attached to beats)
``` ```
## Key relationships ## Key relationships

View file

@ -78,5 +78,13 @@ export const StoryBeats: CollectionConfig = {
description: "Optional mission this beat is tied to. Beats may stand alone.", description: "Optional mission this beat is tied to. Beats may stand alone.",
}, },
}, },
{
name: "voiceover",
type: "relationship",
relationTo: "story-voiceovers",
admin: {
description: "Optional audio narration for this beat. Rendered as a player on /story.",
},
},
], ],
}; };

View file

@ -0,0 +1,44 @@
import path from "path";
import type { CollectionConfig } from "payload";
import hasRoles from "@/utils/access-control/hasRoles";
function isStoryAuthor(user: Parameters<typeof hasRoles>[1]): boolean {
return hasRoles(["admin", "developer"], user);
}
export const Voiceovers: CollectionConfig = {
slug: "story-voiceovers",
admin: {
group: "Story",
useAsTitle: "filename",
defaultColumns: ["filename", "mimeType", "updatedAt"],
description:
"Audio narrations for Chronicle story beats. Attach to a beat via its Voiceover field.",
},
access: {
read: ({ req }) => Boolean(req.user),
create: ({ req }) => isStoryAuthor(req.user),
update: ({ req }) => isStoryAuthor(req.user),
delete: ({ req }) => isStoryAuthor(req.user),
},
// staticDir must be set explicitly: Payload defaults an upload collection's
// staticDir to its slug resolved from cwd (dist/collections/config/sanitize.js),
// which would write files to <cwd>/story-voiceovers and miss the persistent
// /app/media volume in production. media/ is gitignored.
upload: {
staticDir: path.resolve(process.cwd(), "media", "story-voiceovers"),
mimeTypes: ["audio/*"],
},
fields: [
{
name: "aiGenerated",
type: "checkbox",
label: "AI-generated",
defaultValue: false,
admin: {
description:
"Tick if this narration was generated by AI (text-to-speech) rather than recorded by a person. Adds a note under the player on /story.",
},
},
],
};

View file

@ -16,6 +16,13 @@ import {
} from "@/lib/session/countdown"; } from "@/lib/session/countdown";
import { SESSION_EXTENDED_EVENT } from "@/hooks/useGameTick"; import { SESSION_EXTENDED_EVENT } from "@/hooks/useGameTick";
/**
* Stable id for the one-shot "session expiring" heads-up toast. A fixed id lets
* us dismiss it deterministically (e.g. on sign-out) instead of relying on the
* opaque id returned by `toast.info`. Mirrors MissionReminderToasts.
*/
const SESSION_EXPIRY_TOAST_ID = "session-expiry-warning";
interface SessionExpiryBannerProps { interface SessionExpiryBannerProps {
/** Absolute expiry (ms since epoch), server-computed from the JWT `exp`. */ /** Absolute expiry (ms since epoch), server-computed from the JWT `exp`. */
sessionExp: number | null; sessionExp: number | null;
@ -70,6 +77,7 @@ export function SessionExpiryBanner({
toastFiredRef.current = true; toastFiredRef.current = true;
const minutes = Math.round(warnMs / 60_000); const minutes = Math.round(warnMs / 60_000);
toast.info(`Your session expires in about ${minutes} min.`, { toast.info(`Your session expires in about ${minutes} min.`, {
id: SESSION_EXPIRY_TOAST_ID,
action: { action: {
label: "Extend", label: "Extend",
onClick: () => void extendSession(), onClick: () => void extendSession(),
@ -83,6 +91,20 @@ export function SessionExpiryBanner({
if (phase === "warn") fireToast(); if (phase === "warn") fireToast();
}, [phase, warnMs, fireToast]); }, [phase, warnMs, fireToast]);
// The heads-up toast is only relevant while we're in the warn window. Dismiss it
// when the user extends (phase leaves "warn") or dismisses the banner, and again
// on unmount (sign-out swaps the shell for the landing page) — otherwise the
// duration:Infinity toast lingers for a signed-out user and its Extend button 403s.
useEffect(() => {
if (!visible) toast.dismiss(SESSION_EXPIRY_TOAST_ID);
}, [visible]);
useEffect(() => {
return () => {
toast.dismiss(SESSION_EXPIRY_TOAST_ID);
};
}, []);
const extendSession = useCallback(async () => { const extendSession = useCallback(async () => {
setExtending(true); setExtending(true);
setError(null); setError(null);

View file

@ -0,0 +1,115 @@
"use client";
import { useEffect, useRef, useState, type ChangeEvent } from "react";
import { Pause, Play } from "lucide-react";
import { Button } from "@/components/ui/button";
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return "0:00";
const total = Math.floor(seconds);
const minutes = Math.floor(total / 60);
const secs = total % 60;
return `${minutes}:${secs.toString().padStart(2, "0")}`;
}
/**
* Minimal audiobook-style player for a story beat narration. Play/pause plus
* a seek bar and time readout; ends back at zero (per-chapter playback, no
* auto-advance to other chapters). AI-generated narrations get a small note.
*/
export function BeatVoiceover({
src,
label,
aiGenerated = false,
}: {
readonly src: string;
readonly label: string;
readonly aiGenerated?: boolean;
}) {
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
const onTimeUpdate = () => setCurrentTime(audio.currentTime);
const onLoadedMetadata = () => setDuration(audio.duration);
const onEnded = () => {
setPlaying(false);
setCurrentTime(0);
};
audio.addEventListener("play", onPlay);
audio.addEventListener("pause", onPause);
audio.addEventListener("timeupdate", onTimeUpdate);
audio.addEventListener("loadedmetadata", onLoadedMetadata);
audio.addEventListener("ended", onEnded);
return () => {
audio.removeEventListener("play", onPlay);
audio.removeEventListener("pause", onPause);
audio.removeEventListener("timeupdate", onTimeUpdate);
audio.removeEventListener("loadedmetadata", onLoadedMetadata);
audio.removeEventListener("ended", onEnded);
};
}, []);
function togglePlay() {
const audio = audioRef.current;
if (!audio) return;
if (playing) {
audio.pause();
} else {
void audio.play().catch(() => setPlaying(false));
}
}
function handleSeek(event: ChangeEvent<HTMLInputElement>) {
const audio = audioRef.current;
if (!audio) return;
const next = Number(event.target.value);
if (!Number.isFinite(next)) return;
audio.currentTime = next;
setCurrentTime(next);
}
const seekMax = duration > 0 ? duration : 0;
return (
<div className="flex flex-col gap-1 items-center">
<div className="flex items-center gap-2.5" aria-label={`Audio narration: ${label}`}>
<audio ref={audioRef} src={src} preload="metadata" />
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground"
aria-label={playing ? `Pause narration: ${label}` : `Play narration: ${label}`}
onClick={togglePlay}
>
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
</Button>
<input
type="range"
min={0}
max={seekMax}
step={0.1}
value={Math.min(currentTime, seekMax || 0)}
onChange={handleSeek}
disabled={duration <= 0}
aria-label={`Seek narration: ${label}`}
className="h-1 w-32 cursor-pointer accent-primary disabled:cursor-default disabled:opacity-40 sm:w-44"
/>
<span className="font-mono text-xs tabular-nums text-muted-foreground">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
{aiGenerated && <p className="text-xs text-muted-foreground opacity-50 text-center">This audio narration was generated using AI. As a result, the quality of the narration may be inconsistent.<br/><br/>Want to narrate for the Chronicle? Reach out to Jason to sign up!</p>}
</div>
);
}

View file

@ -1,12 +1,13 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Headphones } from "lucide-react";
import type { StoryViewerBeat } from "@/components/frontend/story/StoryViewer"; import type { StoryViewerBeat } from "@/components/frontend/story/StoryViewer";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export type StoryTableOfContentsBeat = Pick< export type StoryTableOfContentsBeat = Pick<
StoryViewerBeat, StoryViewerBeat,
"id" | "title" | "chapter" | "initialRead" "id" | "title" | "chapter" | "initialRead" | "voiceoverUrl"
>; >;
export function StoryTableOfContents({ export function StoryTableOfContents({
@ -79,13 +80,16 @@ export function StoryTableOfContents({
{beats.map((beat) => { {beats.map((beat) => {
const active = activeBeatId === beat.id; const active = activeBeatId === beat.id;
const readLabel = beat.initialRead ? "Read" : "Unread"; const readLabel = beat.initialRead ? "Read" : "Unread";
const ariaLabel = beat.voiceoverUrl
? `Chapter ${beat.chapter}: ${beat.title}. ${readLabel}. Has audio.`
: `Chapter ${beat.chapter}: ${beat.title}. ${readLabel}.`;
return ( return (
<li key={beat.id}> <li key={beat.id}>
<button <button
type="button" type="button"
aria-current={active ? "location" : undefined} aria-current={active ? "location" : undefined}
aria-label={`Chapter ${beat.chapter}: ${beat.title}. ${readLabel}.`} aria-label={ariaLabel}
onClick={() => scrollToBeat(beat.id)} onClick={() => scrollToBeat(beat.id)}
className={cn( className={cn(
"group relative -ml-px flex w-full items-start gap-2 border-l-2 px-2 py-2 text-left text-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", "group relative -ml-px flex w-full items-start gap-2 border-l-2 px-2 py-2 text-left text-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
@ -105,6 +109,12 @@ export function StoryTableOfContents({
{beat.title} {beat.title}
</span> </span>
</span> </span>
{beat.voiceoverUrl && (
<Headphones
aria-hidden="true"
className="mt-1.5 size-3 shrink-0 text-muted-foreground"
/>
)}
<span <span
aria-hidden="true" aria-hidden="true"
className={cn( className={cn(

View file

@ -5,6 +5,7 @@ import { BookOpen, X } from "lucide-react";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { BeatReadMarker } from "@/components/frontend/story/BeatReadMarker"; import { BeatReadMarker } from "@/components/frontend/story/BeatReadMarker";
import { BeatVoiceover } from "@/components/frontend/story/BeatVoiceover";
import type { StoryBeat } from "@/payload-types"; import type { StoryBeat } from "@/payload-types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { READER_BODY_CLASSES } from "@/lib/story/richtextClasses"; import { READER_BODY_CLASSES } from "@/lib/story/richtextClasses";
@ -16,6 +17,8 @@ export type StoryViewerBeat = {
readonly dateLabel: string | null; readonly dateLabel: string | null;
readonly body: StoryBeat["body"]; readonly body: StoryBeat["body"];
readonly initialRead: boolean; readonly initialRead: boolean;
readonly voiceoverUrl: string | null;
readonly voiceoverAiGenerated: boolean;
}; };
export function StoryViewer({ beats }: { readonly beats: readonly StoryViewerBeat[] }) { export function StoryViewer({ beats }: { readonly beats: readonly StoryViewerBeat[] }) {
@ -101,6 +104,15 @@ export function StoryViewer({ beats }: { readonly beats: readonly StoryViewerBea
{beat.dateLabel && ( {beat.dateLabel && (
<p className="mt-3 text-sm text-neutral-500">{beat.dateLabel}</p> <p className="mt-3 text-sm text-neutral-500">{beat.dateLabel}</p>
)} )}
{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="mt-8"> <div className="mt-8">
<RichText data={beat.body} className={cn(READER_BODY_CLASSES)} /> <RichText data={beat.body} className={cn(READER_BODY_CLASSES)} />
</div> </div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,51 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE "story_voiceovers" (
"id" serial PRIMARY KEY NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"url" varchar,
"thumbnail_u_r_l" varchar,
"filename" varchar,
"mime_type" varchar,
"filesize" numeric,
"width" numeric,
"height" numeric,
"focal_x" numeric,
"focal_y" numeric
);
ALTER TABLE "story_beats" ADD COLUMN "voiceover_id" integer;
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "story_voiceovers_find" boolean DEFAULT false;
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "story_voiceovers_create" boolean DEFAULT false;
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "story_voiceovers_update" boolean DEFAULT false;
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "story_voiceovers_delete" boolean DEFAULT false;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "story_voiceovers_id" integer;
CREATE INDEX "story_voiceovers_updated_at_idx" ON "story_voiceovers" USING btree ("updated_at");
CREATE INDEX "story_voiceovers_created_at_idx" ON "story_voiceovers" USING btree ("created_at");
CREATE UNIQUE INDEX "story_voiceovers_filename_idx" ON "story_voiceovers" USING btree ("filename");
ALTER TABLE "story_beats" ADD CONSTRAINT "story_beats_voiceover_id_story_voiceovers_id_fk" FOREIGN KEY ("voiceover_id") REFERENCES "public"."story_voiceovers"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_story_voiceovers_fk" FOREIGN KEY ("story_voiceovers_id") REFERENCES "public"."story_voiceovers"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "story_beats_voiceover_idx" ON "story_beats" USING btree ("voiceover_id");
CREATE INDEX "payload_locked_documents_rels_story_voiceovers_id_idx" ON "payload_locked_documents_rels" USING btree ("story_voiceovers_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "story_voiceovers" DISABLE ROW LEVEL SECURITY;
DROP TABLE "story_voiceovers" CASCADE;
ALTER TABLE "story_beats" DROP CONSTRAINT "story_beats_voiceover_id_story_voiceovers_id_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_story_voiceovers_fk";
DROP INDEX "story_beats_voiceover_idx";
DROP INDEX "payload_locked_documents_rels_story_voiceovers_id_idx";
ALTER TABLE "story_beats" DROP COLUMN "voiceover_id";
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "story_voiceovers_find";
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "story_voiceovers_create";
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "story_voiceovers_update";
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "story_voiceovers_delete";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "story_voiceovers_id";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "story_voiceovers" ADD COLUMN "ai_generated" boolean DEFAULT false;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "story_voiceovers" DROP COLUMN "ai_generated";`)
}

View file

@ -88,6 +88,8 @@ import * as migration_20260923_001209_add_announcements_role_docs_targeting from
import * as migration_20260923_002422_announcements_description_richtext from './20260923_002422_announcements_description_richtext'; import * as migration_20260923_002422_announcements_description_richtext from './20260923_002422_announcements_description_richtext';
import * as migration_20260923_200000_add_missing_role_permission_enum_values from './20260923_200000_add_missing_role_permission_enum_values'; import * as migration_20260923_200000_add_missing_role_permission_enum_values from './20260923_200000_add_missing_role_permission_enum_values';
import * as migration_20260923_202700_add_form_builder_collections from './20260923_202700_add_form_builder_collections'; import * as migration_20260923_202700_add_form_builder_collections from './20260923_202700_add_form_builder_collections';
import * as migration_20260924_205400_add_story_beat_voiceover from './20260924_205400_add_story_beat_voiceover';
import * as migration_20260924_212353_add_story_voiceover_ai_generated from './20260924_212353_add_story_voiceover_ai_generated';
export const migrations = [ export const migrations = [
{ {
@ -540,4 +542,14 @@ export const migrations = [
down: migration_20260923_202700_add_form_builder_collections.down, down: migration_20260923_202700_add_form_builder_collections.down,
name: '20260923_202700_add_form_builder_collections', name: '20260923_202700_add_form_builder_collections',
}, },
{
up: migration_20260924_205400_add_story_beat_voiceover.up,
down: migration_20260924_205400_add_story_beat_voiceover.down,
name: '20260924_205400_add_story_beat_voiceover',
},
{
up: migration_20260924_212353_add_story_voiceover_ai_generated.up,
down: migration_20260924_212353_add_story_voiceover_ai_generated.down,
name: '20260924_212353_add_story_voiceover_ai_generated'
},
]; ];

View file

@ -125,6 +125,7 @@ export interface Config {
'narrative-events': NarrativeEvent; 'narrative-events': NarrativeEvent;
'story-beats': StoryBeat; 'story-beats': StoryBeat;
'story-beat-states': StoryBeatState; 'story-beat-states': StoryBeatState;
'story-voiceovers': StoryVoiceover;
'mission-files': MissionFile; 'mission-files': MissionFile;
'mod-lists': ModList; 'mod-lists': ModList;
'game-servers': GameServer; 'game-servers': GameServer;
@ -216,6 +217,7 @@ export interface Config {
'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>; 'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>;
'story-beats': StoryBeatsSelect<false> | StoryBeatsSelect<true>; 'story-beats': StoryBeatsSelect<false> | StoryBeatsSelect<true>;
'story-beat-states': StoryBeatStatesSelect<false> | StoryBeatStatesSelect<true>; 'story-beat-states': StoryBeatStatesSelect<false> | StoryBeatStatesSelect<true>;
'story-voiceovers': StoryVoiceoversSelect<false> | StoryVoiceoversSelect<true>;
'mission-files': MissionFilesSelect<false> | MissionFilesSelect<true>; 'mission-files': MissionFilesSelect<false> | MissionFilesSelect<true>;
'mod-lists': ModListsSelect<false> | ModListsSelect<true>; 'mod-lists': ModListsSelect<false> | ModListsSelect<true>;
'game-servers': GameServersSelect<false> | GameServersSelect<true>; 'game-servers': GameServersSelect<false> | GameServersSelect<true>;
@ -4306,9 +4308,37 @@ export interface StoryBeat {
* Optional mission this beat is tied to. Beats may stand alone. * Optional mission this beat is tied to. Beats may stand alone.
*/ */
mission?: (number | null) | Mission; mission?: (number | null) | Mission;
/**
* Optional audio narration for this beat. Rendered as a player on /story.
*/
voiceover?: (number | null) | StoryVoiceover;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* Audio narrations for Chronicle story beats. Attach to a beat via its Voiceover field.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "story-voiceovers".
*/
export interface StoryVoiceover {
id: number;
/**
* Tick if this narration was generated by AI (text-to-speech) rather than recorded by a person. Adds a note under the player on /story.
*/
aiGenerated?: boolean | null;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/** /**
* Per-user read markers and reactions for story beats. * Per-user read markers and reactions for story beats.
* *
@ -6188,6 +6218,24 @@ export interface PayloadMcpApiKey {
*/ */
delete?: boolean | null; delete?: boolean | null;
}; };
storyVoiceovers?: {
/**
* Allow clients to find story-voiceovers.
*/
find?: boolean | null;
/**
* Allow clients to create story-voiceovers.
*/
create?: boolean | null;
/**
* Allow clients to update story-voiceovers.
*/
update?: boolean | null;
/**
* Allow clients to delete story-voiceovers.
*/
delete?: boolean | null;
};
missionFiles?: { missionFiles?: {
/** /**
* Allow clients to find mission-files. * Allow clients to find mission-files.
@ -6735,6 +6783,10 @@ export interface PayloadLockedDocument {
relationTo: 'story-beat-states'; relationTo: 'story-beat-states';
value: number | StoryBeatState; value: number | StoryBeatState;
} | null) } | null)
| ({
relationTo: 'story-voiceovers';
value: number | StoryVoiceover;
} | null)
| ({ | ({
relationTo: 'mission-files'; relationTo: 'mission-files';
value: number | MissionFile; value: number | MissionFile;
@ -8602,6 +8654,7 @@ export interface StoryBeatsSelect<T extends boolean = true> {
status?: T; status?: T;
body?: T; body?: T;
mission?: T; mission?: T;
voiceover?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
@ -8617,6 +8670,24 @@ export interface StoryBeatStatesSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "story-voiceovers_select".
*/
export interface StoryVoiceoversSelect<T extends boolean = true> {
aiGenerated?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "mission-files_select". * via the `definition` "mission-files_select".
@ -9592,6 +9663,14 @@ export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
update?: T; update?: T;
delete?: T; delete?: T;
}; };
storyVoiceovers?:
| T
| {
find?: T;
create?: T;
update?: T;
delete?: T;
};
missionFiles?: missionFiles?:
| T | T
| { | {

View file

@ -76,6 +76,7 @@ import { Releases } from "@/collections/projects/Releases";
import { Labels } from "@/collections/projects/Labels"; import { Labels } from "@/collections/projects/Labels";
import { StoryBeats } from "@/collections/story/StoryBeats"; import { StoryBeats } from "@/collections/story/StoryBeats";
import { StoryBeatStates } from "@/collections/story/StoryBeatStates"; import { StoryBeatStates } from "@/collections/story/StoryBeatStates";
import { Voiceovers } from "@/collections/story/Voiceovers";
import { Shims } from "@/collections/Shims"; import { Shims } from "@/collections/Shims";
import { Announcements } from "@/collections/Announcements"; import { Announcements } from "@/collections/Announcements";
import { CustomMinefields } from "@/collections/minigames/CustomMinefields"; import { CustomMinefields } from "@/collections/minigames/CustomMinefields";
@ -314,6 +315,7 @@ const collections = [
// Story // Story
StoryBeats, StoryBeats,
StoryBeatStates, StoryBeatStates,
Voiceovers,
// Server // Server
MissionFiles, MissionFiles,

View file

@ -2,13 +2,28 @@ import { getPayload, Payload } from "payload";
import config from "@/payload.config"; 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, 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";
let payload: Payload; let payload: Payload;
const RUN = `story-${Date.now().toString(36)}`; const RUN = `story-${Date.now().toString(36)}`;
// Minimal MPEG-1 Layer 3 frame-sync header: what `file-type` sniffs as
// audio/mpeg. The bytes must be wrapped in a realm-local Uint8Array, because
// vitest's jsdom environment swaps the global Uint8Array and Node Buffers then
// fail file-type's instanceof check (Payload would report the file unreadable).
function mp3FileData(): Buffer {
const buffer = Buffer.alloc(67);
buffer[0] = 0xff;
buffer[1] = 0xfb;
return new Uint8Array(buffer) as unknown as Buffer;
}
function textFileData(text: string): Buffer {
return new Uint8Array(Buffer.from(text)) as unknown as Buffer;
}
function lexicalBody(text: string): StoryBeat["body"] { function lexicalBody(text: string): StoryBeat["body"] {
return { return {
root: { root: {
@ -44,6 +59,7 @@ describe("Story beats", () => {
let otherUserId: number; let otherUserId: number;
const beatIds: number[] = []; const beatIds: number[] = [];
const stateIds: number[] = []; const stateIds: number[] = [];
const voiceoverIds: number[] = [];
let draftBeatId: number; let draftBeatId: number;
let publishedBeatId: number; let publishedBeatId: number;
@ -102,15 +118,18 @@ describe("Story beats", () => {
}); });
afterAll(async () => { afterAll(async () => {
for (const id of voiceoverIds) {
await payload
.delete({ collection: "story-voiceovers", id, overrideAccess: true })
.catch(() => {});
}
for (const id of stateIds) { for (const id of stateIds) {
await payload await payload
.delete({ collection: "story-beat-states", id, overrideAccess: true }) .delete({ collection: "story-beat-states", id, overrideAccess: true })
.catch(() => {}); .catch(() => {});
} }
for (const id of beatIds) { for (const id of beatIds) {
await payload await payload.delete({ collection: "story-beats", id, overrideAccess: true }).catch(() => {});
.delete({ collection: "story-beats", id, overrideAccess: true })
.catch(() => {});
} }
for (const id of [adminId, userId, otherUserId]) { for (const id of [adminId, userId, otherUserId]) {
const accounts = await payload const accounts = await payload
@ -141,9 +160,7 @@ describe("Story beats", () => {
.delete({ collection: "profiles", id: profile.id, overrideAccess: true }) .delete({ collection: "profiles", id: profile.id, overrideAccess: true })
.catch(() => {}); .catch(() => {});
} }
await payload await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
.delete({ collection: "users", id, overrideAccess: true })
.catch(() => {});
} }
}); });
@ -244,9 +261,7 @@ describe("Story beats", () => {
}); });
it("refuses to mark a draft beat read for a normal user", async () => { it("refuses to mark a draft beat read for a normal user", async () => {
await expect( await expect(markBeatRead(payload, { user, beatId: draftBeatId })).rejects.toThrow();
markBeatRead(payload, { user, beatId: draftBeatId }),
).rejects.toThrow();
}); });
it("scopes state docs to their owner", async () => { it("scopes state docs to their owner", async () => {
@ -322,4 +337,135 @@ describe("Story beats", () => {
expect(result.docs[0]?.readAt).toBeTruthy(); expect(result.docs[0]?.readAt).toBeTruthy();
}); });
}); });
describe("voiceovers", () => {
let voiceoverId: number;
it("blocks normal users from creating voiceovers", async () => {
await expect(
payload.create({
collection: "story-voiceovers",
data: {},
file: {
data: mp3FileData(),
mimetype: "audio/mpeg",
name: `${RUN}-ch0.mp3`,
size: 67,
},
overrideAccess: false,
user,
depth: 0,
}),
).rejects.toThrow();
});
it("allows admin authors to upload audio files", async () => {
const created = (await payload.create({
collection: "story-voiceovers",
data: {},
file: {
data: mp3FileData(),
mimetype: "audio/mpeg",
name: `${RUN}-ch1.mp3`,
size: 67,
},
overrideAccess: false,
user: admin,
depth: 0,
})) as unknown as StoryVoiceover;
voiceoverId = created.id;
voiceoverIds.push(created.id);
expect(created.filename).toBe(`${RUN}-ch1.mp3`);
expect(created.mimeType).toBe("audio/mpeg");
expect(created.url).toBeTruthy();
});
it("hides voiceovers from guests but lets any logged-in user read them", async () => {
await expect(
payload.find({ collection: "story-voiceovers", overrideAccess: false }),
).rejects.toThrow();
const result = (await payload.find({
collection: "story-voiceovers",
overrideAccess: false,
user,
depth: 0,
})) as unknown as { docs: StoryVoiceover[] };
expect(result.docs.map((doc) => doc.id)).toContain(voiceoverId);
});
it("flags AI-generated narrations and defaults the checkbox to false", async () => {
const ai = (await payload.create({
collection: "story-voiceovers",
data: { aiGenerated: true },
file: {
data: mp3FileData(),
mimetype: "audio/mpeg",
name: `${RUN}-ch2-ai.mp3`,
size: 67,
},
overrideAccess: false,
user: admin,
depth: 0,
})) as unknown as StoryVoiceover;
voiceoverIds.push(ai.id);
expect(ai.aiGenerated).toBe(true);
const human = (await payload.findByID({
collection: "story-voiceovers",
id: voiceoverId,
overrideAccess: false,
user,
depth: 0,
})) as unknown as StoryVoiceover;
expect(human.aiGenerated).toBe(false);
});
it("roundtrips a beat voiceover as a hydrated upload doc at depth 1", async () => {
const beat = (await payload.create({
collection: "story-beats",
data: {
title: `${RUN} Voiceover beat`,
status: "published",
body: lexicalBody("Narrated chapter."),
voiceover: voiceoverId,
},
overrideAccess: false,
user: admin,
depth: 0,
})) as unknown as StoryBeat;
beatIds.push(beat.id);
expect(beat.voiceover).toBe(voiceoverId);
const found = (await payload.findByID({
collection: "story-beats",
id: beat.id,
depth: 1,
overrideAccess: false,
user,
})) as unknown as StoryBeat;
const doc = found.voiceover && typeof found.voiceover !== "number" ? found.voiceover : null;
expect(doc).toBeTruthy();
expect(doc?.url).toBeTruthy();
expect(doc?.mimeType).toBe("audio/mpeg");
expect(doc?.aiGenerated).toBe(false);
});
it("rejects non-audio file types", async () => {
await expect(
payload.create({
collection: "story-voiceovers",
data: {},
file: {
data: textFileData("plain text, definitely not audio"),
mimetype: "text/plain",
name: `${RUN}-note.txt`,
size: 34,
},
overrideAccess: false,
user: admin,
depth: 0,
}),
).rejects.toThrow();
});
});
}); });