From 4d08122464236c8c6691a520ddd037b332da8996 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Mon, 14 Sep 2026 15:37:12 -0400 Subject: [PATCH] feat(voiceover): spoiler gate, pacing pattern, and recorder updates Recorder and pages gain a spoiler gate; voiceover lib adds clause-pause pacing support. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/app/(frontend)/voiceover/[id]/page.tsx | 89 ++++++---- src/app/(frontend)/voiceover/page.tsx | 38 +++- .../frontend/voiceover/SpoilerGate.tsx | 162 ++++++++++++++++++ .../frontend/voiceover/VoiceoverRecorder.tsx | 116 +------------ src/lib/voiceover/emotionTags.ts | 13 +- src/lib/voiceover/index.ts | 7 +- 6 files changed, 263 insertions(+), 162 deletions(-) create mode 100644 src/components/frontend/voiceover/SpoilerGate.tsx diff --git a/src/app/(frontend)/voiceover/[id]/page.tsx b/src/app/(frontend)/voiceover/[id]/page.tsx index 28680f9..8785d37 100644 --- a/src/app/(frontend)/voiceover/[id]/page.tsx +++ b/src/app/(frontend)/voiceover/[id]/page.tsx @@ -4,10 +4,11 @@ import { getPayload } from "payload"; import { headers as nextHeaders } from "next/headers"; import Link from "next/link"; import { notFound, redirect } from "next/navigation"; -import { ArrowLeftIcon, MicIcon, TriangleAlertIcon } from "lucide-react"; +import { ArrowLeftIcon, EyeOffIcon, MicIcon, TriangleAlertIcon } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { VoiceoverRecorder } from "@/components/frontend/voiceover/VoiceoverRecorder"; +import { SpoilerDescription, SpoilerGate } from "@/components/frontend/voiceover/SpoilerGate"; import { getScriptForUser } from "@/lib/voiceover"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; @@ -38,44 +39,58 @@ export default async function VoiceoverScriptPage({ if (!script) notFound(); return ( -
- - - Voiceover scripts - + +
+ + + Voiceover scripts + -
- -
-

{script.title}

- {script.description?.trim() && ( -

{script.description}

- )} -

- Read the script at your pace, then submit one clear take for review. -

+
+ +
+

{script.title}

+ +

+ Read the script at your pace, then submit one clear take for review. +

+
+ + {script.status === "draft" && ( + + + +
+

Draft preview

+

+ This script is still a draft and is hidden from other users. +

+
+
+
+ )} + + {script.spoiler && ( + + + +
+

Potential campaign spoilers ahead

+

+ This script may include, infer, or otherwise expose campaign spoilers. Read on + only if you are comfortable with that. +

+
+
+
+ )} + +
- - {script.spoiler && ( - - - -
-

Potential campaign spoilers ahead

-

- This script may include, infer, or otherwise expose campaign spoilers. Read on only - if you are comfortable with that. -

-
-
-
- )} - - -
+ ); } diff --git a/src/app/(frontend)/voiceover/page.tsx b/src/app/(frontend)/voiceover/page.tsx index d6afee9..17f490b 100644 --- a/src/app/(frontend)/voiceover/page.tsx +++ b/src/app/(frontend)/voiceover/page.tsx @@ -8,6 +8,7 @@ import { ArrowRightIcon, CheckCircle2Icon, Clock3Icon, + EyeOffIcon, MicIcon, SparklesIcon, TriangleAlertIcon, @@ -18,6 +19,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { listScriptsForUser, type VoiceoverScriptView } from "@/lib/voiceover"; import { cn } from "@/lib/utils"; +import { redactText } from "@/lib/redaction"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; export const metadata: Metadata = { @@ -32,6 +34,7 @@ const submissionStatusClasses: Record = { }; function ScriptAvailability({ status }: { readonly status: string }) { + const isDraft = status === "draft"; const isOpen = status === "open"; return ( @@ -39,11 +42,19 @@ function ScriptAvailability({ status }: { readonly status: string }) { variant="outline" className={cn( "shrink-0 gap-1", - isOpen ? "border-emerald-500/40 text-emerald-400" : "text-muted-foreground", + isOpen && "border-emerald-500/40 text-emerald-400", + isDraft && "border-violet-500/50 text-violet-300", + !isOpen && !isDraft && "text-muted-foreground", )} > - {isOpen ? : } - {isOpen ? "Open" : "Closed"} + {isDraft ? ( + + ) : isOpen ? ( + + ) : ( + + )} + {isDraft ? "Draft" : isOpen ? "Open" : "Closed"} ); } @@ -81,15 +92,23 @@ function SpoilerBadge() { } function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) { + const isDraft = script.status === "draft"; const isOpen = script.status === "open"; + const isRecordable = isOpen; return ( - +
- {isOpen ? ( + {isRecordable || isDraft ? ( - {script.description?.trim() || `Voiceover script #${script.id}`} + {(script.spoiler ? redactText(script.description) : script.description)?.trim() || + `Voiceover script #${script.id}`}
@@ -129,7 +149,11 @@ function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) {
- {isOpen ? ( + {isDraft ? ( +

+ Draft preview: this script is still hidden from other users. +

+ ) : isRecordable ? ( + ); +} + +export function SpoilerGate({ + spoiler, + children, +}: { + readonly spoiler: boolean; + readonly children: ReactNode; +}) { + const router = useRouter(); + const [acknowledged, setAcknowledged] = useState(false); + + const value = useMemo( + () => ({ + acknowledged: !spoiler || acknowledged, + acknowledge: () => setAcknowledged(true), + }), + [acknowledged, spoiler], + ); + + return ( + + {children} + {spoiler && !acknowledged && ( + + + + + + Potential campaign spoilers + + + This recording page may include, infer, or otherwise expose campaign spoilers. + Continue only if you are comfortable with that. Press and hold the confirmation + button for one second to continue. + + + + router.push("/voiceover")}> + Go back + + I understand + + + + )} + + ); +} + +/** + * Renders the script description, redacted until the spoiler gate is + * acknowledged (plain text for non-spoiler scripts). + */ +export function SpoilerDescription({ + spoiler, + description, +}: { + readonly spoiler: boolean; + readonly description: string | null; +}) { + const { acknowledged } = useSpoilerAcknowledgment(); + const text = spoiler && !acknowledged && description ? redactText(description) : description; + + if (!text?.trim()) return null; + return

{text}

; +} diff --git a/src/components/frontend/voiceover/VoiceoverRecorder.tsx b/src/components/frontend/voiceover/VoiceoverRecorder.tsx index 1dad3d8..04cd68f 100644 --- a/src/components/frontend/voiceover/VoiceoverRecorder.tsx +++ b/src/components/frontend/voiceover/VoiceoverRecorder.tsx @@ -1,31 +1,14 @@ "use client"; -import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useRouter } from "next/navigation"; -import { - CheckCircle2Icon, - LockKeyholeIcon, - MicIcon, - SparklesIcon, - TriangleAlertIcon, -} from "lucide-react"; +import { CheckCircle2Icon, LockKeyholeIcon, MicIcon, SparklesIcon } from "lucide-react"; import { toast } from "sonner"; import { submitVoiceRecording, withdrawSubmission } from "@/app/(frontend)/voiceover/actions"; -import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { buttonVariants } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import type { VoiceoverScriptView } from "@/lib/voiceover"; -import { cn } from "@/lib/utils"; +import { useSpoilerAcknowledgment } from "@/components/frontend/voiceover/SpoilerGate"; import { TeleprompterReader } from "@/components/frontend/voiceover/TeleprompterReader"; import { VoiceCapture, @@ -33,104 +16,13 @@ import { } from "@/components/frontend/voiceover/VoiceCapture"; import { VoiceoverTakes } from "@/components/frontend/voiceover/VoiceoverTakes"; -const HOLD_CONFIRM_MS = 1000; - -function HoldToConfirmButton({ - onConfirm, - children, -}: { - readonly onConfirm: () => void; - readonly children: ReactNode; -}) { - const [holding, setHolding] = useState(false); - const timerRef = useRef(null); - const doneRef = useRef(false); - - const cancel = useCallback(() => { - if (timerRef.current !== null) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - setHolding(false); - }, []); - - const begin = useCallback(() => { - if (timerRef.current !== null || doneRef.current) return; - setHolding(true); - timerRef.current = window.setTimeout(() => { - timerRef.current = null; - doneRef.current = true; - onConfirm(); - }, HOLD_CONFIRM_MS); - }, [onConfirm]); - - useEffect(() => { - return () => { - if (timerRef.current !== null) clearTimeout(timerRef.current); - }; - }, []); - - return ( - - ); -} - export function VoiceoverRecorder({ script }: { readonly script: VoiceoverScriptView }) { const router = useRouter(); const isOpen = script.status === "open"; - const [spoilerAcknowledged, setSpoilerAcknowledged] = useState(false); + const { acknowledged: spoilerAcknowledged } = useSpoilerAcknowledgment(); if (script.spoiler && !spoilerAcknowledged) { - return ( - - - - - - Potential campaign spoilers - - - This recording page may include, infer, or otherwise expose campaign spoilers. - Continue only if you are comfortable with that. Press and hold the confirmation button - for one second to continue. - - - - router.push("/voiceover")}>Go back - setSpoilerAcknowledged(true)}> - I understand - - - - - ); + return null; } async function handleSubmitTake( diff --git a/src/lib/voiceover/emotionTags.ts b/src/lib/voiceover/emotionTags.ts index b113eb0..34e7079 100644 --- a/src/lib/voiceover/emotionTags.ts +++ b/src/lib/voiceover/emotionTags.ts @@ -5,7 +5,7 @@ export interface VoiceoverSegment { readonly directive?: string; } -const TAG_PATTERN = /\[([a-z][a-z\s,-]*)\]|\{([a-z][a-z\s,-]*)\}|<([a-z]+)>/gi; +const TAG_PATTERN = /\[([a-z][a-z\s,-]*)?\]|\{([a-z][a-z\s,-]*)\}|<([a-z]+)>/gi; function normalizeScript(script: string): string { return script @@ -73,9 +73,14 @@ export function parseEmotionTags(script: string): VoiceoverSegment[] { flush(source.slice(cursor, matchIndex), false); } - const emotive = normalizeTag(match[1] ?? ""); - if (emotive) { - pendingTags.push(emotive); + if (match[1] !== undefined) { + const emotive = normalizeTag(match[1]); + if (emotive) { + pendingTags.push(emotive); + } else { + // Empty [] is an explicit reset: the next text renders plain again. + pendingTags = []; + } } const sfx = normalizeTag(match[2] ?? ""); diff --git a/src/lib/voiceover/index.ts b/src/lib/voiceover/index.ts index ee54609..6d8f88b 100644 --- a/src/lib/voiceover/index.ts +++ b/src/lib/voiceover/index.ts @@ -1,4 +1,5 @@ import type { User, VoiceScript, VoiceSubmission } from "@/payload-types"; +import { isSuperuser } from "@/utils/access-control/hasPermission"; type PayloadType = Awaited>; @@ -87,10 +88,11 @@ export async function listScriptsForUser( payload: PayloadType, user: User, ): Promise { + const canSeeDrafts = await isSuperuser(payload, user); const [scriptsRes, submissionsRes] = await Promise.all([ payload.find({ collection: "voice-scripts", - where: { status: { not_equals: "draft" } }, + where: canSeeDrafts ? undefined : { status: { not_equals: "draft" } }, sort: "-createdAt", limit: 0, depth: 0, @@ -122,7 +124,8 @@ export async function getScriptForUser( overrideAccess: true, }) .catch(() => null); - if (!script || script.status === "draft") return null; + if (!script) return null; + if (script.status === "draft" && !(await isSuperuser(payload, user))) return null; const submissionsRes = await payload.find({ collection: "voice-submissions",