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