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
+52
View File
@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Users,
CreditCard,
BarChart3,
ShieldAlert,
Activity,
Flag,
ScrollText,
} from "lucide-react";
import { cn } from "@/lib/utils";
const NAV = [
{ label: "Overview", href: "/admin", icon: LayoutDashboard, exact: true },
{ label: "Users", href: "/admin/users", icon: Users },
{ label: "Subscriptions", href: "/admin/subscriptions", icon: CreditCard },
{ label: "AI usage & cost", href: "/admin/ai-usage", icon: BarChart3 },
{ label: "Moderation", href: "/admin/moderation", icon: ShieldAlert },
{ label: "System health", href: "/admin/health", icon: Activity },
{ label: "Feature flags", href: "/admin/flags", icon: Flag },
{ label: "Audit log", href: "/admin/audit", icon: ScrollText },
];
export function AdminSidebar() {
const pathname = usePathname();
return (
<nav className="flex flex-col gap-1 p-3">
{NAV.map((item) => {
const active = item.exact ? pathname === item.href : pathname.startsWith(item.href);
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" />
{item.label}
</Link>
);
})}
</nav>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
Legend,
CartesianGrid,
} from "recharts";
export interface CostPoint {
date: string;
openai: number;
elevenlabs: number;
}
export function CostChart({ data }: { data: CostPoint[] }) {
return (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={data} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} className="stroke-muted" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="currentColor" className="text-muted-foreground" />
<YAxis tick={{ fontSize: 11 }} stroke="currentColor" className="text-muted-foreground" tickFormatter={(v) => `$${v}`} />
<Tooltip
formatter={(v: number) => `$${v.toFixed(2)}`}
contentStyle={{ fontSize: 12, borderRadius: 8 }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
{/* Wix-palette data series: Wix Blue + Deep Purple */}
<Bar dataKey="openai" name="OpenAI" stackId="a" fill="#116DFF" radius={[0, 0, 0, 0]} />
<Bar dataKey="elevenlabs" name="ElevenLabs" stackId="a" fill="#3910ED" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { toggleFeatureFlagAction } from "@/app/(admin)/admin/actions";
interface Flag {
key: string;
enabled: boolean;
}
export function FlagsClient({ flags }: { flags: Flag[] }) {
const router = useRouter();
const [newKey, setNewKey] = useState("");
async function toggle(key: string, enabled: boolean) {
const res = await toggleFeatureFlagAction(key, enabled);
if (res.ok) router.refresh();
else toast.error(res.error ?? "Failed");
}
async function create(e: React.FormEvent) {
e.preventDefault();
const key = newKey.trim();
if (!key) return;
const res = await toggleFeatureFlagAction(key, false);
if (res.ok) {
setNewKey("");
toast.success("Flag created");
router.refresh();
} else {
toast.error(res.error ?? "Failed");
}
}
return (
<div className="max-w-2xl space-y-6">
<Card>
<CardContent className="py-4">
<form onSubmit={create} className="flex gap-2">
<Input
placeholder="new_flag_key"
value={newKey}
onChange={(e) => setNewKey(e.target.value.replace(/\s+/g, "_"))}
/>
<Button type="submit">
<Plus className="h-4 w-4" /> Add flag
</Button>
</form>
</CardContent>
</Card>
{flags.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">No feature flags yet.</p>
) : (
<div className="divide-y rounded-lg border">
{flags.map((f) => (
<div key={f.key} className="flex items-center justify-between p-4">
<code className="text-sm">{f.key}</code>
<Switch checked={f.enabled} onCheckedChange={(v) => toggle(f.key, v)} />
</div>
))}
</div>
)}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { reviewContentFlagAction } from "@/app/(admin)/admin/actions";
export function ModerationActions({ flagId }: { flagId: string }) {
const router = useRouter();
async function act(status: "reviewed" | "removed") {
const res = await reviewContentFlagAction(flagId, status);
if (res.ok) {
toast.success("Updated");
router.refresh();
} else {
toast.error(res.error ?? "Failed");
}
}
return (
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => act("reviewed")}>
Mark reviewed
</Button>
<Button size="sm" variant="destructive" onClick={() => act("removed")}>
Remove
</Button>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useRouter } from "next/navigation";
import { MoreHorizontal, ShieldCheck, ShieldOff, Ban, UserCheck } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { banUserAction, setRoleAction } from "@/app/(admin)/admin/actions";
export interface AdminUser {
id: string;
name: string;
email: string;
role: string;
banned: boolean;
plan: string;
createdAt: string;
}
export function UsersTable({ users }: { users: AdminUser[] }) {
const router = useRouter();
async function run(action: () => Promise<{ ok: boolean; error?: string }>, msg: string) {
const res = await action();
if (res.ok) {
toast.success(msg);
router.refresh();
} else {
toast.error(res.error ?? "Failed");
}
}
return (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/40 text-left text-xs uppercase text-muted-foreground">
<tr>
<th className="p-3 font-medium">User</th>
<th className="p-3 font-medium">Plan</th>
<th className="p-3 font-medium">Role</th>
<th className="p-3 font-medium">Status</th>
<th className="p-3 font-medium">Joined</th>
<th className="p-3" />
</tr>
</thead>
<tbody className="divide-y">
{users.map((u) => (
<tr key={u.id} className="hover:bg-muted/20">
<td className="p-3">
<p className="font-medium">{u.name}</p>
<p className="text-xs text-muted-foreground">{u.email}</p>
</td>
<td className="p-3 capitalize">{u.plan}</td>
<td className="p-3">
{u.role === "admin" ? <Badge>admin</Badge> : <span className="text-muted-foreground">user</span>}
</td>
<td className="p-3">
{u.banned ? <Badge variant="destructive">banned</Badge> : <Badge variant="success">active</Badge>}
</td>
<td className="p-3 text-muted-foreground">{new Date(u.createdAt).toLocaleDateString()}</td>
<td className="p-3 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{u.role === "admin" ? (
<DropdownMenuItem onSelect={() => run(() => setRoleAction(u.id, "user"), "Role updated")}>
<ShieldOff className="h-4 w-4" /> Revoke admin
</DropdownMenuItem>
) : (
<DropdownMenuItem onSelect={() => run(() => setRoleAction(u.id, "admin"), "Role updated")}>
<ShieldCheck className="h-4 w-4" /> Make admin
</DropdownMenuItem>
)}
{u.banned ? (
<DropdownMenuItem onSelect={() => run(() => banUserAction(u.id, false), "User unbanned")}>
<UserCheck className="h-4 w-4" /> Unban
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => run(() => banUserAction(u.id, true), "User banned")}
>
<Ban className="h-4 w-4" /> Ban
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+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>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Loader2, MailCheck } 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 ForgotPasswordForm() {
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const form = new FormData(e.currentTarget);
const { error } = await authClient.requestPasswordReset({
email: String(form.get("email")),
redirectTo: "/reset-password",
});
setLoading(false);
if (error) {
toast.error(error.message ?? "Something went wrong");
return;
}
setSent(true);
}
if (sent) {
return (
<Card className="rounded-3xl shadow-md">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-2xl">
<MailCheck className="h-6 w-6 text-brand" /> Check your inbox
</CardTitle>
<CardDescription>
If an account exists for that email, we&apos;ve sent a reset link.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline" className="w-full">
<Link href="/sign-in">Back to sign in</Link>
</Button>
</CardContent>
</Card>
);
}
return (
<Card className="rounded-3xl shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-2xl">Forgot your password?</CardTitle>
<CardDescription>Enter your email and we&apos;ll send you a reset link.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
Send reset link
</Button>
<p className="text-center text-sm text-muted-foreground">
<Link href="/sign-in" className="font-semibold text-brand hover:underline">
Back to sign in
</Link>
</p>
</form>
</CardContent>
</Card>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { signIn } from "@/lib/auth/auth-client";
export function GoogleButton({ callbackURL = "/dashboard" }: { callbackURL?: string }) {
const [loading, setLoading] = useState(false);
async function handleGoogle() {
setLoading(true);
const { error } = await signIn.social({ provider: "google", callbackURL });
if (error) {
toast.error(error.message ?? "Google sign-in failed");
setLoading(false);
}
}
return (
<Button type="button" variant="outline" className="w-full" onClick={handleGoogle} disabled={loading}>
<svg className="h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1Z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z"
/>
<path
fill="currentColor"
d="M5.84 14.1a6.6 6.6 0 0 1 0-4.2V7.06H2.18a11 11 0 0 0 0 9.88l3.66-2.84Z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84C6.71 7.3 9.14 5.38 12 5.38Z"
/>
</svg>
Continue with Google
</Button>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } 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 ResetPasswordForm() {
const router = useRouter();
const params = useSearchParams();
const token = params.get("token") ?? "";
const errorParam = params.get("error");
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
const newPassword = String(form.get("password"));
const confirm = String(form.get("confirm"));
if (newPassword !== confirm) {
toast.error("Passwords don't match");
return;
}
setLoading(true);
const { error } = await authClient.resetPassword({ newPassword, token });
setLoading(false);
if (error) {
toast.error(error.message ?? "Could not reset password");
return;
}
toast.success("Password updated. Please sign in.");
router.push("/sign-in");
}
if (errorParam || !token) {
return (
<Card className="rounded-3xl shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-2xl">Invalid or expired link</CardTitle>
<CardDescription>Please request a new password reset link.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full">
<Link href="/forgot-password">Request new link</Link>
</Button>
</CardContent>
</Card>
);
}
return (
<Card className="rounded-3xl shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-2xl">Set a new password</CardTitle>
<CardDescription>Choose a strong password you don&apos;t use elsewhere.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">New password</Label>
<Input id="password" name="password" type="password" minLength={8} required />
</div>
<div className="space-y-2">
<Label htmlFor="confirm">Confirm password</Label>
<Input id="confirm" name="confirm" type="password" minLength={8} required />
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
Update password
</Button>
</form>
</CardContent>
</Card>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } 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 { signIn } from "@/lib/auth/auth-client";
import { GoogleButton } from "./google-button";
export function SignInForm({ googleEnabled }: { googleEnabled: boolean }) {
const router = useRouter();
const params = useSearchParams();
const redirectTo = params.get("redirect") || "/dashboard";
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const form = new FormData(e.currentTarget);
const { error } = await signIn.email({
email: String(form.get("email")),
password: String(form.get("password")),
});
if (error) {
toast.error(error.message ?? "Invalid email or password");
setLoading(false);
return;
}
router.push(redirectTo);
router.refresh();
}
return (
<Card className="rounded-3xl shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription>Sign in to your PodcastYes account.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{googleEnabled && (
<>
<GoogleButton callbackURL={redirectTo} />
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">or</span>
</div>
</div>
</>
)}
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link href="/forgot-password" className="text-xs text-muted-foreground hover:text-foreground">
Forgot?
</Link>
</div>
<Input id="password" name="password" type="password" autoComplete="current-password" required />
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
Sign in
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/sign-up" className="font-semibold text-brand hover:underline">
Sign up
</Link>
</p>
</CardContent>
</Card>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import Link from "next/link";
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 { signUp } from "@/lib/auth/auth-client";
import { GoogleButton } from "./google-button";
export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const form = new FormData(e.currentTarget);
const { error } = await signUp.email({
name: String(form.get("name")),
email: String(form.get("email")),
password: String(form.get("password")),
});
if (error) {
toast.error(error.message ?? "Could not create account");
setLoading(false);
return;
}
toast.success("Account created! Welcome to PodcastYes.");
router.push("/dashboard");
router.refresh();
}
return (
<Card className="rounded-3xl shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-2xl">Create your account</CardTitle>
<CardDescription>Start producing podcasts with AI free, no card required.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{googleEnabled && (
<>
<GoogleButton />
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">or</span>
</div>
</div>
</>
)}
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" autoComplete="name" required />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
minLength={8}
required
/>
<p className="text-xs text-muted-foreground">At least 8 characters.</p>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
Create account
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/sign-in" className="font-semibold text-brand hover:underline">
Sign in
</Link>
</p>
</CardContent>
</Card>
);
}
+71
View File
@@ -0,0 +1,71 @@
import Link from "next/link";
import { Mic } from "lucide-react";
export function SiteFooter() {
return (
<footer className="border-t border-border bg-secondary">
<div className="container flex flex-col gap-10 py-16 md:flex-row md:justify-between">
<div className="max-w-xs space-y-4">
<Link href="/" className="flex items-center gap-2.5 font-display text-lg font-bold tracking-tight">
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-brand text-brand-foreground">
<Mic className="h-5 w-5" />
</span>
PodcastYes
</Link>
<p className="text-sm leading-relaxed text-muted-foreground">
From topic idea to a finished, published podcast episode in minutes script, voice, and
cover art generated by AI.
</p>
</div>
<div className="grid grid-cols-2 gap-10 text-sm sm:grid-cols-3">
<FooterCol
title="Product"
links={[
["How it works", "/#how-it-works"],
["Features", "/#features"],
["Pricing", "/pricing"],
]}
/>
<FooterCol
title="Account"
links={[
["Log in", "/sign-in"],
["Create account", "/sign-up"],
["Dashboard", "/dashboard"],
]}
/>
<FooterCol
title="Legal"
links={[
["Terms", "/terms"],
["Privacy", "/privacy"],
]}
/>
</div>
</div>
<div className="border-t border-border">
<div className="container py-6 text-center text-xs text-muted-foreground">
© {new Date().getFullYear()} PodcastYes. All rights reserved.
</div>
</div>
</footer>
);
}
function FooterCol({ title, links }: { title: string; links: [string, string][] }) {
return (
<div className="space-y-3.5">
<p className="font-semibold text-foreground">{title}</p>
<ul className="space-y-2.5 text-muted-foreground">
{links.map(([label, href]) => (
<li key={href}>
<Link href={href} className="transition-colors hover:text-brand">
{label}
</Link>
</li>
))}
</ul>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Link from "next/link";
import { Mic } from "lucide-react";
import { Button } from "@/components/ui/button";
export function SiteHeader() {
return (
<header className="sticky top-0 z-40 w-full border-b border-border/70 bg-background/85 backdrop-blur-md">
<div className="container flex h-[72px] items-center justify-between">
<Link href="/" className="flex items-center gap-2.5 font-display text-lg font-bold tracking-tight">
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-brand text-brand-foreground">
<Mic className="h-5 w-5" />
</span>
<span>PodcastYes</span>
</Link>
<nav className="hidden items-center gap-8 text-sm font-medium text-muted-foreground md:flex">
<Link href="/#how-it-works" className="transition-colors hover:text-foreground">How it works</Link>
<Link href="/#features" className="transition-colors hover:text-foreground">Features</Link>
<Link href="/pricing" className="transition-colors hover:text-foreground">Pricing</Link>
</nav>
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="sm" className="hidden sm:inline-flex">
<Link href="/sign-in">Log in</Link>
</Button>
<Button asChild size="sm">
<Link href="/sign-up">Get started</Link>
</Button>
</div>
</div>
</header>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-medium",
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
brand: "border-transparent bg-brand/10 text-brand",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive/12 text-destructive",
outline: "border-border text-foreground",
success: "border-transparent bg-success/12 text-success",
warning: "border-transparent bg-warning/15 text-warning",
},
},
defaultVariants: { variant: "default" },
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
// Wix primary action = solid black pill
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/85",
// Wix Blue accent action
brand: "bg-brand text-brand-foreground shadow-sm hover:bg-brand-hover",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border-[1.5px] border-foreground/15 bg-background text-foreground hover:border-foreground/30 hover:bg-secondary",
secondary: "bg-secondary text-secondary-foreground hover:bg-foreground/[0.07]",
ghost: "text-foreground hover:bg-secondary",
link: "text-brand underline-offset-4 hover:underline rounded-none",
},
size: {
default: "h-11 px-5 text-sm",
sm: "h-9 px-4 text-sm",
lg: "h-12 px-8 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
+50
View File
@@ -0,0 +1,50 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-2xl border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("font-display text-lg font-bold leading-none tracking-tight", className)} {...props} />
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+83
View File
@@ -0,0 +1,83 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[9rem] overflow-hidden rounded-xl border bg-popover p-1.5 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm outline-none transition-colors focus:bg-secondary focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuRadioGroup,
};
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-11 w-full rounded-xl border border-input bg-background px-4 py-2 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
+22
View File
@@ -0,0 +1,22 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-semibold leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+24
View File
@@ -0,0 +1,24 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & { indicatorClassName?: string }
>(({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-secondary", className)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn("h-full w-full flex-1 bg-brand transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
+89
View File
@@ -0,0 +1,89 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-11 w-full items-center justify-between whitespace-nowrap rounded-xl border border-input bg-background px-4 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:border-transparent focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
position === "popper" && "data-[side=bottom]:translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-lg py-2 pl-8 pr-2 text-sm outline-none focus:bg-brand/10 focus:text-brand data-[state=checked]:font-semibold data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem };
+28
View File
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-brand data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[96px] w-full rounded-xl border border-input bg-background px-4 py-3 text-base transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
export { Textarea };