Initial commit: PodcastYes — AI podcast platform

This commit is contained in:
Leon Serfaty
2026-06-07 03:58:32 -04:00
commit 155507f21a
151 changed files with 19826 additions and 0 deletions
+112
View File
@@ -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&apos;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>
);
}
+39
View File
@@ -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>
);
}
+213
View File
@@ -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>
);
}
+66
View File
@@ -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>
);
}
+47
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+336
View File
@@ -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 &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>
);
}
+131
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+86
View File
@@ -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>
);
}
+153
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+97
View File
@@ -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>
);
}
+72
View File
@@ -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>
);
}
+222
View File
@@ -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&apos;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 &quot;Powered by PodcastYes&quot;</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>
);
}
+31
View File
@@ -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>
);
}
+74
View File
@@ -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>
);
}