Initial commit: PodcastYes — AI podcast platform
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Copy, KeyRound, Trash2, Check } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { createApiKeyAction, revokeApiKeyAction } from "@/app/(app)/api-keys/actions";
|
||||
|
||||
interface KeyRow {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function ApiKeysClient({ keys }: { keys: KeyRow[] }) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newKey, setNewKey] = useState<string | null>(null);
|
||||
|
||||
async function create(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
const res = await createApiKeyAction(name);
|
||||
setCreating(false);
|
||||
if (!res.ok || !res.key) {
|
||||
toast.error(res.error ?? "Could not create");
|
||||
return;
|
||||
}
|
||||
setNewKey(res.key);
|
||||
setName("");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function revoke(id: string) {
|
||||
if (!confirm("Revoke this key? Apps using it will stop working.")) return;
|
||||
const res = await revokeApiKeyAction(id);
|
||||
if (res.ok) {
|
||||
toast.success("Key revoked");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.error ?? "Could not revoke");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{newKey && (
|
||||
<Card className="ring-2 ring-brand">
|
||||
<CardContent className="space-y-2 py-4">
|
||||
<p className="text-sm font-medium">Your new API key — copy it now, it won't be shown again.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 truncate rounded-lg bg-secondary px-2.5 py-1.5 text-xs">{newKey}</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(newKey);
|
||||
toast.success("Copied");
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" /> Copy
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<form onSubmit={create} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Key name (e.g. Production)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" disabled={creating}>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
|
||||
Create key
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<p className="text-center text-sm text-muted-foreground">No API keys yet.</p>
|
||||
) : (
|
||||
<div className="divide-y rounded-lg border">
|
||||
{keys.map((k) => (
|
||||
<div key={k.id} className="flex items-center justify-between gap-3 p-4">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{k.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<code>{k.prefix}</code> · created {new Date(k.createdAt).toLocaleDateString()}
|
||||
{k.lastUsedAt ? ` · last used ${new Date(k.lastUsedAt).toLocaleDateString()}` : " · never used"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="text-destructive" onClick={() => revoke(k.id)}>
|
||||
<Trash2 className="h-4 w-4" /> Revoke
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Download } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function formatDuration(sec?: number | null): string {
|
||||
if (!sec) return "";
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function AudioPlayer({
|
||||
storageKey,
|
||||
durationSec,
|
||||
}: {
|
||||
storageKey: string;
|
||||
durationSec?: number | null;
|
||||
}) {
|
||||
const src = `/api/assets/${storageKey}`;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<audio controls src={src} className="w-full" preload="metadata" />
|
||||
<div className="flex items-center justify-between">
|
||||
{durationSec ? (
|
||||
<span className="text-xs text-muted-foreground">Length {formatDuration(durationSec)}</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={`${src}?download=1`} download>
|
||||
<Download className="h-4 w-4" /> Download MP3
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, Loader2, CreditCard, ExternalLink } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn, formatPrice } from "@/lib/utils";
|
||||
import { PLAN_ORDER, PLANS, type PlanKey } from "@/lib/billing/plans";
|
||||
import type { BillingInterval } from "@/lib/billing/catalog";
|
||||
import {
|
||||
startStripeCheckoutAction,
|
||||
startPaypalCheckoutAction,
|
||||
openStripePortalAction,
|
||||
cancelSubscriptionAction,
|
||||
} from "@/app/(app)/billing/actions";
|
||||
|
||||
interface SubInfo {
|
||||
provider: string;
|
||||
status: string;
|
||||
cancelAtPeriodEnd: boolean | null;
|
||||
periodEnd: string | null;
|
||||
}
|
||||
|
||||
export function BillingClient({
|
||||
currentPlan,
|
||||
subscription,
|
||||
stripeConfigured,
|
||||
paypalConfigured,
|
||||
}: {
|
||||
currentPlan: PlanKey;
|
||||
subscription: SubInfo | null;
|
||||
stripeConfigured: boolean;
|
||||
paypalConfigured: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [interval, setInterval] = useState<BillingInterval>("month");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
async function go(action: () => Promise<{ ok: boolean; url?: string; error?: string }>, tag: string) {
|
||||
setBusy(tag);
|
||||
const res = await action();
|
||||
if (res.ok && res.url) {
|
||||
window.location.href = res.url;
|
||||
return;
|
||||
}
|
||||
setBusy(null);
|
||||
if (res.ok) {
|
||||
toast.success("Done");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.error ?? "Something went wrong");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{subscription && currentPlan !== "free" && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-4 py-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold capitalize">{PLANS[currentPlan].name} plan</span>
|
||||
<Badge variant={subscription.status === "active" ? "success" : "warning"}>
|
||||
{subscription.cancelAtPeriodEnd ? "Cancels at period end" : subscription.status}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="capitalize">{subscription.provider}</Badge>
|
||||
</div>
|
||||
{subscription.periodEnd && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{subscription.cancelAtPeriodEnd ? "Access until" : "Renews"}{" "}
|
||||
{new Date(subscription.periodEnd).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{subscription.provider === "stripe" && (
|
||||
<Button variant="outline" onClick={() => go(openStripePortalAction, "portal")} disabled={busy === "portal"}>
|
||||
{busy === "portal" ? <Loader2 className="h-4 w-4 animate-spin" /> : <ExternalLink className="h-4 w-4" />}
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
{!subscription.cancelAtPeriodEnd && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm("Cancel your subscription at the end of the period?")) {
|
||||
go(cancelSubscriptionAction, "cancel");
|
||||
}
|
||||
}}
|
||||
disabled={busy === "cancel"}
|
||||
>
|
||||
{busy === "cancel" && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<IntervalToggle interval={interval} onChange={setInterval} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-4">
|
||||
{PLAN_ORDER.map((key) => {
|
||||
const plan = PLANS[key];
|
||||
const isCurrent = key === currentPlan;
|
||||
const price = interval === "year" ? plan.priceYearly : plan.priceMonthly;
|
||||
return (
|
||||
<Card key={key} className={cn("relative", plan.highlight && "ring-2 ring-brand")}>
|
||||
{plan.highlight && (
|
||||
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-brand text-brand-foreground shadow-sm">
|
||||
Most popular
|
||||
</Badge>
|
||||
)}
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<div>
|
||||
<h3 className="font-semibold">{plan.name}</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{plan.tagline}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="font-display text-3xl font-extrabold tracking-tight">{formatPrice(price)}</span>
|
||||
<span className="text-xs text-muted-foreground">/{interval === "year" ? "yr" : "mo"}</span>
|
||||
</div>
|
||||
|
||||
{isCurrent ? (
|
||||
<Button variant="secondary" className="w-full" disabled>
|
||||
<Check className="h-4 w-4" /> Current plan
|
||||
</Button>
|
||||
) : key === "free" ? (
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
Free
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{stripeConfigured && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => go(() => startStripeCheckoutAction(key, interval), `stripe-${key}`)}
|
||||
disabled={busy === `stripe-${key}`}
|
||||
>
|
||||
{busy === `stripe-${key}` ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CreditCard className="h-4 w-4" />
|
||||
)}
|
||||
Pay with card
|
||||
</Button>
|
||||
)}
|
||||
{paypalConfigured && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => go(() => startPaypalCheckoutAction(key), `paypal-${key}`)}
|
||||
disabled={busy === `paypal-${key}`}
|
||||
>
|
||||
{busy === `paypal-${key}` ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
PayPal
|
||||
</Button>
|
||||
)}
|
||||
{!stripeConfigured && !paypalConfigured && (
|
||||
<p className="text-center text-xs text-muted-foreground">Billing not configured</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="space-y-1.5 pt-2 text-xs">
|
||||
{plan.bullets.slice(0, 5).map((b) => (
|
||||
<li key={b} className="flex gap-1.5">
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-brand" />
|
||||
<span className="text-muted-foreground">{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IntervalToggle({
|
||||
interval,
|
||||
onChange,
|
||||
}: {
|
||||
interval: BillingInterval;
|
||||
onChange: (i: BillingInterval) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex rounded-full border border-border bg-secondary p-1">
|
||||
{(["month", "year"] as const).map((i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onChange(i)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
interval === i ? "bg-background text-foreground shadow-sm" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{i === "month" ? "Monthly" : "Yearly"}
|
||||
{i === "year" && <span className="ml-1 text-xs opacity-80">(2 mo free)</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { MoreVertical, ImageIcon, RefreshCw, Trash2, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { regenerateAction, deleteEpisodeAction } from "@/app/(app)/episodes/actions";
|
||||
|
||||
export function EpisodeActions({ episodeId }: { episodeId: string }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function regen(type: "art" | "full") {
|
||||
setBusy(true);
|
||||
const res = await regenerateAction(episodeId, type);
|
||||
setBusy(false);
|
||||
if (res.ok) {
|
||||
toast.success(type === "art" ? "Regenerating cover art…" : "Regenerating episode…");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.error ?? "Could not regenerate");
|
||||
}
|
||||
}
|
||||
|
||||
async function del() {
|
||||
if (!confirm("Delete this episode? This cannot be undone.")) return;
|
||||
const res = await deleteEpisodeAction(episodeId);
|
||||
if (res.ok) {
|
||||
toast.success("Episode deleted");
|
||||
router.push("/episodes");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.error ?? "Could not delete");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <MoreVertical className="h-4 w-4" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem onSelect={() => regen("art")}>
|
||||
<ImageIcon className="h-4 w-4" /> Regenerate cover art
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => regen("full")}>
|
||||
<RefreshCw className="h-4 w-4" /> Regenerate everything
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={del} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="h-4 w-4" /> Delete episode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Link from "next/link";
|
||||
import { Mic2 } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { EpisodeStatusBadge } from "./episode-status-badge";
|
||||
|
||||
export interface EpisodeCardData {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
format: string;
|
||||
language: string;
|
||||
createdAt: Date;
|
||||
coverArtKey?: string | null;
|
||||
}
|
||||
|
||||
export function EpisodeCard({ episode }: { episode: EpisodeCardData }) {
|
||||
return (
|
||||
<Link href={`/episodes/${episode.id}`}>
|
||||
<Card className="group overflow-hidden transition-shadow hover:shadow-md">
|
||||
<div className="relative aspect-square bg-muted">
|
||||
{episode.coverArtKey ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/assets/${episode.coverArtKey}`}
|
||||
alt={episode.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||
<Mic2 className="h-10 w-10" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-2 top-2">
|
||||
<EpisodeStatusBadge status={episode.status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<p className="truncate font-medium">{episode.title}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{episode.format.replace("_", "-").toLowerCase()} · {episode.language.toUpperCase()} ·{" "}
|
||||
{episode.createdAt.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||
|
||||
// Keyed by the Prisma EpisodeStatus enum values (kept as strings to avoid
|
||||
// importing the Prisma client into client bundles).
|
||||
const MAP: Record<string, { label: string; variant: BadgeProps["variant"]; spin?: boolean }> = {
|
||||
DRAFT: { label: "Draft", variant: "secondary" },
|
||||
QUEUED: { label: "Queued", variant: "secondary", spin: true },
|
||||
SCRIPTING: { label: "Writing script", variant: "warning", spin: true },
|
||||
SYNTHESIZING: { label: "Recording audio", variant: "warning", spin: true },
|
||||
STITCHING: { label: "Mixing audio", variant: "warning", spin: true },
|
||||
ART: { label: "Designing art", variant: "warning", spin: true },
|
||||
SAVING: { label: "Finalizing", variant: "warning", spin: true },
|
||||
READY: { label: "Ready", variant: "success" },
|
||||
FAILED: { label: "Failed", variant: "destructive" },
|
||||
};
|
||||
|
||||
export function EpisodeStatusBadge({ status }: { status: string }) {
|
||||
const config = MAP[status] ?? { label: status, variant: "secondary" as const };
|
||||
return (
|
||||
<Badge variant={config.variant} className="gap-1.5 whitespace-nowrap">
|
||||
{config.spin && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, CheckCircle2, AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { regenerateAction } from "@/app/(app)/episodes/actions";
|
||||
|
||||
const STEPS = [
|
||||
{ key: "SCRIPTING", label: "Writing the script" },
|
||||
{ key: "SYNTHESIZING", label: "Recording the audio" },
|
||||
{ key: "STITCHING", label: "Mixing the audio" },
|
||||
{ key: "ART", label: "Designing the cover art" },
|
||||
{ key: "SAVING", label: "Finalizing" },
|
||||
];
|
||||
const ORDER = ["QUEUED", "SCRIPTING", "SYNTHESIZING", "STITCHING", "ART", "SAVING", "READY"];
|
||||
|
||||
export function GenerationProgress({
|
||||
episodeId,
|
||||
initialStatus,
|
||||
initialStage,
|
||||
initialError,
|
||||
}: {
|
||||
episodeId: string;
|
||||
initialStatus: string;
|
||||
initialStage?: string | null;
|
||||
initialError?: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
const [stage, setStage] = useState<string | null | undefined>(initialStage);
|
||||
const [error, setError] = useState<string | null | undefined>(initialError);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "READY" || status === "FAILED") return;
|
||||
const es = new EventSource(`/api/episodes/${episodeId}/stream`);
|
||||
es.onmessage = (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === "open") return;
|
||||
if (data.status) {
|
||||
setStatus(data.status);
|
||||
setStage(data.stage);
|
||||
if (data.error) setError(data.error);
|
||||
if (data.status === "READY") {
|
||||
es.close();
|
||||
router.refresh();
|
||||
} else if (data.status === "FAILED") {
|
||||
es.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
es.onerror = () => es.close();
|
||||
return () => es.close();
|
||||
}, [episodeId, status, router]);
|
||||
|
||||
async function retry() {
|
||||
setRetrying(true);
|
||||
setStatus("QUEUED");
|
||||
setError(null);
|
||||
await regenerateAction(episodeId, "full");
|
||||
router.refresh();
|
||||
setRetrying(false);
|
||||
}
|
||||
|
||||
if (status === "FAILED") {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
|
||||
<AlertCircle className="h-10 w-10 text-destructive" />
|
||||
<div>
|
||||
<p className="font-medium">Generation failed</p>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{error || "Something went wrong while producing this episode."}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={retry} disabled={retrying}>
|
||||
{retrying ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
Try again
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const currentIdx = ORDER.indexOf(status);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-5 py-8">
|
||||
<div className="text-center">
|
||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-brand" />
|
||||
<p className="mt-3 font-medium">{stage || "Generating your episode…"}</p>
|
||||
<p className="text-sm text-muted-foreground">This usually takes a minute or two.</p>
|
||||
</div>
|
||||
<ol className="mx-auto max-w-sm space-y-3">
|
||||
{STEPS.map((s) => {
|
||||
const idx = ORDER.indexOf(s.key);
|
||||
const done = currentIdx > idx;
|
||||
const active = status === s.key;
|
||||
return (
|
||||
<li key={s.key} className="flex items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-full",
|
||||
done && "bg-brand text-brand-foreground",
|
||||
active && "bg-brand/15 text-brand",
|
||||
!done && !active && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{done ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : active ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current" />
|
||||
)}
|
||||
</span>
|
||||
<span className={cn("text-sm", active ? "font-medium" : "text-muted-foreground")}>
|
||||
{s.label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-8 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="font-display text-3xl font-extrabold tracking-tight">{title}</h1>
|
||||
{description && <p className="text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileText, Hash, Mail, Loader2, Copy, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { repurposeAction } from "@/app/(app)/episodes/actions";
|
||||
|
||||
type Format = "blog" | "social_thread" | "newsletter";
|
||||
type Content = { title: string; body: string } | null;
|
||||
|
||||
const FORMATS: { key: Format; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
|
||||
{ key: "blog", label: "Blog post", icon: FileText },
|
||||
{ key: "social_thread", label: "Social thread", icon: Hash },
|
||||
{ key: "newsletter", label: "Newsletter", icon: Mail },
|
||||
];
|
||||
|
||||
export function RepurposeClient({
|
||||
episodeId,
|
||||
initial,
|
||||
}: {
|
||||
episodeId: string;
|
||||
initial: Record<Format, Content>;
|
||||
}) {
|
||||
const [content, setContent] = useState<Record<Format, Content>>(initial);
|
||||
const [busy, setBusy] = useState<Format | null>(null);
|
||||
|
||||
async function generate(format: Format) {
|
||||
setBusy(format);
|
||||
const res = await repurposeAction(episodeId, format);
|
||||
setBusy(null);
|
||||
if (!res.ok || !res.content) {
|
||||
toast.error(res.error ?? "Could not generate");
|
||||
return;
|
||||
}
|
||||
setContent((prev) => ({ ...prev, [format]: res.content! }));
|
||||
toast.success("Generated");
|
||||
}
|
||||
|
||||
function copy(text: string) {
|
||||
navigator.clipboard.writeText(text).then(() => toast.success("Copied to clipboard"));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{FORMATS.map((f) => {
|
||||
const c = content[f.key];
|
||||
return (
|
||||
<Card key={f.key} className="flex flex-col">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<f.icon className="h-4 w-4 text-brand" /> {f.label}
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="outline" onClick={() => generate(f.key)} disabled={busy === f.key}>
|
||||
{busy === f.key ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
{c ? "Regenerate" : "Generate"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
{c ? (
|
||||
<div className="space-y-3">
|
||||
<p className="font-medium">{c.title}</p>
|
||||
<div className="max-h-96 overflow-y-auto whitespace-pre-wrap rounded-md bg-muted/40 p-3 text-sm text-muted-foreground">
|
||||
{c.body}
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={() => copy(`${c.title}\n\n${c.body}`)}>
|
||||
<Copy className="h-4 w-4" /> Copy
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Turn this episode into a {f.label.toLowerCase()}.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Save, RefreshCw, AudioLines } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
updateScriptAction,
|
||||
regenerateAction,
|
||||
regenerateSectionAction,
|
||||
} from "@/app/(app)/episodes/actions";
|
||||
|
||||
interface Turn {
|
||||
speakerKey: string;
|
||||
text: string;
|
||||
}
|
||||
interface Section {
|
||||
id: string;
|
||||
title: string;
|
||||
turns: Turn[];
|
||||
}
|
||||
interface Script {
|
||||
title: string;
|
||||
sections: Section[];
|
||||
}
|
||||
|
||||
export function ScriptEditor({
|
||||
episodeId,
|
||||
script,
|
||||
speakerNames,
|
||||
}: {
|
||||
episodeId: string;
|
||||
script: Script;
|
||||
speakerNames: Record<string, string>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [sections, setSections] = useState<Section[]>(script.sections);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, startSave] = useTransition();
|
||||
const [busySection, setBusySection] = useState<string | null>(null);
|
||||
const [rerecording, setRerecording] = useState(false);
|
||||
|
||||
function updateTurn(si: number, ti: number, text: string) {
|
||||
setSections((prev) =>
|
||||
prev.map((s, i) =>
|
||||
i === si ? { ...s, turns: s.turns.map((t, j) => (j === ti ? { ...t, text } : t)) } : s
|
||||
)
|
||||
);
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function save() {
|
||||
startSave(async () => {
|
||||
const res = await updateScriptAction(episodeId, { title: script.title, sections });
|
||||
if (res.ok) {
|
||||
toast.success("Script saved");
|
||||
setDirty(false);
|
||||
} else {
|
||||
toast.error(res.error ?? "Could not save");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function regenSection(id: string) {
|
||||
setBusySection(id);
|
||||
const res = await regenerateSectionAction(episodeId, id);
|
||||
setBusySection(null);
|
||||
if (!res.ok || !res.section) {
|
||||
toast.error(res.error ?? "Could not regenerate");
|
||||
return;
|
||||
}
|
||||
setSections((prev) => prev.map((s) => (s.id === id ? res.section! : s)));
|
||||
setDirty(false);
|
||||
toast.success("Section regenerated");
|
||||
}
|
||||
|
||||
async function rerecord() {
|
||||
setRerecording(true);
|
||||
if (dirty) {
|
||||
const saved = await updateScriptAction(episodeId, { title: script.title, sections });
|
||||
if (!saved.ok) {
|
||||
toast.error(saved.error ?? "Save failed");
|
||||
setRerecording(false);
|
||||
return;
|
||||
}
|
||||
setDirty(false);
|
||||
}
|
||||
const res = await regenerateAction(episodeId, "audio");
|
||||
if (res.ok) {
|
||||
toast.success("Re-recording audio…");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.error ?? "Could not re-record");
|
||||
setRerecording(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Script</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={rerecord} disabled={rerecording}>
|
||||
{rerecording ? <Loader2 className="h-4 w-4 animate-spin" /> : <AudioLines className="h-4 w-4" />}
|
||||
Re-record audio
|
||||
</Button>
|
||||
<Button size="sm" onClick={save} disabled={!dirty || saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sections.map((section, si) => (
|
||||
<Card key={section.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||
<CardTitle className="text-sm">{section.title}</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => regenSection(section.id)}
|
||||
disabled={busySection === section.id}
|
||||
>
|
||||
{busySection === section.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
Regenerate
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{section.turns.map((turn, ti) => (
|
||||
<div key={ti} className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{speakerNames[turn.speakerKey] ?? turn.speakerKey}
|
||||
</span>
|
||||
<Textarea
|
||||
value={turn.text}
|
||||
onChange={(e) => updateTurn(si, ti, e.target.value)}
|
||||
rows={Math.max(2, Math.ceil(turn.text.length / 80))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Sparkles } 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TONES, LANGUAGES } from "@/lib/episodes/options";
|
||||
import { createSeriesAction } from "@/app/(app)/series/actions";
|
||||
|
||||
export function SeriesCreateForm() {
|
||||
const router = useRouter();
|
||||
const [theme, setTheme] = useState("");
|
||||
const [count, setCount] = useState("6");
|
||||
const [tone, setTone] = useState<string>(TONES[0]);
|
||||
const [audience, setAudience] = useState("");
|
||||
const [language, setLanguage] = useState("en");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (theme.trim().length < 5) {
|
||||
toast.error("Describe your season theme.");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
const res = await createSeriesAction({
|
||||
theme: theme.trim(),
|
||||
count: Number(count),
|
||||
tone,
|
||||
audience: audience.trim() || undefined,
|
||||
language,
|
||||
});
|
||||
if (!res.ok || !res.seriesId) {
|
||||
toast.error(res.error ?? "Could not plan season");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
toast.success("Season planned!");
|
||||
router.push(`/series/${res.seriesId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-2xl">
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme">Season theme</Label>
|
||||
<Textarea
|
||||
id="theme"
|
||||
rows={3}
|
||||
placeholder="e.g. A beginner's journey through personal finance"
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Episodes</Label>
|
||||
<Select value={count} onValueChange={setCount}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[2, 3, 4, 5, 6, 8, 10, 12].map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n} episodes
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Tone</Label>
|
||||
<Select value={tone} onValueChange={setTone}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TONES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Language</Label>
|
||||
<Select value={language} onValueChange={setLanguage}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGES.map((l) => (
|
||||
<SelectItem key={l.code} value={l.code}>
|
||||
{l.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="aud">Audience (optional)</Label>
|
||||
<Input id="aud" value={audience} onChange={(e) => setAudience(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
Plan season
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { generateFromSeriesAction } from "@/app/(app)/series/actions";
|
||||
|
||||
interface PlanItem {
|
||||
title: string;
|
||||
topic: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export function SeriesDetailClient({
|
||||
seriesId,
|
||||
episodes,
|
||||
}: {
|
||||
seriesId: string;
|
||||
episodes: PlanItem[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState<number | null>(null);
|
||||
|
||||
async function generate(index: number) {
|
||||
setBusy(index);
|
||||
const res = await generateFromSeriesAction(seriesId, index);
|
||||
setBusy(null);
|
||||
if (!res.ok || !res.episodeId) {
|
||||
toast.error(res.error ?? "Could not generate");
|
||||
return;
|
||||
}
|
||||
toast.success("Generating episode…");
|
||||
router.push(`/episodes/${res.episodeId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{episodes.map((ep, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="flex items-start justify-between gap-4 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
{i + 1}. {ep.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{ep.summary}</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => generate(i)} disabled={busy === i}>
|
||||
{busy === i ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
Generate
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } 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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
|
||||
export function SettingsClient({ name, email }: { name: string; email: string }) {
|
||||
const router = useRouter();
|
||||
const [displayName, setDisplayName] = useState(name);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [savingPw, setSavingPw] = useState(false);
|
||||
|
||||
async function saveProfile(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSavingProfile(true);
|
||||
const { error } = await authClient.updateUser({ name: displayName });
|
||||
setSavingProfile(false);
|
||||
if (error) toast.error(error.message ?? "Could not update");
|
||||
else {
|
||||
toast.success("Profile updated");
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
setSavingPw(true);
|
||||
const { error } = await authClient.changePassword({
|
||||
currentPassword: String(form.get("current")),
|
||||
newPassword: String(form.get("new")),
|
||||
revokeOtherSessions: true,
|
||||
});
|
||||
setSavingPw(false);
|
||||
if (error) toast.error(error.message ?? "Could not change password");
|
||||
else {
|
||||
toast.success("Password changed");
|
||||
e.currentTarget.reset();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile</CardTitle>
|
||||
<CardDescription>Update your name and see your email.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={saveProfile} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Email</Label>
|
||||
<Input value={email} disabled />
|
||||
</div>
|
||||
<Button type="submit" disabled={savingProfile}>
|
||||
{savingProfile && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Save profile
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Password</CardTitle>
|
||||
<CardDescription>Change your account password.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={changePassword} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current">Current password</Label>
|
||||
<Input id="current" name="current" type="password" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new">New password</Label>
|
||||
<Input id="new" name="new" type="password" minLength={8} required />
|
||||
</div>
|
||||
<Button type="submit" disabled={savingPw}>
|
||||
{savingPw && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Change password
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Mic2,
|
||||
ListMusic,
|
||||
BarChart3,
|
||||
CreditCard,
|
||||
Users,
|
||||
KeyRound,
|
||||
Settings,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { PlanKey, FeatureKey } from "@/lib/billing/plans";
|
||||
import { PLANS } from "@/lib/billing/plans";
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
requiresFeature?: FeatureKey;
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||
{ label: "Episodes", href: "/episodes", icon: Mic2 },
|
||||
{ label: "Series", href: "/series", icon: ListMusic, requiresFeature: "series_generator" },
|
||||
{ label: "Usage", href: "/usage", icon: BarChart3 },
|
||||
{ label: "Billing", href: "/billing", icon: CreditCard },
|
||||
{ label: "Team", href: "/team", icon: Users, requiresFeature: "team_workspace" },
|
||||
{ label: "API Keys", href: "/api-keys", icon: KeyRound, requiresFeature: "api_access" },
|
||||
{ label: "Settings", href: "/settings", icon: Settings },
|
||||
];
|
||||
|
||||
export function SidebarNav({ plan }: { plan: PlanKey }) {
|
||||
const pathname = usePathname();
|
||||
const features = PLANS[plan].features;
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col gap-1 p-4">
|
||||
{NAV.map((item) => {
|
||||
const active = pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
const locked = item.requiresFeature && !features.includes(item.requiresFeature);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-full px-4 py-2.5 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-brand/10 font-semibold text-brand"
|
||||
: "text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{locked && <Lock className="h-3.5 w-3.5 opacity-60" />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="mt-3 px-2">
|
||||
<Badge variant="brand" className="capitalize">
|
||||
{plan} plan
|
||||
</Badge>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, UserPlus, Building2, Save } 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 { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { saveBrandingAction } from "@/app/(app)/team/actions";
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
interface Branding {
|
||||
brandName: string | null;
|
||||
primaryColor: string | null;
|
||||
logoUrl: string | null;
|
||||
removePoweredBy: boolean;
|
||||
}
|
||||
|
||||
export function TeamClient({
|
||||
org,
|
||||
members,
|
||||
branding,
|
||||
seats,
|
||||
}: {
|
||||
org: { id: string; name: string } | null;
|
||||
members: Member[];
|
||||
branding: Branding | null;
|
||||
seats: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
if (!org) return <CreateWorkspace />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<MembersCard orgId={org.id} members={members} seats={seats} />
|
||||
<BrandingCard orgId={org.id} branding={branding} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateWorkspace() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function create(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
const { data, error } = await authClient.organization.create({ name: name.trim(), slug });
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Could not create workspace");
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
if (data?.id) await authClient.organization.setActive({ organizationId: data.id });
|
||||
toast.success("Workspace created");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5" /> Create your team workspace
|
||||
</CardTitle>
|
||||
<CardDescription>Invite up to your plan's seat limit and share a workspace.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={create} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wsname">Workspace name</Label>
|
||||
<Input id="wsname" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<Button type="submit" disabled={busy || !name.trim()}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Create workspace
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersCard({ orgId, members, seats }: { orgId: string; members: Member[]; seats: number }) {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function invite(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (members.length >= seats) {
|
||||
toast.error(`Your plan includes ${seats} seats.`);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
await authClient.organization.setActive({ organizationId: orgId });
|
||||
const { error } = await authClient.organization.inviteMember({ email: email.trim(), role: "member" });
|
||||
setBusy(false);
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Could not invite");
|
||||
return;
|
||||
}
|
||||
toast.success(`Invitation sent to ${email}`);
|
||||
setEmail("");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Members</span>
|
||||
<Badge variant="secondary">
|
||||
{members.length} / {seats} seats
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="divide-y rounded-lg border">
|
||||
{members.map((m) => (
|
||||
<div key={m.id} className="flex items-center gap-3 p-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>{m.name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{m.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{m.email}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="capitalize">{m.role}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={invite} className="flex gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="teammate@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
|
||||
Invite
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandingCard({ orgId, branding }: { orgId: string; branding: Branding | null }) {
|
||||
const router = useRouter();
|
||||
const [brandName, setBrandName] = useState(branding?.brandName ?? "");
|
||||
const [primaryColor, setPrimaryColor] = useState(branding?.primaryColor ?? "");
|
||||
const [logoUrl, setLogoUrl] = useState(branding?.logoUrl ?? "");
|
||||
const [removePoweredBy, setRemovePoweredBy] = useState(branding?.removePoweredBy ?? false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const res = await saveBrandingAction(orgId, { brandName, primaryColor, logoUrl, removePoweredBy });
|
||||
setBusy(false);
|
||||
if (res.ok) {
|
||||
toast.success("Branding saved");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.error ?? "Could not save");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>White-label branding</CardTitle>
|
||||
<CardDescription>Make the workspace your own.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={save} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="brand">Brand name</Label>
|
||||
<Input id="brand" value={brandName} onChange={(e) => setBrandName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">Primary colour (hex)</Label>
|
||||
<Input id="color" placeholder="#7c3aed" value={primaryColor} onChange={(e) => setPrimaryColor(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logo">Logo URL</Label>
|
||||
<Input id="logo" placeholder="https://…" value={logoUrl} onChange={(e) => setLogoUrl(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Remove "Powered by PodcastYes"</p>
|
||||
<p className="text-xs text-muted-foreground">Hide PodcastYes branding for your clients.</p>
|
||||
</div>
|
||||
<Switch checked={removePoweredBy} onCheckedChange={setRemovePoweredBy} />
|
||||
</div>
|
||||
<Button type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
Save branding
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Link from "next/link";
|
||||
import { Lock } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function UpgradeGate({
|
||||
title,
|
||||
description,
|
||||
requiredPlan,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
requiredPlan: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand/10 text-brand">
|
||||
<Lock className="h-6 w-6" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">{title}</p>
|
||||
<p className="max-w-md text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/billing">Upgrade to {requiredPlan}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { LogOut, Settings, Shield } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { signOut } from "@/lib/auth/auth-client";
|
||||
|
||||
interface UserMenuProps {
|
||||
name: string;
|
||||
email: string;
|
||||
image?: string | null;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export function UserMenu({ name, email, image, isAdmin }: UserMenuProps) {
|
||||
const router = useRouter();
|
||||
const initials = name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
async function handleSignOut() {
|
||||
await signOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="outline-none">
|
||||
<Avatar>
|
||||
{image && <AvatarImage src={image} alt={name} />}
|
||||
<AvatarFallback>{initials || "U"}</AvatarFallback>
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col">
|
||||
<span className="truncate font-medium">{name}</span>
|
||||
<span className="truncate text-xs font-normal text-muted-foreground">{email}</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings">
|
||||
<Settings className="h-4 w-4" /> Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin">
|
||||
<Shield className="h-4 w-4" /> Admin
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleSignOut} className="text-destructive focus:text-destructive">
|
||||
<LogOut className="h-4 w-4" /> Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user