Files
2026-06-07 03:58:32 -04:00

337 lines
12 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, ArrowRight, Loader2, Sparkles, User } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { TONES, FORMATS, LANGUAGES, LENGTH_OPTIONS, FORMAT_SPEAKERS } from "@/lib/episodes/options";
import { VOICE_CATALOG, DEFAULT_VOICE_IDS } from "@/lib/ai/voices";
import { createEpisodeAction, type CreateEpisodeInput } from "@/app/(app)/episodes/actions";
type Format = "SOLO" | "INTERVIEW" | "MULTI_HOST";
interface SpeakerState {
speakerKey: string;
displayName: string;
elevenVoiceId: string;
}
function defaultSpeakers(format: Format): SpeakerState[] {
return FORMAT_SPEAKERS[format].map((s, i) => ({
speakerKey: s.speakerKey,
displayName: s.defaultName,
elevenVoiceId:
DEFAULT_VOICE_IDS[s.speakerKey] ?? VOICE_CATALOG[i % VOICE_CATALOG.length].id,
}));
}
export function EpisodeWizard({ maxMinutes }: { maxMinutes: number }) {
const router = useRouter();
const [step, setStep] = useState(1);
const [submitting, setSubmitting] = useState(false);
const [title, setTitle] = useState("");
const [topic, setTopic] = useState("");
const [tone, setTone] = useState<string>(TONES[0]);
const [format, setFormat] = useState<Format>("SOLO");
const [language, setLanguage] = useState("en");
const [length, setLength] = useState(5);
const [audience, setAudience] = useState("");
const [speakers, setSpeakers] = useState<SpeakerState[]>(defaultSpeakers("SOLO"));
const lengths = useMemo(() => LENGTH_OPTIONS.filter((l) => l <= maxMinutes), [maxMinutes]);
function changeFormat(f: Format) {
setFormat(f);
setSpeakers(defaultSpeakers(f));
}
function updateSpeaker(idx: number, patch: Partial<SpeakerState>) {
setSpeakers((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } : s)));
}
function next() {
if (step === 1 && topic.trim().length < 10) {
toast.error("Please describe your topic in a bit more detail.");
return;
}
setStep((s) => Math.min(3, s + 1));
}
async function submit() {
setSubmitting(true);
const input: CreateEpisodeInput = {
title: title.trim() || undefined,
topic: topic.trim(),
tone,
format,
language,
targetLengthMin: length,
audience: audience.trim() || undefined,
speakers,
};
const res = await createEpisodeAction(input);
if (!res.ok) {
toast.error(res.error);
setSubmitting(false);
return;
}
toast.success("Generating your episode…");
router.push(`/episodes/${res.episodeId}`);
}
return (
<div className="mx-auto max-w-2xl">
<Stepper step={step} />
<Card className="mt-6">
<CardContent className="space-y-6 p-6">
{step === 1 && (
<>
<Field label="What's your episode about?" htmlFor="topic">
<Textarea
id="topic"
rows={4}
placeholder="e.g. The surprising history of coffee and how it shaped global trade…"
value={topic}
onChange={(e) => setTopic(e.target.value)}
/>
</Field>
<Field label="Episode title (optional)" htmlFor="title">
<Input
id="title"
placeholder="Leave blank to auto-generate"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Tone">
<SimpleSelect value={tone} onChange={setTone} options={TONES.map((t) => ({ value: t, label: t }))} />
</Field>
<Field label="Language">
<SimpleSelect
value={language}
onChange={setLanguage}
options={LANGUAGES.map((l) => ({ value: l.code, label: l.label }))}
/>
</Field>
<Field label="Length">
<SimpleSelect
value={String(length)}
onChange={(v) => setLength(Number(v))}
options={lengths.map((l) => ({ value: String(l), label: `${l} minutes` }))}
/>
</Field>
<Field label="Audience (optional)" htmlFor="audience">
<Input
id="audience"
placeholder="e.g. busy professionals"
value={audience}
onChange={(e) => setAudience(e.target.value)}
/>
</Field>
</div>
<Field label="Format">
<div className="grid gap-3 sm:grid-cols-3">
{FORMATS.map((f) => (
<button
key={f.value}
type="button"
onClick={() => changeFormat(f.value)}
className={cn(
"rounded-2xl border p-4 text-left transition-colors",
format === f.value
? "border-brand bg-brand/5 ring-1 ring-brand"
: "border-border hover:bg-secondary"
)}
>
<p className="text-sm font-semibold">{f.label}</p>
<p className="mt-1 text-xs text-muted-foreground">{f.description}</p>
</button>
))}
</div>
</Field>
</>
)}
{step === 2 && (
<>
<div>
<h2 className="text-lg font-semibold">Cast your voices</h2>
<p className="text-sm text-muted-foreground">
Assign a realistic AI voice to each speaker.
</p>
</div>
<div className="space-y-4">
{speakers.map((sp, idx) => (
<div key={sp.speakerKey} className="rounded-2xl border border-border p-4">
<div className="mb-3 flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-brand/10 text-brand">
<User className="h-4 w-4" />
</span>
<span className="text-sm font-medium capitalize">{sp.speakerKey}</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Display name">
<Input
value={sp.displayName}
onChange={(e) => updateSpeaker(idx, { displayName: e.target.value })}
/>
</Field>
<Field label="Voice">
<SimpleSelect
value={sp.elevenVoiceId}
onChange={(v) => updateSpeaker(idx, { elevenVoiceId: v })}
options={VOICE_CATALOG.map((v) => ({
value: v.id,
label: `${v.name} · ${v.gender} · ${v.accent}`,
}))}
/>
</Field>
</div>
</div>
))}
</div>
</>
)}
{step === 3 && (
<>
<div>
<h2 className="text-lg font-semibold">Review &amp; generate</h2>
<p className="text-sm text-muted-foreground">
We&apos;ll write the script, record the audio, and design the cover art.
</p>
</div>
<dl className="grid grid-cols-2 gap-3 text-sm">
<Summary label="Title" value={title || "Auto-generated"} />
<Summary label="Format" value={FORMATS.find((f) => f.value === format)?.label ?? format} />
<Summary label="Tone" value={tone} />
<Summary label="Length" value={`${length} min`} />
<Summary label="Language" value={LANGUAGES.find((l) => l.code === language)?.label ?? language} />
<Summary label="Voices" value={speakers.map((s) => s.displayName).join(", ")} />
</dl>
<div className="flex items-center gap-2 rounded-2xl border border-border bg-secondary p-4 text-sm text-muted-foreground">
<Badge variant="brand">Heads up</Badge>
Generating uses 1 script + 1 audio credit from your monthly plan.
</div>
</>
)}
<div className="flex items-center justify-between pt-2">
<Button
variant="ghost"
onClick={() => setStep((s) => Math.max(1, s - 1))}
disabled={step === 1 || submitting}
>
<ArrowLeft className="h-4 w-4" /> Back
</Button>
{step < 3 ? (
<Button onClick={next}>
Continue <ArrowRight className="h-4 w-4" />
</Button>
) : (
<Button onClick={submit} disabled={submitting}>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
Generate episode
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
}
function Stepper({ step }: { step: number }) {
const labels = ["Configure", "Voices", "Review"];
return (
<div className="flex items-center justify-center gap-2">
{labels.map((label, i) => {
const n = i + 1;
const active = step === n;
const done = step > n;
return (
<div key={label} className="flex items-center gap-2">
<span
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold transition-colors",
active || done ? "bg-brand text-brand-foreground" : "bg-secondary text-muted-foreground"
)}
>
{n}
</span>
<span className={cn("text-sm", active ? "font-medium" : "text-muted-foreground")}>{label}</span>
{i < labels.length - 1 && <span className="mx-1 h-px w-6 bg-border" />}
</div>
);
})}
</div>
);
}
function Field({
label,
htmlFor,
children,
}: {
label: string;
htmlFor?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-2">
<Label htmlFor={htmlFor}>{label}</Label>
{children}
</div>
);
}
function SimpleSelect({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function Summary({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-border p-3">
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="mt-0.5 font-medium">{value}</dd>
</div>
);
}