337 lines
12 KiB
TypeScript
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 & generate</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
We'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>
|
|
);
|
|
}
|