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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
982c33f4f3
commit
4d08122464
6 changed files with 263 additions and 162 deletions
|
|
@ -4,10 +4,11 @@ import { getPayload } from "payload";
|
||||||
import { headers as nextHeaders } from "next/headers";
|
import { headers as nextHeaders } from "next/headers";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound, redirect } from "next/navigation";
|
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 { Card, CardContent } from "@/components/ui/card";
|
||||||
import { VoiceoverRecorder } from "@/components/frontend/voiceover/VoiceoverRecorder";
|
import { VoiceoverRecorder } from "@/components/frontend/voiceover/VoiceoverRecorder";
|
||||||
|
import { SpoilerDescription, SpoilerGate } from "@/components/frontend/voiceover/SpoilerGate";
|
||||||
import { getScriptForUser } from "@/lib/voiceover";
|
import { getScriptForUser } from "@/lib/voiceover";
|
||||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
|
||||||
|
|
@ -38,44 +39,58 @@ export default async function VoiceoverScriptPage({
|
||||||
if (!script) notFound();
|
if (!script) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-col gap-6 p-5">
|
<SpoilerGate spoiler={script.spoiler}>
|
||||||
<Link
|
<div className="flex min-w-0 flex-col gap-6 p-5">
|
||||||
href="/voiceover"
|
<Link
|
||||||
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
href="/voiceover"
|
||||||
>
|
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
<ArrowLeftIcon className="size-3" />
|
>
|
||||||
Voiceover scripts
|
<ArrowLeftIcon className="size-3" />
|
||||||
</Link>
|
Voiceover scripts
|
||||||
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<MicIcon className="mt-0.5 size-5 shrink-0 text-muted-foreground" />
|
<MicIcon className="mt-0.5 size-5 shrink-0 text-muted-foreground" />
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<h1 className="text-lg font-semibold">{script.title}</h1>
|
<h1 className="text-lg font-semibold">{script.title}</h1>
|
||||||
{script.description?.trim() && (
|
<SpoilerDescription spoiler={script.spoiler} description={script.description} />
|
||||||
<p className="text-sm text-foreground/80">{script.description}</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
)}
|
Read the script at your pace, then submit one clear take for review.
|
||||||
<p className="text-sm text-muted-foreground">
|
</p>
|
||||||
Read the script at your pace, then submit one clear take for review.
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{script.status === "draft" && (
|
||||||
|
<Card className="border-dashed border-violet-500/50">
|
||||||
|
<CardContent className="flex items-start gap-3 p-4 text-sm">
|
||||||
|
<EyeOffIcon className="mt-0.5 size-4 shrink-0 text-violet-300" />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-medium text-violet-300">Draft preview</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
This script is still a draft and is hidden from other users.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{script.spoiler && (
|
||||||
|
<Card className="border-amber-500/40">
|
||||||
|
<CardContent className="flex items-start gap-3 p-4 text-sm">
|
||||||
|
<TriangleAlertIcon className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-medium text-amber-300">Potential campaign spoilers ahead</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
This script may include, infer, or otherwise expose campaign spoilers. Read on
|
||||||
|
only if you are comfortable with that.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VoiceoverRecorder script={script} />
|
||||||
</div>
|
</div>
|
||||||
|
</SpoilerGate>
|
||||||
{script.spoiler && (
|
|
||||||
<Card className="border-amber-500/40">
|
|
||||||
<CardContent className="flex items-start gap-3 p-4 text-sm">
|
|
||||||
<TriangleAlertIcon className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<p className="font-medium text-amber-300">Potential campaign spoilers ahead</p>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
This script may include, infer, or otherwise expose campaign spoilers. Read on only
|
|
||||||
if you are comfortable with that.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<VoiceoverRecorder script={script} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
CheckCircle2Icon,
|
CheckCircle2Icon,
|
||||||
Clock3Icon,
|
Clock3Icon,
|
||||||
|
EyeOffIcon,
|
||||||
MicIcon,
|
MicIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TriangleAlertIcon,
|
TriangleAlertIcon,
|
||||||
|
|
@ -18,6 +19,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { listScriptsForUser, type VoiceoverScriptView } from "@/lib/voiceover";
|
import { listScriptsForUser, type VoiceoverScriptView } from "@/lib/voiceover";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { redactText } from "@/lib/redaction";
|
||||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
|
@ -32,6 +34,7 @@ const submissionStatusClasses: Record<string, string> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function ScriptAvailability({ status }: { readonly status: string }) {
|
function ScriptAvailability({ status }: { readonly status: string }) {
|
||||||
|
const isDraft = status === "draft";
|
||||||
const isOpen = status === "open";
|
const isOpen = status === "open";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -39,11 +42,19 @@ function ScriptAvailability({ status }: { readonly status: string }) {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 gap-1",
|
"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 ? <CheckCircle2Icon className="size-3" /> : <Clock3Icon className="size-3" />}
|
{isDraft ? (
|
||||||
{isOpen ? "Open" : "Closed"}
|
<EyeOffIcon className="size-3" />
|
||||||
|
) : isOpen ? (
|
||||||
|
<CheckCircle2Icon className="size-3" />
|
||||||
|
) : (
|
||||||
|
<Clock3Icon className="size-3" />
|
||||||
|
)}
|
||||||
|
{isDraft ? "Draft" : isOpen ? "Open" : "Closed"}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -81,15 +92,23 @@ function SpoilerBadge() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) {
|
function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) {
|
||||||
|
const isDraft = script.status === "draft";
|
||||||
const isOpen = script.status === "open";
|
const isOpen = script.status === "open";
|
||||||
|
const isRecordable = isOpen;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn("flex h-full flex-col", !isOpen && "opacity-75")}>
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"flex h-full flex-col",
|
||||||
|
isDraft && "border-dashed border-violet-500/50",
|
||||||
|
!isOpen && !isDraft && "opacity-75",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<CardHeader className="gap-3">
|
<CardHeader className="gap-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 flex-col gap-2">
|
<div className="flex min-w-0 flex-col gap-2">
|
||||||
<CardTitle className="min-w-0">
|
<CardTitle className="min-w-0">
|
||||||
{isOpen ? (
|
{isRecordable || isDraft ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/voiceover/${script.id}`}
|
href={`/voiceover/${script.id}`}
|
||||||
className="rounded-sm outline-none transition-colors hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
className="rounded-sm outline-none transition-colors hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
|
@ -101,7 +120,8 @@ function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) {
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{script.description?.trim() || `Voiceover script #${script.id}`}
|
{(script.spoiler ? redactText(script.description) : script.description)?.trim() ||
|
||||||
|
`Voiceover script #${script.id}`}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
|
|
@ -129,7 +149,11 @@ function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) {
|
||||||
<SubmissionStatuses script={script} />
|
<SubmissionStatuses script={script} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isOpen ? (
|
{isDraft ? (
|
||||||
|
<p className="mt-auto text-sm text-violet-300/90">
|
||||||
|
Draft preview: this script is still hidden from other users.
|
||||||
|
</p>
|
||||||
|
) : isRecordable ? (
|
||||||
<Button asChild className="mt-auto w-full sm:w-fit">
|
<Button asChild className="mt-auto w-full sm:w-fit">
|
||||||
<Link href={`/voiceover/${script.id}`}>
|
<Link href={`/voiceover/${script.id}`}>
|
||||||
<MicIcon data-icon="inline-start" />
|
<MicIcon data-icon="inline-start" />
|
||||||
|
|
|
||||||
162
src/components/frontend/voiceover/SpoilerGate.tsx
Normal file
162
src/components/frontend/voiceover/SpoilerGate.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { createContext, useContext, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { TriangleAlertIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { redactText } from "@/lib/redaction";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const HOLD_CONFIRM_MS = 1000;
|
||||||
|
|
||||||
|
interface SpoilerAcknowledgment {
|
||||||
|
readonly acknowledged: boolean;
|
||||||
|
readonly acknowledge: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to acknowledged so consumers outside a gate are never blocked.
|
||||||
|
const SpoilerContext = createContext<SpoilerAcknowledgment>({
|
||||||
|
acknowledged: true,
|
||||||
|
acknowledge: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useSpoilerAcknowledgment(): SpoilerAcknowledgment {
|
||||||
|
return useContext(SpoilerContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoldToConfirmButton({
|
||||||
|
onConfirm,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
readonly onConfirm: () => void;
|
||||||
|
readonly children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [holding, setHolding] = useState(false);
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
const doneRef = useRef(false);
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
setHolding(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const begin = () => {
|
||||||
|
if (timerRef.current !== null || doneRef.current) return;
|
||||||
|
setHolding(true);
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
timerRef.current = null;
|
||||||
|
doneRef.current = true;
|
||||||
|
onConfirm();
|
||||||
|
}, HOLD_CONFIRM_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(buttonVariants(), "relative touch-none select-none overflow-hidden")}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (event.button === 0) begin();
|
||||||
|
}}
|
||||||
|
onPointerUp={cancel}
|
||||||
|
onPointerLeave={cancel}
|
||||||
|
onPointerCancel={cancel}
|
||||||
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if ((event.key === " " || event.key === "Enter") && !event.repeat) begin();
|
||||||
|
}}
|
||||||
|
onKeyUp={(event) => {
|
||||||
|
if (event.key === " " || event.key === "Enter") cancel();
|
||||||
|
}}
|
||||||
|
onBlur={cancel}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="absolute inset-0">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 origin-left bg-primary-foreground/40 transition-transform ease-linear",
|
||||||
|
holding ? "scale-x-100 duration-[1000ms]" : "scale-x-0 duration-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="relative">{children}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<SpoilerContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
{spoiler && !acknowledged && (
|
||||||
|
<AlertDialog open>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<TriangleAlertIcon className="size-5 text-amber-400" />
|
||||||
|
Potential campaign spoilers
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
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.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => router.push("/voiceover")}>
|
||||||
|
Go back
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<HoldToConfirmButton onConfirm={value.acknowledge}>I understand</HoldToConfirmButton>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</SpoilerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 <p className="text-sm text-foreground/80">{text}</p>;
|
||||||
|
}
|
||||||
|
|
@ -1,31 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import { CheckCircle2Icon, LockKeyholeIcon, MicIcon, SparklesIcon } from "lucide-react";
|
||||||
CheckCircle2Icon,
|
|
||||||
LockKeyholeIcon,
|
|
||||||
MicIcon,
|
|
||||||
SparklesIcon,
|
|
||||||
TriangleAlertIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { submitVoiceRecording, withdrawSubmission } from "@/app/(frontend)/voiceover/actions";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import type { VoiceoverScriptView } from "@/lib/voiceover";
|
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 { TeleprompterReader } from "@/components/frontend/voiceover/TeleprompterReader";
|
||||||
import {
|
import {
|
||||||
VoiceCapture,
|
VoiceCapture,
|
||||||
|
|
@ -33,104 +16,13 @@ import {
|
||||||
} from "@/components/frontend/voiceover/VoiceCapture";
|
} from "@/components/frontend/voiceover/VoiceCapture";
|
||||||
import { VoiceoverTakes } from "@/components/frontend/voiceover/VoiceoverTakes";
|
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<number | null>(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 (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(buttonVariants(), "relative touch-none select-none overflow-hidden")}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
if (event.button === 0) begin();
|
|
||||||
}}
|
|
||||||
onPointerUp={cancel}
|
|
||||||
onPointerLeave={cancel}
|
|
||||||
onPointerCancel={cancel}
|
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if ((event.key === " " || event.key === "Enter") && !event.repeat) begin();
|
|
||||||
}}
|
|
||||||
onKeyUp={(event) => {
|
|
||||||
if (event.key === " " || event.key === "Enter") cancel();
|
|
||||||
}}
|
|
||||||
onBlur={cancel}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" className="absolute inset-0">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-0 origin-left bg-primary-foreground/40 transition-transform ease-linear",
|
|
||||||
holding ? "scale-x-100 duration-[1000ms]" : "scale-x-0 duration-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="relative">{children}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VoiceoverRecorder({ script }: { readonly script: VoiceoverScriptView }) {
|
export function VoiceoverRecorder({ script }: { readonly script: VoiceoverScriptView }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOpen = script.status === "open";
|
const isOpen = script.status === "open";
|
||||||
const [spoilerAcknowledged, setSpoilerAcknowledged] = useState(false);
|
const { acknowledged: spoilerAcknowledged } = useSpoilerAcknowledgment();
|
||||||
|
|
||||||
if (script.spoiler && !spoilerAcknowledged) {
|
if (script.spoiler && !spoilerAcknowledged) {
|
||||||
return (
|
return null;
|
||||||
<AlertDialog open>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle className="flex items-center gap-2">
|
|
||||||
<TriangleAlertIcon className="size-5 text-amber-400" />
|
|
||||||
Potential campaign spoilers
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
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.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel onClick={() => router.push("/voiceover")}>Go back</AlertDialogCancel>
|
|
||||||
<HoldToConfirmButton onConfirm={() => setSpoilerAcknowledged(true)}>
|
|
||||||
I understand
|
|
||||||
</HoldToConfirmButton>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmitTake(
|
async function handleSubmitTake(
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export interface VoiceoverSegment {
|
||||||
readonly directive?: string;
|
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 {
|
function normalizeScript(script: string): string {
|
||||||
return script
|
return script
|
||||||
|
|
@ -73,9 +73,14 @@ export function parseEmotionTags(script: string): VoiceoverSegment[] {
|
||||||
flush(source.slice(cursor, matchIndex), false);
|
flush(source.slice(cursor, matchIndex), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const emotive = normalizeTag(match[1] ?? "");
|
if (match[1] !== undefined) {
|
||||||
if (emotive) {
|
const emotive = normalizeTag(match[1]);
|
||||||
pendingTags.push(emotive);
|
if (emotive) {
|
||||||
|
pendingTags.push(emotive);
|
||||||
|
} else {
|
||||||
|
// Empty [] is an explicit reset: the next text renders plain again.
|
||||||
|
pendingTags = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sfx = normalizeTag(match[2] ?? "");
|
const sfx = normalizeTag(match[2] ?? "");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { User, VoiceScript, VoiceSubmission } from "@/payload-types";
|
import type { User, VoiceScript, VoiceSubmission } from "@/payload-types";
|
||||||
|
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||||
|
|
||||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||||
|
|
||||||
|
|
@ -87,10 +88,11 @@ export async function listScriptsForUser(
|
||||||
payload: PayloadType,
|
payload: PayloadType,
|
||||||
user: User,
|
user: User,
|
||||||
): Promise<VoiceoverScriptView[]> {
|
): Promise<VoiceoverScriptView[]> {
|
||||||
|
const canSeeDrafts = await isSuperuser(payload, user);
|
||||||
const [scriptsRes, submissionsRes] = await Promise.all([
|
const [scriptsRes, submissionsRes] = await Promise.all([
|
||||||
payload.find({
|
payload.find({
|
||||||
collection: "voice-scripts",
|
collection: "voice-scripts",
|
||||||
where: { status: { not_equals: "draft" } },
|
where: canSeeDrafts ? undefined : { status: { not_equals: "draft" } },
|
||||||
sort: "-createdAt",
|
sort: "-createdAt",
|
||||||
limit: 0,
|
limit: 0,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
|
|
@ -122,7 +124,8 @@ export async function getScriptForUser(
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
})
|
})
|
||||||
.catch(() => null);
|
.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({
|
const submissionsRes = await payload.find({
|
||||||
collection: "voice-submissions",
|
collection: "voice-submissions",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue