1
0
Fork 0
polaris-task-force/src/app/(frontend)/voiceover/page.tsx
Z8MB1E 4d08122464 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>
2026-09-14 15:37:12 -04:00

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>
);
}