Initial commit: PodcastYes — AI podcast platform
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user