From de2ca66ceca9ac26a1edbf3bb2a43fe58db29246 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Thu, 10 Sep 2026 16:14:02 -0400 Subject: [PATCH] feat(voiceover): add hold-to-confirm spoiler gate on recording pages Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- bun.lock | 1 + package.json | 1 + .../frontend/voiceover/VoiceoverRecorder.tsx | 116 ++++++++++++++++- src/components/ui/alert-dialog.tsx | 117 ++++++++++++++++++ 4 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/alert-dialog.tsx diff --git a/bun.lock b/bun.lock index d0189bd..2e19725 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "@payloadcms/plugin-mcp": "^3.88.0", "@payloadcms/richtext-lexical": "3.88.0", "@payloadcms/ui": "3.88.0", + "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-avatar": "^1.2.6", "@radix-ui/react-checkbox": "^1.3.11", "@radix-ui/react-collapsible": "^1.1.20", diff --git a/package.json b/package.json index a14adad..2ad5338 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@payloadcms/plugin-mcp": "^3.88.0", "@payloadcms/richtext-lexical": "3.88.0", "@payloadcms/ui": "3.88.0", + "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-avatar": "^1.2.6", "@radix-ui/react-checkbox": "^1.3.11", "@radix-ui/react-collapsible": "^1.1.20", diff --git a/src/components/frontend/voiceover/VoiceoverRecorder.tsx b/src/components/frontend/voiceover/VoiceoverRecorder.tsx index 43f7330..1dad3d8 100644 --- a/src/components/frontend/voiceover/VoiceoverRecorder.tsx +++ b/src/components/frontend/voiceover/VoiceoverRecorder.tsx @@ -1,13 +1,31 @@ "use client"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useRouter } from "next/navigation"; -import { CheckCircle2Icon, LockKeyholeIcon, MicIcon, SparklesIcon } from "lucide-react"; +import { + CheckCircle2Icon, + LockKeyholeIcon, + MicIcon, + SparklesIcon, + TriangleAlertIcon, +} 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 { TeleprompterReader } from "@/components/frontend/voiceover/TeleprompterReader"; import { VoiceCapture, @@ -15,9 +33,105 @@ 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); + + 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 + + + + + ); + } async function handleSubmitTake( source: VoiceCaptureSource, diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..caffe9b --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,117 @@ +"use client"; + +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +};