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 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 (
|
||||
<div className="flex min-w-0 flex-col gap-6 p-5">
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
<SpoilerGate spoiler={script.spoiler}>
|
||||
<div className="flex min-w-0 flex-col gap-6 p-5">
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<MicIcon className="mt-0.5 size-5 shrink-0 text-muted-foreground" />
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold">{script.title}</h1>
|
||||
{script.description?.trim() && (
|
||||
<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>
|
||||
<div className="flex items-start gap-3">
|
||||
<MicIcon className="mt-0.5 size-5 shrink-0 text-muted-foreground" />
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold">{script.title}</h1>
|
||||
<SpoilerDescription spoiler={script.spoiler} description={script.description} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Read the script at your pace, then submit one clear take for review.
|
||||
</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>
|
||||
|
||||
{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>
|
||||
</SpoilerGate>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
|||
};
|
||||
|
||||
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 ? <CheckCircle2Icon className="size-3" /> : <Clock3Icon className="size-3" />}
|
||||
{isOpen ? "Open" : "Closed"}
|
||||
{isDraft ? (
|
||||
<EyeOffIcon className="size-3" />
|
||||
) : isOpen ? (
|
||||
<CheckCircle2Icon className="size-3" />
|
||||
) : (
|
||||
<Clock3Icon className="size-3" />
|
||||
)}
|
||||
{isDraft ? "Draft" : isOpen ? "Open" : "Closed"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<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">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<CardTitle className="min-w-0">
|
||||
{isOpen ? (
|
||||
{isRecordable || isDraft ? (
|
||||
<Link
|
||||
href={`/voiceover/${script.id}`}
|
||||
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>
|
||||
<CardDescription>
|
||||
{script.description?.trim() || `Voiceover script #${script.id}`}
|
||||
{(script.spoiler ? redactText(script.description) : script.description)?.trim() ||
|
||||
`Voiceover script #${script.id}`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<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} />
|
||||
</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">
|
||||
<Link href={`/voiceover/${script.id}`}>
|
||||
<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";
|
||||
|
||||
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<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 }) {
|
||||
const router = useRouter();
|
||||
const isOpen = script.status === "open";
|
||||
const [spoilerAcknowledged, setSpoilerAcknowledged] = useState(false);
|
||||
const { acknowledged: spoilerAcknowledged } = useSpoilerAcknowledgment();
|
||||
|
||||
if (script.spoiler && !spoilerAcknowledged) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleSubmitTake(
|
||||
|
|
|
|||
|
|
@ -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] ?? "");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { User, VoiceScript, VoiceSubmission } from "@/payload-types";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
|
||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
|
||||
|
|
@ -87,10 +88,11 @@ export async function listScriptsForUser(
|
|||
payload: PayloadType,
|
||||
user: User,
|
||||
): Promise<VoiceoverScriptView[]> {
|
||||
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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue