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>
219 lines
7.2 KiB
TypeScript
219 lines
7.2 KiB
TypeScript
import type { Metadata } from "next";
|
|
import Link from "next/link";
|
|
import { headers as nextHeaders } from "next/headers";
|
|
import { redirect } from "next/navigation";
|
|
import { getPayload } from "payload";
|
|
import config from "@payload-config";
|
|
import {
|
|
ArrowRightIcon,
|
|
CheckCircle2Icon,
|
|
Clock3Icon,
|
|
EyeOffIcon,
|
|
MicIcon,
|
|
SparklesIcon,
|
|
TriangleAlertIcon,
|
|
} from "lucide-react";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
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 = {
|
|
title: "Voiceover: Polaris Task Force",
|
|
description: "Record voiceover takes for active Polaris Task Force scripts.",
|
|
};
|
|
|
|
const submissionStatusClasses: Record<string, string> = {
|
|
pending: "border-amber-500/40 text-amber-400",
|
|
accepted: "border-emerald-500/40 text-emerald-400",
|
|
rejected: "border-red-500/40 text-red-400",
|
|
};
|
|
|
|
function ScriptAvailability({ status }: { readonly status: string }) {
|
|
const isDraft = status === "draft";
|
|
const isOpen = status === "open";
|
|
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
"shrink-0 gap-1",
|
|
isOpen && "border-emerald-500/40 text-emerald-400",
|
|
isDraft && "border-violet-500/50 text-violet-300",
|
|
!isOpen && !isDraft && "text-muted-foreground",
|
|
)}
|
|
>
|
|
{isDraft ? (
|
|
<EyeOffIcon className="size-3" />
|
|
) : isOpen ? (
|
|
<CheckCircle2Icon className="size-3" />
|
|
) : (
|
|
<Clock3Icon className="size-3" />
|
|
)}
|
|
{isDraft ? "Draft" : isOpen ? "Open" : "Closed"}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
function SubmissionStatuses({ script }: { readonly script: VoiceoverScriptView }) {
|
|
if (script.mySubmissions.length === 0) {
|
|
return <p className="text-xs text-muted-foreground">No takes submitted yet.</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-2" aria-label="Your take statuses">
|
|
{script.mySubmissions.map((submission, index) => (
|
|
<Badge
|
|
key={submission.id}
|
|
variant="outline"
|
|
className={cn(
|
|
"font-normal capitalize",
|
|
submissionStatusClasses[submission.status] ?? "text-muted-foreground",
|
|
)}
|
|
>
|
|
Take {index + 1}: {submission.status}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SpoilerBadge() {
|
|
return (
|
|
<Badge variant="outline" className="gap-1 border-amber-500/40 text-amber-300">
|
|
<TriangleAlertIcon className="size-3" />
|
|
Spoilers
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
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",
|
|
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">
|
|
{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"
|
|
>
|
|
{script.title}
|
|
</Link>
|
|
) : (
|
|
script.title
|
|
)}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{(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">
|
|
{script.spoiler && <SpoilerBadge />}
|
|
<ScriptAvailability status={script.status} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge variant="outline" className="gap-1 border-amber-500/40 text-amber-300">
|
|
<SparklesIcon className="size-3" />+{script.xpAmount} XP
|
|
</Badge>
|
|
<span className="text-xs text-muted-foreground">
|
|
{script.otherSubmissionCount} other submission
|
|
{script.otherSubmissionCount === 1 ? "" : "s"}
|
|
</span>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="flex flex-1 flex-col gap-4">
|
|
<div className="flex flex-col gap-2">
|
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
Your submissions
|
|
</span>
|
|
<SubmissionStatuses script={script} />
|
|
</div>
|
|
|
|
{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" />
|
|
Record a take
|
|
<ArrowRightIcon data-icon="inline-end" />
|
|
</Link>
|
|
</Button>
|
|
) : (
|
|
<p className="mt-auto text-sm text-muted-foreground">
|
|
This script is closed to new takes.
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export default async function VoiceoverPage() {
|
|
const headers = await nextHeaders();
|
|
const payloadConfig = await config;
|
|
const payload = await getPayload({ config: payloadConfig });
|
|
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
|
|
|
if (!isPayloadUser(user)) {
|
|
redirect(`/login?returnTo=${encodeURIComponent("/voiceover")}`);
|
|
}
|
|
|
|
const scripts = await listScriptsForUser(payload, user);
|
|
|
|
return (
|
|
<div className="flex min-w-0 flex-col gap-6 p-5">
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center gap-2">
|
|
<MicIcon className="size-5 text-muted-foreground" />
|
|
<h1 className="text-lg font-semibold">Voiceover submissions</h1>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
Choose an active script, read it in the teleprompter, and submit your best take.
|
|
</p>
|
|
</div>
|
|
|
|
{scripts.length > 0 ? (
|
|
<div className="grid min-w-0 gap-4 xl:grid-cols-2">
|
|
{scripts.map((script) => (
|
|
<ScriptCard key={script.id} script={script} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center gap-3 p-8 text-center">
|
|
<MicIcon className="size-8 text-muted-foreground" />
|
|
<div className="flex flex-col gap-1">
|
|
<h2 className="font-semibold">No scripts available</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
New voiceover opportunities will appear here when they are published.
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|