Compare commits
3 commits
975a08195a
...
d00a5980a5
| Author | SHA1 | Date | |
|---|---|---|---|
| d00a5980a5 | |||
| 2837518b3b | |||
| 1e1fc33b6a |
17 changed files with 70214 additions and 15 deletions
|
|
@ -2,12 +2,13 @@ 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 type { StoryBeat, StoryBeatState, StoryVoiceover } 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 { BeatVoiceover } from "@/components/frontend/story/BeatVoiceover";
|
||||
import { StoryViewer, type StoryViewerBeat } from "@/components/frontend/story/StoryViewer";
|
||||
import { StoryTableOfContents } from "@/components/frontend/story/StoryTableOfContents";
|
||||
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() {
|
||||
const payloadConfig = await config;
|
||||
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,
|
||||
body: beat.body,
|
||||
initialRead: Boolean(stateByBeat.get(beat.id)?.readAt),
|
||||
voiceoverUrl: voiceoverDoc(beat)?.url ?? null,
|
||||
voiceoverAiGenerated: voiceoverDoc(beat)?.aiGenerated ?? false,
|
||||
}));
|
||||
|
||||
return (
|
||||
|
|
@ -111,8 +118,7 @@ export default async function StoryPage() {
|
|||
</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.
|
||||
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">
|
||||
|
|
@ -136,6 +142,8 @@ export default async function StoryPage() {
|
|||
? `${mission.codeName} · ${mission.name}`
|
||||
: mission.name
|
||||
: null;
|
||||
const voiceoverDocForBeat = voiceoverDoc(beat);
|
||||
const voiceoverUrl = voiceoverDocForBeat?.url ?? null;
|
||||
return (
|
||||
<li
|
||||
key={beat.id}
|
||||
|
|
@ -161,6 +169,15 @@ export default async function StoryPage() {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{voiceoverUrl && (
|
||||
<div className="mt-4">
|
||||
<BeatVoiceover
|
||||
src={voiceoverUrl}
|
||||
label={beat.title}
|
||||
aiGenerated={voiceoverDocForBeat?.aiGenerated ?? false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<RichText data={beat.body} className={cn(BEAT_BODY_CLASSES)} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3304,6 +3304,7 @@
|
|||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgb(255 255 255 / 95%);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Must out-rank leaflet.css, which styles the SAME selector later (it loads
|
||||
|
|
@ -3410,6 +3411,10 @@
|
|||
letter-spacing: 0.16em;
|
||||
padding: 4px 8px;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ collections/
|
|||
projects/ 4 files # Projects, Labels, Releases, Sprints (Jira-like)
|
||||
tickets/ 2 files # Tickets (Lexical rich text), TicketVotes
|
||||
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
|
||||
|
|
|
|||
|
|
@ -78,5 +78,13 @@ export const StoryBeats: CollectionConfig = {
|
|||
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.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
44
src/collections/story/Voiceovers.ts
Normal file
44
src/collections/story/Voiceovers.ts
Normal 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.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -16,6 +16,13 @@ import {
|
|||
} from "@/lib/session/countdown";
|
||||
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 {
|
||||
/** Absolute expiry (ms since epoch), server-computed from the JWT `exp`. */
|
||||
sessionExp: number | null;
|
||||
|
|
@ -70,6 +77,7 @@ export function SessionExpiryBanner({
|
|||
toastFiredRef.current = true;
|
||||
const minutes = Math.round(warnMs / 60_000);
|
||||
toast.info(`Your session expires in about ${minutes} min.`, {
|
||||
id: SESSION_EXPIRY_TOAST_ID,
|
||||
action: {
|
||||
label: "Extend",
|
||||
onClick: () => void extendSession(),
|
||||
|
|
@ -83,6 +91,20 @@ export function SessionExpiryBanner({
|
|||
if (phase === "warn") 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 () => {
|
||||
setExtending(true);
|
||||
setError(null);
|
||||
|
|
|
|||
115
src/components/frontend/story/BeatVoiceover.tsx
Normal file
115
src/components/frontend/story/BeatVoiceover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Headphones } from "lucide-react";
|
||||
import type { StoryViewerBeat } from "@/components/frontend/story/StoryViewer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type StoryTableOfContentsBeat = Pick<
|
||||
StoryViewerBeat,
|
||||
"id" | "title" | "chapter" | "initialRead"
|
||||
"id" | "title" | "chapter" | "initialRead" | "voiceoverUrl"
|
||||
>;
|
||||
|
||||
export function StoryTableOfContents({
|
||||
|
|
@ -79,13 +80,16 @@ export function StoryTableOfContents({
|
|||
{beats.map((beat) => {
|
||||
const active = activeBeatId === beat.id;
|
||||
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 (
|
||||
<li key={beat.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={active ? "location" : undefined}
|
||||
aria-label={`Chapter ${beat.chapter}: ${beat.title}. ${readLabel}.`}
|
||||
aria-label={ariaLabel}
|
||||
onClick={() => scrollToBeat(beat.id)}
|
||||
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",
|
||||
|
|
@ -105,6 +109,12 @@ export function StoryTableOfContents({
|
|||
{beat.title}
|
||||
</span>
|
||||
</span>
|
||||
{beat.voiceoverUrl && (
|
||||
<Headphones
|
||||
aria-hidden="true"
|
||||
className="mt-1.5 size-3 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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 { BeatVoiceover } from "@/components/frontend/story/BeatVoiceover";
|
||||
import type { StoryBeat } from "@/payload-types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { READER_BODY_CLASSES } from "@/lib/story/richtextClasses";
|
||||
|
|
@ -16,6 +17,8 @@ export type StoryViewerBeat = {
|
|||
readonly dateLabel: string | null;
|
||||
readonly body: StoryBeat["body"];
|
||||
readonly initialRead: boolean;
|
||||
readonly voiceoverUrl: string | null;
|
||||
readonly voiceoverAiGenerated: boolean;
|
||||
};
|
||||
|
||||
export function StoryViewer({ beats }: { readonly beats: readonly StoryViewerBeat[] }) {
|
||||
|
|
@ -101,6 +104,15 @@ export function StoryViewer({ beats }: { readonly beats: readonly StoryViewerBea
|
|||
{beat.dateLabel && (
|
||||
<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">
|
||||
<RichText data={beat.body} className={cn(READER_BODY_CLASSES)} />
|
||||
</div>
|
||||
|
|
|
|||
34828
src/migrations/20260924_205400_add_story_beat_voiceover.json
Normal file
34828
src/migrations/20260924_205400_add_story_beat_voiceover.json
Normal file
File diff suppressed because it is too large
Load diff
51
src/migrations/20260924_205400_add_story_beat_voiceover.ts
Normal file
51
src/migrations/20260924_205400_add_story_beat_voiceover.ts
Normal 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";`)
|
||||
}
|
||||
34835
src/migrations/20260924_212353_add_story_voiceover_ai_generated.json
Normal file
34835
src/migrations/20260924_212353_add_story_voiceover_ai_generated.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||
}
|
||||
|
|
@ -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_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_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 = [
|
||||
{
|
||||
|
|
@ -540,4 +542,14 @@ export const migrations = [
|
|||
down: migration_20260923_202700_add_form_builder_collections.down,
|
||||
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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ export interface Config {
|
|||
'narrative-events': NarrativeEvent;
|
||||
'story-beats': StoryBeat;
|
||||
'story-beat-states': StoryBeatState;
|
||||
'story-voiceovers': StoryVoiceover;
|
||||
'mission-files': MissionFile;
|
||||
'mod-lists': ModList;
|
||||
'game-servers': GameServer;
|
||||
|
|
@ -216,6 +217,7 @@ export interface Config {
|
|||
'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>;
|
||||
'story-beats': StoryBeatsSelect<false> | StoryBeatsSelect<true>;
|
||||
'story-beat-states': StoryBeatStatesSelect<false> | StoryBeatStatesSelect<true>;
|
||||
'story-voiceovers': StoryVoiceoversSelect<false> | StoryVoiceoversSelect<true>;
|
||||
'mission-files': MissionFilesSelect<false> | MissionFilesSelect<true>;
|
||||
'mod-lists': ModListsSelect<false> | ModListsSelect<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.
|
||||
*/
|
||||
mission?: (number | null) | Mission;
|
||||
/**
|
||||
* Optional audio narration for this beat. Rendered as a player on /story.
|
||||
*/
|
||||
voiceover?: (number | null) | StoryVoiceover;
|
||||
updatedAt: 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.
|
||||
*
|
||||
|
|
@ -6188,6 +6218,24 @@ export interface PayloadMcpApiKey {
|
|||
*/
|
||||
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?: {
|
||||
/**
|
||||
* Allow clients to find mission-files.
|
||||
|
|
@ -6735,6 +6783,10 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'story-beat-states';
|
||||
value: number | StoryBeatState;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'story-voiceovers';
|
||||
value: number | StoryVoiceover;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'mission-files';
|
||||
value: number | MissionFile;
|
||||
|
|
@ -8602,6 +8654,7 @@ export interface StoryBeatsSelect<T extends boolean = true> {
|
|||
status?: T;
|
||||
body?: T;
|
||||
mission?: T;
|
||||
voiceover?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
|
|
@ -8617,6 +8670,24 @@ export interface StoryBeatStatesSelect<T extends boolean = true> {
|
|||
updatedAt?: 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
|
||||
* via the `definition` "mission-files_select".
|
||||
|
|
@ -9592,6 +9663,14 @@ export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
|
|||
update?: T;
|
||||
delete?: T;
|
||||
};
|
||||
storyVoiceovers?:
|
||||
| T
|
||||
| {
|
||||
find?: T;
|
||||
create?: T;
|
||||
update?: T;
|
||||
delete?: T;
|
||||
};
|
||||
missionFiles?:
|
||||
| T
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ 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 { Voiceovers } from "@/collections/story/Voiceovers";
|
||||
import { Shims } from "@/collections/Shims";
|
||||
import { Announcements } from "@/collections/Announcements";
|
||||
import { CustomMinefields } from "@/collections/minigames/CustomMinefields";
|
||||
|
|
@ -314,6 +315,7 @@ const collections = [
|
|||
// Story
|
||||
StoryBeats,
|
||||
StoryBeatStates,
|
||||
Voiceovers,
|
||||
|
||||
// Server
|
||||
MissionFiles,
|
||||
|
|
|
|||
|
|
@ -2,13 +2,28 @@ 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 type { StoryBeat, StoryBeatState, StoryVoiceover, User } from "@/payload-types";
|
||||
import { markBeatRead, setBeatReaction } from "@/lib/story";
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
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"] {
|
||||
return {
|
||||
root: {
|
||||
|
|
@ -44,6 +59,7 @@ describe("Story beats", () => {
|
|||
let otherUserId: number;
|
||||
const beatIds: number[] = [];
|
||||
const stateIds: number[] = [];
|
||||
const voiceoverIds: number[] = [];
|
||||
let draftBeatId: number;
|
||||
let publishedBeatId: number;
|
||||
|
||||
|
|
@ -102,15 +118,18 @@ describe("Story beats", () => {
|
|||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of voiceoverIds) {
|
||||
await payload
|
||||
.delete({ collection: "story-voiceovers", id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
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(() => {});
|
||||
await payload.delete({ collection: "story-beats", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of [adminId, userId, otherUserId]) {
|
||||
const accounts = await payload
|
||||
|
|
@ -141,9 +160,7 @@ describe("Story beats", () => {
|
|||
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
await payload
|
||||
.delete({ collection: "users", id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
await payload.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 () => {
|
||||
await expect(
|
||||
markBeatRead(payload, { user, beatId: draftBeatId }),
|
||||
).rejects.toThrow();
|
||||
await expect(markBeatRead(payload, { user, beatId: draftBeatId })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("scopes state docs to their owner", async () => {
|
||||
|
|
@ -322,4 +337,135 @@ describe("Story beats", () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue