diff --git a/src/app/(frontend)/story/page.tsx b/src/app/(frontend)/story/page.tsx index 1626075..827d777 100644 --- a/src/app/(frontend)/story/page.tsx +++ b/src/app/(frontend)/story/page.tsx @@ -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() {

Story Chronicle

- 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.

{sorted.length > 0 && (
@@ -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 (
  • )}
  • + {voiceoverUrl && ( +
    + +
    + )}
    diff --git a/src/collections/AGENTS.md b/src/collections/AGENTS.md index 4c63975..18b1cab 100644 --- a/src/collections/AGENTS.md +++ b/src/collections/AGENTS.md @@ -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 diff --git a/src/collections/story/StoryBeats.ts b/src/collections/story/StoryBeats.ts index 03d33d3..61fa686 100644 --- a/src/collections/story/StoryBeats.ts +++ b/src/collections/story/StoryBeats.ts @@ -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.", + }, + }, ], }; diff --git a/src/collections/story/Voiceovers.ts b/src/collections/story/Voiceovers.ts new file mode 100644 index 0000000..9068dbb --- /dev/null +++ b/src/collections/story/Voiceovers.ts @@ -0,0 +1,44 @@ +import path from "path"; +import type { CollectionConfig } from "payload"; +import hasRoles from "@/utils/access-control/hasRoles"; + +function isStoryAuthor(user: Parameters[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 /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.", + }, + }, + ], +}; diff --git a/src/components/frontend/story/BeatVoiceover.tsx b/src/components/frontend/story/BeatVoiceover.tsx new file mode 100644 index 0000000..a4c3804 --- /dev/null +++ b/src/components/frontend/story/BeatVoiceover.tsx @@ -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(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) { + 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 ( +
    +
    +
    + {aiGenerated &&

    This audio narration was generated using AI. As a result, the quality of the narration may be inconsistent.

    Want to narrate for the Chronicle? Reach out to Jason to sign up!

    } +
    + ); +} diff --git a/src/components/frontend/story/StoryTableOfContents.tsx b/src/components/frontend/story/StoryTableOfContents.tsx index 4f0efd2..8dd3ba9 100644 --- a/src/components/frontend/story/StoryTableOfContents.tsx +++ b/src/components/frontend/story/StoryTableOfContents.tsx @@ -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 (