Comprehensive admin + user dashboards (production-ready)

This commit is contained in:
Leon Serfaty
2026-06-07 17:54:30 -04:00
parent 155507f21a
commit f033f00379
122 changed files with 7878 additions and 805 deletions
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Menu, X, ShieldCheck } from "lucide-react";
import { AdminSidebar } from "./admin-sidebar";
export function AdminMobileNav() {
const [open, setOpen] = useState(false);
return (
<DialogPrimitive.Root open={open} onOpenChange={setOpen}>
<DialogPrimitive.Trigger className="inline-flex h-10 w-10 items-center justify-center rounded-full text-foreground hover:bg-secondary md:hidden">
<Menu className="h-5 w-5" />
<span className="sr-only">Open menu</span>
</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-foreground/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=open]:fade-in-0 md:hidden" />
<DialogPrimitive.Content className="fixed inset-y-0 left-0 z-50 w-72 overflow-y-auto border-r border-border bg-background shadow-xl duration-200 data-[state=open]:animate-in data-[state=open]:slide-in-from-left md:hidden">
<div className="flex items-center justify-between border-b border-border px-4 py-4">
<DialogPrimitive.Title className="flex items-center gap-2 font-display font-bold tracking-tight">
<span className="flex h-8 w-8 items-center justify-center rounded-2xl bg-foreground text-background">
<ShieldCheck className="h-4 w-4" />
</span>
Admin
</DialogPrimitive.Title>
<DialogPrimitive.Close className="rounded-full p-1 text-muted-foreground hover:text-foreground">
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
<AdminSidebar onNavigate={() => setOpen(false)} />
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
+71 -31
View File
@@ -4,49 +4,89 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
TrendingUp,
BarChart3,
Users,
CreditCard,
BarChart3,
ShieldAlert,
ListChecks,
Activity,
Webhook,
ShieldAlert,
Flag,
ScrollText,
Settings,
} 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 },
interface Item {
label: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
exact?: boolean;
}
const GROUPS: { label: string; items: Item[] }[] = [
{
label: "Insights",
items: [
{ label: "Overview", href: "/admin", icon: LayoutDashboard, exact: true },
{ label: "Revenue", href: "/admin/revenue", icon: TrendingUp },
{ label: "AI cost", href: "/admin/ai-usage", icon: BarChart3 },
],
},
{
label: "Operations",
items: [
{ label: "Users", href: "/admin/users", icon: Users },
{ label: "Subscriptions", href: "/admin/subscriptions", icon: CreditCard },
{ label: "Jobs", href: "/admin/jobs", icon: ListChecks },
{ label: "System health", href: "/admin/health", icon: Activity },
{ label: "Webhooks", href: "/admin/webhooks", icon: Webhook },
],
},
{
label: "Governance",
items: [
{ label: "Moderation", href: "/admin/moderation", icon: ShieldAlert },
{ label: "Feature flags", href: "/admin/flags", icon: Flag },
{ label: "Audit log", href: "/admin/audit", icon: ScrollText },
{ label: "Settings", href: "/admin/settings", icon: Settings },
],
},
];
export function AdminSidebar() {
export function AdminSidebar({ onNavigate }: { onNavigate?: () => void }) {
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 className="flex flex-col gap-5 p-4">
{GROUPS.map((group) => (
<div key={group.label} className="space-y-1">
<p className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground/70">
{group.label}
</p>
{group.items.map((item) => {
const active = item.exact
? pathname === item.href
: pathname === item.href || pathname.startsWith(item.href + "/");
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 rounded-full px-3 py-2 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>
);
})}
</div>
))}
</nav>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
import { Download, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { exportAuditCsvAction } from "@/app/(admin)/admin/actions";
export function AuditExport({
filters,
}: {
filters: { action?: string; actor?: string };
}) {
const [busy, setBusy] = useState(false);
async function exportCsv() {
setBusy(true);
try {
const res = await exportAuditCsvAction(filters);
if (!res.ok || !res.csv) {
toast.error(res.error ?? "Export failed");
return;
}
const blob = new Blob([res.csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success("CSV downloaded");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Export failed");
} finally {
setBusy(false);
}
}
return (
<Button variant="outline" size="sm" onClick={exportCsv} disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
Export CSV
</Button>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogTrigger,
} from "@/components/ui/dialog";
export function AuditMetaViewer({
metadata,
action,
}: {
metadata: unknown;
action: string;
}) {
if (metadata == null) return <span className="text-muted-foreground"></span>;
const compact = JSON.stringify(metadata);
const pretty = JSON.stringify(metadata, null, 2);
return (
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="max-w-[220px] truncate text-left font-mono text-xs text-muted-foreground hover:text-brand"
title="View details"
>
{compact.slice(0, 60)}
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Event details</DialogTitle>
<DialogDescription>
Metadata for <span className="font-mono">{action}</span>.
</DialogDescription>
</DialogHeader>
<pre className="max-h-[60vh] overflow-auto rounded-xl border border-border bg-secondary/50 p-4 font-mono text-xs leading-relaxed">
{pretty}
</pre>
</DialogContent>
</Dialog>
);
}
+155 -8
View File
@@ -2,17 +2,29 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus } from "lucide-react";
import { Plus, Trash2, Save, Loader2 } 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";
import { Textarea } from "@/components/ui/textarea";
import { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
import {
toggleFeatureFlagAction,
setRolloutAction,
deleteFlagAction,
} from "@/app/(admin)/admin/actions";
interface Flag {
key: string;
label: string;
description: string;
enabled: boolean;
rolloutPct: number;
metadata: unknown;
updatedAt: string | null;
known: boolean;
}
export function FlagsClient({ flags }: { flags: Flag[] }) {
@@ -40,7 +52,7 @@ export function FlagsClient({ flags }: { flags: Flag[] }) {
}
return (
<div className="max-w-2xl space-y-6">
<div className="max-w-3xl space-y-6">
<Card>
<CardContent className="py-4">
<form onSubmit={create} className="flex gap-2">
@@ -59,15 +71,150 @@ export function FlagsClient({ flags }: { flags: Flag[] }) {
{flags.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">No feature flags yet.</p>
) : (
<div className="divide-y rounded-lg border">
<div className="space-y-4">
{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>
<FlagRow key={f.key} flag={f} onToggle={toggle} />
))}
</div>
)}
</div>
);
}
function FlagRow({
flag,
onToggle,
}: {
flag: Flag;
onToggle: (key: string, enabled: boolean) => void;
}) {
const router = useRouter();
const [pct, setPct] = useState(flag.rolloutPct);
const [meta, setMeta] = useState(
flag.metadata == null ? "" : JSON.stringify(flag.metadata, null, 2)
);
const [saving, setSaving] = useState(false);
const dirty =
pct !== flag.rolloutPct ||
meta.trim() !== (flag.metadata == null ? "" : JSON.stringify(flag.metadata, null, 2)).trim();
async function save() {
let metadata: unknown = null;
const trimmed = meta.trim();
if (trimmed) {
try {
metadata = JSON.parse(trimmed);
} catch {
toast.error("Metadata must be valid JSON.");
return;
}
}
setSaving(true);
try {
const res = await setRolloutAction(flag.key, pct, flag.enabled, metadata);
if (res.ok) {
toast.success("Flag saved");
router.refresh();
} else {
toast.error(res.error ?? "Failed");
}
} finally {
setSaving(false);
}
}
return (
<Card className="transition-shadow hover:shadow-md">
<CardContent className="space-y-4 p-5">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<p className="font-medium">{flag.label}</p>
{!flag.known && (
<span className="rounded-full bg-warning/15 px-2 py-0.5 text-[11px] font-semibold text-warning">
unused
</span>
)}
</div>
<p className="text-sm text-muted-foreground">{flag.description}</p>
<code className="text-xs text-muted-foreground/70">{flag.key}</code>
</div>
<Switch
checked={flag.enabled}
onCheckedChange={(v) => onToggle(flag.key, v)}
/>
</div>
{/* Rollout control */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Rollout</span>
<span className="text-muted-foreground">{pct}%</span>
</div>
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={100}
value={pct}
onChange={(e) => setPct(Number(e.target.value))}
className="h-2 flex-1 cursor-pointer appearance-none rounded-full bg-secondary accent-brand"
/>
<Input
type="number"
min={0}
max={100}
value={pct}
onChange={(e) =>
setPct(Math.max(0, Math.min(100, Number(e.target.value) || 0)))
}
className="h-9 w-20"
/>
</div>
</div>
{/* Metadata */}
<div className="space-y-1.5">
<label className="text-sm font-medium">Metadata (JSON)</label>
<Textarea
value={meta}
onChange={(e) => setMeta(e.target.value)}
placeholder='{ "audience": "beta" }'
className="min-h-[72px] font-mono text-xs"
/>
</div>
<div className="flex items-center justify-between gap-3 pt-1">
<p className="text-xs text-muted-foreground">
{flag.updatedAt
? `Updated ${new Date(flag.updatedAt).toLocaleString()}`
: "Never persisted"}
</p>
<div className="flex items-center gap-2">
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
<Trash2 className="h-4 w-4" /> Delete
</Button>
}
title={`Delete "${flag.key}"?`}
description={
flag.known
? "This removes the DB override; the flag falls back to its code default."
: "This permanently deletes the custom flag."
}
confirmLabel="Delete flag"
successMessage="Flag deleted"
onConfirm={() => deleteFlagAction(flag.key)}
/>
<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>
</CardContent>
</Card>
);
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { MoreHorizontal, RotateCw, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
import { retryJobAction, cancelJobAction } from "@/app/(admin)/admin/actions";
export function JobRowActions({ job }: { job: { id: string; status: string } }) {
const canRetry = job.status === "failed";
const canCancel = job.status === "queued" || job.status === "running";
if (!canRetry && !canCancel) {
return <span className="text-muted-foreground"></span>;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{canRetry && (
<ConfirmDialog
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<RotateCw className="h-4 w-4" /> Retry job
</DropdownMenuItem>
}
title="Retry this job?"
description="The episode will be re-queued and a fresh generation enqueued."
destructive={false}
confirmLabel="Retry"
successMessage="Job re-queued"
onConfirm={() => retryJobAction(job.id)}
/>
)}
{canCancel && (
<ConfirmDialog
trigger={
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="text-destructive focus:text-destructive"
>
<XCircle className="h-4 w-4" /> Cancel job
</DropdownMenuItem>
}
title="Cancel this job?"
description="The job will be marked failed; if the episode is still in progress it will be marked failed too."
confirmLabel="Cancel job"
successMessage="Job canceled"
onConfirm={() => cancelJobAction(job.id)}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
+158
View File
@@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Save, Loader2, RotateCcw } 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 } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { updatePlanAction } from "@/app/(admin)/admin/actions";
export interface PlanLimitsValue {
script: number;
audio: number;
art: number;
repurpose: number;
seats: number;
maxEpisodeMinutes: number;
}
export interface EditablePlan {
key: string;
name: string;
priceMonthly: number; // cents
priceYearly: number; // cents
limits: PlanLimitsValue;
}
const LIMIT_FIELDS: { key: keyof PlanLimitsValue; label: string }[] = [
{ key: "script", label: "Scripts / mo" },
{ key: "audio", label: "Audio / mo" },
{ key: "art", label: "Cover art / mo" },
{ key: "repurpose", label: "Repurpose / mo" },
{ key: "seats", label: "Seats" },
{ key: "maxEpisodeMinutes", label: "Max minutes" },
];
export function PlanEditor({ plan }: { plan: EditablePlan }) {
const router = useRouter();
const [priceMonthly, setPriceMonthly] = useState(plan.priceMonthly);
const [priceYearly, setPriceYearly] = useState(plan.priceYearly);
const [limits, setLimits] = useState<PlanLimitsValue>(plan.limits);
const [saving, setSaving] = useState(false);
const dirty =
priceMonthly !== plan.priceMonthly ||
priceYearly !== plan.priceYearly ||
LIMIT_FIELDS.some((f) => limits[f.key] !== plan.limits[f.key]);
function reset() {
setPriceMonthly(plan.priceMonthly);
setPriceYearly(plan.priceYearly);
setLimits(plan.limits);
}
async function save() {
setSaving(true);
try {
const res = await updatePlanAction(plan.key, { priceMonthly, priceYearly, limits });
if (res.ok) {
toast.success(`${plan.name} updated`);
router.refresh();
} else {
toast.error(res.error ?? "Failed");
}
} finally {
setSaving(false);
}
}
return (
<Card className="transition-shadow hover:shadow-md">
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="flex items-center gap-2">
{plan.name}
<Badge variant="brand" className="capitalize">
{plan.key}
</Badge>
</CardTitle>
{dirty && (
<span className="text-xs font-semibold text-warning">Unsaved changes</span>
)}
</CardHeader>
<CardContent className="space-y-5">
{/* Prices — stored in cents; entered in dollars. */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`${plan.key}-monthly`}>Monthly price ($)</Label>
<Input
id={`${plan.key}-monthly`}
type="number"
min={0}
step="0.01"
value={(priceMonthly / 100).toString()}
onChange={(e) =>
setPriceMonthly(Math.max(0, Math.round((Number(e.target.value) || 0) * 100)))
}
className="h-11"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`${plan.key}-yearly`}>Yearly price ($)</Label>
<Input
id={`${plan.key}-yearly`}
type="number"
min={0}
step="0.01"
value={(priceYearly / 100).toString()}
onChange={(e) =>
setPriceYearly(Math.max(0, Math.round((Number(e.target.value) || 0) * 100)))
}
className="h-11"
/>
</div>
</div>
{/* Limits — use -1 for unlimited. */}
<div className="space-y-2">
<p className="text-sm font-semibold">Limits</p>
<p className="text-xs text-muted-foreground">Use -1 for unlimited.</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{LIMIT_FIELDS.map((f) => (
<div key={f.key} className="space-y-1.5">
<Label htmlFor={`${plan.key}-${f.key}`} className="text-xs font-medium">
{f.label}
</Label>
<Input
id={`${plan.key}-${f.key}`}
type="number"
value={limits[f.key].toString()}
onChange={(e) =>
setLimits((prev) => ({
...prev,
[f.key]: Math.round(Number(e.target.value) || 0),
}))
}
className="h-11"
/>
</div>
))}
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button variant="ghost" size="sm" onClick={reset} disabled={!dirty || saving}>
<RotateCcw className="h-4 w-4" /> Reset
</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>
</CardContent>
</Card>
);
}
@@ -0,0 +1,90 @@
"use client";
import Link from "next/link";
import { MoreHorizontal, Undo2, XCircle, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
import {
refundLatestAction,
cancelSubscriptionAdminAction,
} from "@/app/(admin)/admin/actions";
export function SubscriptionRowActions({
sub,
}: {
sub: {
id: string;
referenceId: string;
provider: string;
status: string;
cancelAtPeriodEnd: boolean | null;
};
}) {
const isStripe = sub.provider === "stripe";
const canCancel = !["canceled"].includes(sub.status) && !sub.cancelAtPeriodEnd;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem asChild>
<Link href={`/admin/users/${sub.referenceId}`}>
<User className="h-4 w-4" /> View customer
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{isStripe && (
<ConfirmDialog
trigger={
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="text-destructive focus:text-destructive"
>
<Undo2 className="h-4 w-4" /> Refund latest
</DropdownMenuItem>
}
title="Refund the latest charge?"
description="This issues a Stripe refund for the most recent invoice's payment. This cannot be undone."
confirmLabel="Refund"
successMessage="Refund issued"
onConfirm={() => refundLatestAction(sub.id)}
/>
)}
{canCancel && (
<ConfirmDialog
trigger={
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="text-destructive focus:text-destructive"
>
<XCircle className="h-4 w-4" /> Cancel subscription
</DropdownMenuItem>
}
title="Cancel this subscription?"
description={
isStripe
? "Stripe will cancel at the end of the current billing period."
: "This subscription will be marked canceled immediately."
}
confirmLabel="Cancel subscription"
successMessage="Subscription canceled"
onConfirm={() => cancelSubscriptionAdminAction(sub.id)}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
/** Periodically re-fetches the current server component tree (for live pages). */
export function AutoRefresh({ seconds = 10 }: { seconds?: number }) {
const router = useRouter();
useEffect(() => {
const id = setInterval(() => router.refresh(), seconds * 1000);
return () => clearInterval(id);
}, [router, seconds]);
return null;
}
+29
View File
@@ -0,0 +1,29 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { cn } from "@/lib/utils";
export function ChartCard({
title,
description,
action,
children,
className,
}: {
title: string;
description?: string;
action?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<Card className={cn(className)}>
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
<div className="space-y-1">
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</div>
{action}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
+28
View File
@@ -0,0 +1,28 @@
// Wix-aligned chart palette. Series 1 = Wix Blue, series 2 = deep purple,
// plus success/warning for status-coded series. Kept hex (Recharts needs concrete
// colors), mirroring the design tokens in app/globals.css.
export const CHART = {
brand: "#116DFF",
brand2: "#3910ED",
success: "#00C271",
warning: "#FF5500",
ink: "#0B0B0B",
muted: "#6A6A6A",
grid: "#E4E4E4",
} as const;
// Tier colors for the plan-distribution donut.
export const TIER_COLORS: Record<string, string> = {
free: "#9AA0A6",
creator: "#116DFF",
pro: "#3910ED",
agency: "#0B0B0B",
};
export const axisTick = { fontSize: 11, fill: CHART.muted } as const;
export const tooltipStyle = {
fontSize: 12,
borderRadius: 12,
border: "1px solid #E4E4E4",
boxShadow: "0 8px 24px -4px rgba(11,11,11,0.08)",
} as const;
+196
View File
@@ -0,0 +1,196 @@
"use client";
import {
ResponsiveContainer,
AreaChart,
Area,
BarChart,
Bar,
LineChart,
Line,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
Tooltip,
Legend,
CartesianGrid,
} from "recharts";
import { CHART, axisTick, tooltipStyle } from "./chart-theme";
type Row = Record<string, string | number>;
interface SeriesDef {
key: string;
name: string;
color?: string;
}
const COLORS = [CHART.brand, CHART.brand2, CHART.success, CHART.warning];
/** Smooth area trend (e.g. MRR over time). */
export function AreaTrend({
data,
xKey,
series,
height = 280,
format,
}: {
data: Row[];
xKey: string;
series: SeriesDef[];
height?: number;
format?: (v: number) => string;
}) {
return (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<defs>
{series.map((s, i) => (
<linearGradient key={s.key} id={`grad-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={s.color ?? COLORS[i % COLORS.length]} stopOpacity={0.25} />
<stop offset="100%" stopColor={s.color ?? COLORS[i % COLORS.length]} stopOpacity={0} />
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={CHART.grid} />
<XAxis dataKey={xKey} tick={axisTick} tickLine={false} axisLine={false} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} tickFormatter={format} width={48} />
<Tooltip contentStyle={tooltipStyle} formatter={format ? (v: number) => format(v) : undefined} />
{series.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} />}
{series.map((s, i) => (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
name={s.name}
stroke={s.color ?? COLORS[i % COLORS.length]}
strokeWidth={2}
fill={`url(#grad-${s.key})`}
/>
))}
</AreaChart>
</ResponsiveContainer>
);
}
/** Stacked or grouped bars (e.g. spend by provider/day, signups). */
export function BarSeries({
data,
xKey,
series,
stacked = true,
height = 280,
format,
}: {
data: Row[];
xKey: string;
series: SeriesDef[];
stacked?: boolean;
height?: number;
format?: (v: number) => string;
}) {
return (
<ResponsiveContainer width="100%" height={height}>
<BarChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={CHART.grid} />
<XAxis dataKey={xKey} tick={axisTick} tickLine={false} axisLine={false} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} tickFormatter={format} width={48} />
<Tooltip contentStyle={tooltipStyle} formatter={format ? (v: number) => format(v) : undefined} cursor={{ fill: "rgba(17,109,255,0.06)" }} />
{series.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} />}
{series.map((s, i) => (
<Bar
key={s.key}
dataKey={s.key}
name={s.name}
stackId={stacked ? "a" : undefined}
fill={s.color ?? COLORS[i % COLORS.length]}
radius={i === series.length - 1 ? [4, 4, 0, 0] : [0, 0, 0, 0]}
maxBarSize={42}
/>
))}
</BarChart>
</ResponsiveContainer>
);
}
export function LineSeries({
data,
xKey,
series,
height = 280,
format,
}: {
data: Row[];
xKey: string;
series: SeriesDef[];
height?: number;
format?: (v: number) => string;
}) {
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={CHART.grid} />
<XAxis dataKey={xKey} tick={axisTick} tickLine={false} axisLine={false} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} tickFormatter={format} width={48} />
<Tooltip contentStyle={tooltipStyle} formatter={format ? (v: number) => format(v) : undefined} />
{series.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} />}
{series.map((s, i) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.name}
stroke={s.color ?? COLORS[i % COLORS.length]}
strokeWidth={2}
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}
/** Donut for distributions (e.g. plan tiers). */
export function Donut({
data,
height = 240,
colors,
}: {
data: { name: string; value: number }[];
height?: number;
colors?: string[];
}) {
return (
<ResponsiveContainer width="100%" height={height}>
<PieChart>
<Pie data={data} dataKey="value" nameKey="name" innerRadius={56} outerRadius={84} paddingAngle={2} strokeWidth={0}>
{data.map((entry, i) => (
<Cell key={entry.name} fill={(colors ?? COLORS)[i % (colors ?? COLORS).length]} />
))}
</Pie>
<Tooltip contentStyle={tooltipStyle} />
<Legend wrapperStyle={{ fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
);
}
/** Tiny inline sparkline for StatCards. */
export function Sparkline({ data, color = CHART.brand }: { data: number[]; color?: string }) {
const rows = data.map((v, i) => ({ i, v }));
return (
<ResponsiveContainer width="100%" height={40}>
<AreaChart data={rows} margin={{ top: 2, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={`spark-${color.replace("#", "")}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<Area type="monotone" dataKey="v" stroke={color} strokeWidth={1.5} fill={`url(#spark-${color.replace("#", "")})`} />
</AreaChart>
</ResponsiveContainer>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogTrigger,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export function ConfirmDialog({
trigger,
title,
description,
confirmLabel = "Confirm",
destructive = true,
successMessage = "Done",
body,
onConfirm,
}: {
trigger: React.ReactNode;
title: string;
description?: string;
confirmLabel?: string;
destructive?: boolean;
successMessage?: string;
body?: React.ReactNode;
onConfirm: () => Promise<{ ok: boolean; error?: string } | void>;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
async function run() {
setBusy(true);
try {
const res = await onConfirm();
if (res && res.ok === false) {
toast.error(res.error ?? "Action failed");
return;
}
toast.success(successMessage);
setOpen(false);
router.refresh();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Action failed");
} finally {
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
{body}
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={busy}>
Cancel
</Button>
</DialogClose>
<Button variant={destructive ? "destructive" : "default"} onClick={run} disabled={busy}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { SortHeader } from "./table-controls";
import { cn } from "@/lib/utils";
export interface Column<T> {
key: string;
header: string;
/** When set, the header becomes a sort toggle for this key. */
sortKey?: string;
align?: "left" | "right" | "center";
className?: string;
cell: (row: T) => React.ReactNode;
}
function alignClass(align?: "left" | "right" | "center") {
return align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left";
}
/** Server-rendered table. Sorting/search/pagination live in the URL via table-controls. */
export function DataTable<T>({
columns,
rows,
getRowKey,
empty = "No results.",
}: {
columns: Column<T>[];
rows: T[];
getRowKey: (row: T, i: number) => string;
empty?: React.ReactNode;
}) {
return (
<div className="overflow-x-auto rounded-2xl border border-border bg-card shadow-sm">
<table className="w-full text-sm">
<thead className="border-b border-border bg-secondary/60 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
{columns.map((c) => (
<th key={c.key} className={cn("px-4 py-3 font-medium", alignClass(c.align), c.className)}>
{c.sortKey ? (
<span className={cn(c.align === "right" && "flex justify-end")}>
<SortHeader label={c.header} sortKey={c.sortKey} />
</span>
) : (
c.header
)}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-14 text-center text-muted-foreground">
{empty}
</td>
</tr>
) : (
rows.map((row, i) => (
<tr key={getRowKey(row, i)} className="transition-colors hover:bg-secondary/40">
{columns.map((c) => (
<td key={c.key} className={cn("px-4 py-3 align-middle", alignClass(c.align), c.className)}>
{c.cell(row)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
);
}
/** Toolbar row above a table (search left, filters/actions right). */
export function TableToolbar({ children }: { children: React.ReactNode }) {
return (
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{children}
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { ArrowUpRight, ArrowDownRight } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { Sparkline } from "./charts";
export interface StatCardProps {
label: string;
value: string;
/** Percent change vs the previous period. */
delta?: number | null;
/** When true, a negative delta is "good" (e.g. churn, error rate). */
invertDelta?: boolean;
spark?: number[];
sparkColor?: string;
icon?: React.ComponentType<{ className?: string }>;
hint?: string;
}
export function StatCard({
label,
value,
delta,
invertDelta,
spark,
sparkColor,
icon: Icon,
hint,
}: StatCardProps) {
const hasDelta = delta !== undefined && delta !== null && Number.isFinite(delta);
const positive = hasDelta ? (invertDelta ? (delta as number) <= 0 : (delta as number) >= 0) : false;
return (
<Card>
<CardContent className="space-y-3 p-5">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">{label}</span>
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
</div>
<div className="flex items-end justify-between gap-3">
<p className="font-display text-3xl font-extrabold tracking-tight">{value}</p>
{hasDelta && (
<span
className={cn(
"mb-1 inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-semibold",
positive ? "bg-success/12 text-success" : "bg-destructive/12 text-destructive"
)}
>
{(delta as number) >= 0 ? (
<ArrowUpRight className="h-3 w-3" />
) : (
<ArrowDownRight className="h-3 w-3" />
)}
{Math.abs(delta as number).toFixed(0)}%
</span>
)}
</div>
{spark && spark.length > 1 ? (
<Sparkline data={spark} color={sparkColor} />
) : hint ? (
<p className="text-xs text-muted-foreground">{hint}</p>
) : null}
</CardContent>
</Card>
);
}
+217
View File
@@ -0,0 +1,217 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import {
Search,
ChevronLeft,
ChevronRight,
ArrowUp,
ArrowDown,
ChevronsUpDown,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
function useUpdateParams() {
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
return useCallback(
(updates: Record<string, string | null>, opts?: { resetPage?: boolean }) => {
const params = new URLSearchParams(sp.toString());
for (const [k, v] of Object.entries(updates)) {
if (v === null || v === "") params.delete(k);
else params.set(k, v);
}
if (opts?.resetPage) params.delete("page");
router.push(`${pathname}?${params.toString()}`);
},
[router, pathname, sp]
);
}
/** Debounced search box bound to the `q` param. */
export function SearchInput({ placeholder = "Search…" }: { placeholder?: string }) {
const sp = useSearchParams();
const update = useUpdateParams();
const [value, setValue] = useState(sp.get("q") ?? "");
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
return () => clearTimeout(timer.current);
}, []);
function onChange(v: string) {
setValue(v);
clearTimeout(timer.current);
timer.current = setTimeout(() => update({ q: v || null }, { resetPage: true }), 350);
}
return (
<div className="relative w-full sm:max-w-xs">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="h-10 pl-9"
/>
</div>
);
}
/** Sortable column header — toggles the `sort` param ("key.dir"). */
export function SortHeader({
label,
sortKey,
className,
}: {
label: string;
sortKey: string;
className?: string;
}) {
const sp = useSearchParams();
const update = useUpdateParams();
const [k, dir] = (sp.get("sort") ?? "").split(".");
const active = k === sortKey;
const nextDir = active && dir === "desc" ? "asc" : "desc";
return (
<button
type="button"
onClick={() => update({ sort: `${sortKey}.${nextDir}` })}
className={cn("inline-flex items-center gap-1 font-medium hover:text-foreground", className)}
>
{label}
{active ? (
dir === "desc" ? (
<ArrowDown className="h-3.5 w-3.5 text-brand" />
) : (
<ArrowUp className="h-3.5 w-3.5 text-brand" />
)
) : (
<ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
)}
</button>
);
}
/** Filter select bound to an arbitrary param. */
export function FilterSelect({
param,
options,
placeholder,
allLabel = "All",
}: {
param: string;
options: { value: string; label: string }[];
placeholder: string;
allLabel?: string;
}) {
const sp = useSearchParams();
const update = useUpdateParams();
const value = sp.get(param) ?? "all";
return (
<Select
value={value}
onValueChange={(v) => update({ [param]: v === "all" ? null : v }, { resetPage: true })}
>
<SelectTrigger className="h-10 w-[150px]">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{allLabel}</SelectItem>
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** Prev/next pagination bound to the `page` param. */
export function Pagination({
page,
pageSize,
total,
}: {
page: number;
pageSize: number;
total: number;
}) {
const update = useUpdateParams();
const pages = Math.max(1, Math.ceil(total / pageSize));
const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
const to = Math.min(total, page * pageSize);
if (total <= pageSize) {
return <p className="text-xs text-muted-foreground">{total} result{total === 1 ? "" : "s"}</p>;
}
return (
<div className="flex items-center justify-between gap-4">
<p className="text-xs text-muted-foreground">
{from}{to} of {total}
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => update({ page: String(page - 1) })}
>
<ChevronLeft className="h-4 w-4" /> Prev
</Button>
<span className="px-2 text-xs text-muted-foreground">
{page} / {pages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= pages}
onClick={() => update({ page: String(page + 1) })}
>
Next <ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
/** Date-range preset toggle bound to the `range` param. */
export function RangePicker({ value }: { value?: string }) {
const update = useUpdateParams();
const current = value ?? "30d";
const options: [string, string][] = [
["7d", "7D"],
["30d", "30D"],
["90d", "90D"],
["12m", "12M"],
];
return (
<div className="inline-flex rounded-full border border-border p-0.5">
{options.map(([val, label]) => (
<button
key={val}
type="button"
onClick={() => update({ range: val })}
className={cn(
"rounded-full px-3 py-1 text-xs font-semibold transition-colors",
current === val ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
>
{label}
</button>
))}
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { LogIn, ShieldCheck, ShieldOff, Ban, UserCheck, Gift } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
import { authClient } from "@/lib/auth/auth-client";
import {
banUserAction,
setRoleAction,
compPlanAction,
} from "@/app/(admin)/admin/actions";
type CompPlan = "creator" | "pro" | "agency";
type CompInterval = "month" | "year";
export function UserDetailActions({
user,
}: {
user: { id: string; role: string; banned: boolean };
}) {
const router = useRouter();
const [impersonating, setImpersonating] = useState(false);
const [compPlan, setCompPlan] = useState<CompPlan>("pro");
const [compInterval, setCompInterval] = useState<CompInterval>("month");
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");
}
}
async function impersonate() {
setImpersonating(true);
try {
const res = await authClient.admin.impersonateUser({ userId: user.id });
if (res.error) {
toast.error(res.error.message ?? "Could not impersonate");
return;
}
window.location.href = "/dashboard";
} catch (e) {
toast.error(e instanceof Error ? e.message : "Could not impersonate");
} finally {
setImpersonating(false);
}
}
return (
<div className="flex flex-wrap items-center gap-2">
<Button variant="brand" size="sm" onClick={impersonate} disabled={impersonating}>
<LogIn className="h-4 w-4" />
{impersonating ? "Starting…" : "Impersonate"}
</Button>
{user.role === "admin" ? (
<Button
variant="outline"
size="sm"
onClick={() => run(() => setRoleAction(user.id, "user"), "Role updated")}
>
<ShieldOff className="h-4 w-4" /> Revoke admin
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => run(() => setRoleAction(user.id, "admin"), "Role updated")}
>
<ShieldCheck className="h-4 w-4" /> Make admin
</Button>
)}
<ConfirmDialog
trigger={
<Button variant="outline" size="sm">
<Gift className="h-4 w-4" /> Comp plan
</Button>
}
title="Comp a paid plan"
description="Grant this user a complimentary subscription. No payment is collected."
destructive={false}
confirmLabel="Grant plan"
successMessage="Comped plan granted"
body={
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="comp-plan">Plan</Label>
<Select value={compPlan} onValueChange={(v) => setCompPlan(v as CompPlan)}>
<SelectTrigger id="comp-plan">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="creator">Creator</SelectItem>
<SelectItem value="pro">Pro</SelectItem>
<SelectItem value="agency">Agency</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="comp-interval">Interval</Label>
<Select
value={compInterval}
onValueChange={(v) => setCompInterval(v as CompInterval)}
>
<SelectTrigger id="comp-interval">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="month">Monthly (30 days)</SelectItem>
<SelectItem value="year">Yearly (365 days)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
}
onConfirm={() => compPlanAction(user.id, compPlan, compInterval)}
/>
{user.banned ? (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm">
<UserCheck className="h-4 w-4" /> Unban
</Button>
}
title="Unban this user?"
description="They will regain access immediately."
destructive={false}
confirmLabel="Unban"
successMessage="User unbanned"
onConfirm={() => banUserAction(user.id, false)}
/>
) : (
<ConfirmDialog
trigger={
<Button variant="destructive" size="sm">
<Ban className="h-4 w-4" /> Ban
</Button>
}
title="Ban this user?"
description="Their active sessions will be revoked and they'll lose access immediately."
confirmLabel="Ban user"
successMessage="User banned"
onConfirm={() => banUserAction(user.id, true)}
/>
)}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { MoreHorizontal, Eye, ShieldCheck, ShieldOff, Ban, UserCheck } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { banUserAction, setRoleAction } from "@/app/(admin)/admin/actions";
export function UserRowActions({ user }: { user: { id: string; role: string; banned: boolean } }) {
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem asChild>
<Link href={`/admin/users/${user.id}`}>
<Eye className="h-4 w-4" /> View details
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{user.role === "admin" ? (
<DropdownMenuItem onSelect={() => run(() => setRoleAction(user.id, "user"), "Role updated")}>
<ShieldOff className="h-4 w-4" /> Revoke admin
</DropdownMenuItem>
) : (
<DropdownMenuItem onSelect={() => run(() => setRoleAction(user.id, "admin"), "Role updated")}>
<ShieldCheck className="h-4 w-4" /> Make admin
</DropdownMenuItem>
)}
{user.banned ? (
<DropdownMenuItem onSelect={() => run(() => banUserAction(user.id, false), "User unbanned")}>
<UserCheck className="h-4 w-4" /> Unban
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => run(() => banUserAction(user.id, true), "User banned")}
>
<Ban className="h-4 w-4" /> Ban
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
+24 -17
View File
@@ -2,11 +2,13 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Copy, KeyRound, Trash2, Check } from "lucide-react";
import { Loader2, Copy, KeyRound, Trash2 } 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 { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
import { EmptyState } from "@/components/ui/empty-state";
import { createApiKeyAction, revokeApiKeyAction } from "@/app/(app)/api-keys/actions";
interface KeyRow {
@@ -37,17 +39,6 @@ export function ApiKeysClient({ keys }: { keys: KeyRow[] }) {
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 && (
@@ -88,9 +79,13 @@ export function ApiKeysClient({ keys }: { keys: KeyRow[] }) {
</Card>
{keys.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">No API keys yet.</p>
<EmptyState
icon={KeyRound}
title="No API keys yet"
description="Create a key above to start generating episodes programmatically."
/>
) : (
<div className="divide-y rounded-lg border">
<div className="divide-y rounded-2xl border">
{keys.map((k) => (
<div key={k.id} className="flex items-center justify-between gap-3 p-4">
<div className="min-w-0">
@@ -100,9 +95,21 @@ export function ApiKeysClient({ keys }: { keys: KeyRow[] }) {
{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>
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm" className="text-destructive">
<Trash2 className="h-4 w-4" /> Revoke
</Button>
}
title="Revoke this key?"
description={`Apps using "${k.name}" will immediately stop working. This cannot be undone.`}
confirmLabel="Revoke key"
successMessage="Key revoked"
onConfirm={async () => {
const res = await revokeApiKeyAction(k.id);
return { ok: res.ok, error: res.error };
}}
/>
</div>
))}
</div>
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useState } from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Menu, X, Mic } from "lucide-react";
import type { PlanKey } from "@/lib/billing/plans";
import { SidebarNav } from "./sidebar-nav";
/**
* Left-drawer navigation for phones. Mirrors `AdminMobileNav` (radix dialog),
* wrapping the shared `SidebarNav` and closing on every link tap. Only rendered
* below the `md` breakpoint.
*/
export function AppMobileNav({ plan, workspaceName }: { plan: PlanKey; workspaceName: string }) {
const [open, setOpen] = useState(false);
return (
<DialogPrimitive.Root open={open} onOpenChange={setOpen}>
<DialogPrimitive.Trigger className="inline-flex h-10 w-10 items-center justify-center rounded-full text-foreground hover:bg-secondary md:hidden">
<Menu className="h-5 w-5" />
<span className="sr-only">Open menu</span>
</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-foreground/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=open]:fade-in-0 md:hidden" />
<DialogPrimitive.Content className="fixed inset-y-0 left-0 z-50 w-72 overflow-y-auto border-r border-border bg-background shadow-xl duration-200 data-[state=open]:animate-in data-[state=open]:slide-in-from-left md:hidden">
<div className="flex items-center justify-between border-b border-border px-4 py-4">
<DialogPrimitive.Title className="flex items-center gap-2 font-display font-bold tracking-tight">
<span className="flex h-8 w-8 items-center justify-center rounded-2xl bg-brand text-brand-foreground">
<Mic className="h-4 w-4" />
</span>
<span className="truncate">{workspaceName}</span>
</DialogPrimitive.Title>
<DialogPrimitive.Close className="rounded-full p-1 text-muted-foreground hover:text-foreground">
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
<SidebarNav plan={plan} onNavigate={() => setOpen(false)} />
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
+13 -25
View File
@@ -1,39 +1,27 @@
"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")}`;
}
import { WaveformPlayer } from "./waveform-player";
/**
* Episode audio player — a thin wrapper around the custom WaveformPlayer so the
* authed asset route and (optional) ZIP export are wired up for the editor.
*/
export function AudioPlayer({
storageKey,
durationSec,
episodeId,
}: {
storageKey: string;
durationSec?: number | null;
episodeId?: string;
}) {
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>
<WaveformPlayer
src={src}
downloadUrl={`${src}?download=1`}
durationSec={durationSec}
exportUrl={episodeId ? `/api/episodes/${episodeId}/export` : undefined}
/>
);
}
+15 -12
View File
@@ -7,6 +7,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
import { cn, formatPrice } from "@/lib/utils";
import { PLAN_ORDER, PLANS, type PlanKey } from "@/lib/billing/plans";
import type { BillingInterval } from "@/lib/billing/catalog";
@@ -83,19 +84,21 @@ export function BillingClient({
</Button>
)}
{!subscription.cancelAtPeriodEnd && (
<Button
variant="ghost"
className="text-destructive"
onClick={() => {
if (confirm("Cancel your subscription at the end of the period?")) {
go(cancelSubscriptionAction, "cancel");
}
<ConfirmDialog
trigger={
<Button variant="ghost" className="text-destructive">
Cancel
</Button>
}
title="Cancel subscription?"
description="Your plan stays active until the end of the current billing period, then reverts to Free. You can resubscribe any time."
confirmLabel="Cancel subscription"
successMessage="Subscription will cancel at period end"
onConfirm={async () => {
const res = await cancelSubscriptionAction();
return res.ok ? { ok: true } : { ok: false, error: res.error };
}}
disabled={busy === "cancel"}
>
{busy === "cancel" && <Loader2 className="h-4 w-4 animate-spin" />}
Cancel
</Button>
/>
)}
</div>
</CardContent>
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Command } from "cmdk";
import { useTheme } from "next-themes";
import {
LayoutDashboard,
Mic2,
ListMusic,
BarChart3,
CreditCard,
Users,
KeyRound,
Settings,
Plus,
Moon,
Sun,
Search,
} from "lucide-react";
interface PaletteRoute {
label: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
keywords?: string[];
}
const ROUTES: PaletteRoute[] = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard, keywords: ["home"] },
{ label: "Episodes", href: "/episodes", icon: Mic2, keywords: ["library", "podcasts"] },
{ label: "Series", href: "/series", icon: ListMusic, keywords: ["season"] },
{ label: "Usage", href: "/usage", icon: BarChart3, keywords: ["limits", "quota"] },
{ label: "Billing", href: "/billing", icon: CreditCard, keywords: ["plan", "upgrade", "subscription"] },
{ label: "Team", href: "/team", icon: Users, keywords: ["members", "workspace", "branding"] },
{ label: "API keys", href: "/api-keys", icon: KeyRound, keywords: ["developer", "token"] },
{ label: "Settings", href: "/settings", icon: Settings, keywords: ["account", "profile"] },
];
/**
* Global ⌘K / Ctrl-K command palette. Provides "New episode", jump-to-route
* navigation, and a theme toggle. Mounted once in the app layout.
*/
export function CommandPalette() {
const router = useRouter();
const { resolvedTheme, setTheme } = useTheme();
const [open, setOpen] = useState(false);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((o) => !o);
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
function run(action: () => void) {
setOpen(false);
action();
}
const isDark = resolvedTheme === "dark";
return (
<Command.Dialog
open={open}
onOpenChange={setOpen}
label="Command palette"
shouldFilter
contentClassName="fixed left-1/2 top-[20vh] z-[61] w-[92vw] max-w-lg -translate-x-1/2 overflow-hidden rounded-2xl border border-border bg-popover text-popover-foreground shadow-lg outline-none data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
overlayClassName="fixed inset-0 z-[60] bg-foreground/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=open]:fade-in-0"
>
<div className="flex items-center gap-2 border-b border-border px-4">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<Command.Input
placeholder="Search actions and pages…"
className="h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<Command.List className="max-h-[55vh] overflow-y-auto p-2">
<Command.Empty className="py-8 text-center text-sm text-muted-foreground">
No results found.
</Command.Empty>
<Command.Group
heading="Actions"
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-muted-foreground"
>
<PaletteItem
label="New episode"
keywords={["create", "generate"]}
icon={Plus}
onSelect={() => run(() => router.push("/episodes/new"))}
/>
<PaletteItem
label={isDark ? "Switch to light mode" : "Switch to dark mode"}
keywords={["theme", "dark", "light", "appearance"]}
icon={isDark ? Sun : Moon}
onSelect={() => run(() => setTheme(isDark ? "light" : "dark"))}
/>
</Command.Group>
<Command.Group
heading="Go to"
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-muted-foreground"
>
{ROUTES.map((r) => (
<PaletteItem
key={r.href}
label={r.label}
keywords={r.keywords}
icon={r.icon}
onSelect={() => run(() => router.push(r.href))}
/>
))}
</Command.Group>
</Command.List>
</Command.Dialog>
);
}
function PaletteItem({
label,
keywords,
icon: Icon,
onSelect,
}: {
label: string;
keywords?: string[];
icon: React.ComponentType<{ className?: string }>;
onSelect: () => void;
}) {
return (
<Command.Item
value={`${label} ${(keywords ?? []).join(" ")}`}
onSelect={onSelect}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm outline-none data-[selected=true]:bg-secondary data-[selected=true]:text-foreground"
>
<Icon className="h-4 w-4 text-muted-foreground" />
{label}
</Command.Item>
);
}
+185 -23
View File
@@ -3,8 +3,30 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { MoreVertical, ImageIcon, RefreshCw, Trash2, Loader2 } from "lucide-react";
import {
MoreVertical,
ImageIcon,
RefreshCw,
Trash2,
Loader2,
Share2,
FileArchive,
Copy,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
DropdownMenu,
DropdownMenuContent,
@@ -12,11 +34,30 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { regenerateAction, deleteEpisodeAction } from "@/app/(app)/episodes/actions";
import {
regenerateAction,
deleteEpisodeAction,
setEpisodeShareAction,
} from "@/app/(app)/episodes/actions";
export function EpisodeActions({ episodeId }: { episodeId: string }) {
export function EpisodeActions({
episodeId,
initialShareId = null,
}: {
episodeId: string;
initialShareId?: string | null;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [shareId, setShareId] = useState<string | null>(initialShareId);
const [shareToggling, setShareToggling] = useState(false);
const [copied, setCopied] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const shareUrl =
shareId && typeof window !== "undefined" ? `${window.location.origin}/p/${shareId}` : "";
async function regen(type: "art" | "full") {
setBusy(true);
@@ -30,11 +71,38 @@ export function EpisodeActions({ episodeId }: { episodeId: string }) {
}
}
async function del() {
if (!confirm("Delete this episode? This cannot be undone.")) return;
async function toggleShare(enabled: boolean) {
setShareToggling(true);
const res = await setEpisodeShareAction(episodeId, enabled);
setShareToggling(false);
if (!res.ok) {
toast.error(res.error ?? "Could not update sharing");
return;
}
setShareId(res.shareId ?? null);
toast.success(enabled ? "Public link enabled" : "Sharing turned off");
router.refresh();
}
function copyLink() {
if (!shareUrl) return;
navigator.clipboard
.writeText(shareUrl)
.then(() => {
setCopied(true);
toast.success("Link copied");
setTimeout(() => setCopied(false), 1500);
})
.catch(() => toast.error("Could not copy"));
}
async function confirmDelete() {
setDeleting(true);
const res = await deleteEpisodeAction(episodeId);
setDeleting(false);
if (res.ok) {
toast.success("Episode deleted");
setDeleteOpen(false);
router.push("/episodes");
router.refresh();
} else {
@@ -43,24 +111,118 @@ export function EpisodeActions({ episodeId }: { episodeId: string }) {
}
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" />}
<>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShareOpen(true)}>
<Share2 className="h-4 w-4" /> Share
</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>
<Button asChild variant="outline" size="icon" title="Download everything (.zip)">
<a href={`/api/episodes/${episodeId}/export`} aria-label="Export ZIP">
<FileArchive className="h-4 w-4" />
</a>
</Button>
<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-56">
<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>
<DropdownMenuItem asChild>
<a href={`/api/episodes/${episodeId}/export`}>
<FileArchive className="h-4 w-4" /> Export ZIP
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setDeleteOpen(true)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4" /> Delete episode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Share dialog */}
<Dialog open={shareOpen} onOpenChange={setShareOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Share this episode</DialogTitle>
<DialogDescription>
Turn on a public link to let anyone listen no account needed.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-xl border bg-secondary/40 px-4 py-3">
<div className="space-y-0.5">
<p className="text-sm font-semibold">Public link</p>
<p className="text-xs text-muted-foreground">
{shareId ? "Anyone with the link can listen." : "Currently private."}
</p>
</div>
<div className="flex items-center gap-2">
{shareToggling && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
<Switch
checked={!!shareId}
disabled={shareToggling}
onCheckedChange={toggleShare}
aria-label="Toggle public link"
/>
</div>
</div>
{shareId && (
<div className="space-y-2">
<Label htmlFor="share-url">Public URL</Label>
<div className="flex gap-2">
<Input id="share-url" readOnly value={shareUrl} className="font-mono text-xs" />
<Button type="button" variant="outline" size="icon" onClick={copyLink}>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this episode?</DialogTitle>
<DialogDescription>
This permanently removes the script, audio, cover art and all repurposed content. This
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={deleting}>
Cancel
</Button>
</DialogClose>
<Button variant="destructive" onClick={confirmDelete} disabled={deleting}>
{deleting && <Loader2 className="h-4 w-4 animate-spin" />}
Delete episode
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ export interface EpisodeCardData {
export function EpisodeCard({ episode }: { episode: EpisodeCardData }) {
return (
<Link href={`/episodes/${episode.id}`}>
<Card className="group overflow-hidden transition-shadow hover:shadow-md">
<Card className="group overflow-hidden transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<div className="relative aspect-square bg-muted">
{episode.coverArtKey ? (
// eslint-disable-next-line @next/next/no-img-element
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Eye, Loader2 } from "lucide-react";
import { authClient } from "@/lib/auth/auth-client";
/**
* Sticky banner shown while an admin is impersonating a user. Reads the live
* session; renders nothing for normal sessions. "Stop" ends impersonation and
* returns to the admin surface.
*/
export function ImpersonationBanner() {
const { data: session } = authClient.useSession();
const router = useRouter();
const [busy, setBusy] = useState(false);
// `impersonatedBy` is set on the session row by the better-auth admin plugin.
const impersonatedBy = session?.session
? (session.session as { impersonatedBy?: string | null }).impersonatedBy
: null;
if (!impersonatedBy) return null;
const who = session?.user?.email ?? session?.user?.name ?? "this user";
async function stop() {
setBusy(true);
try {
await authClient.admin.stopImpersonating();
} finally {
router.push("/admin");
router.refresh();
}
}
return (
<div className="sticky top-0 z-40 flex items-center justify-center gap-3 bg-warning px-4 py-2 text-center text-sm font-medium text-warning-foreground">
<span className="inline-flex items-center gap-1.5">
<Eye className="h-4 w-4" />
Viewing as {who}
</span>
<button
onClick={stop}
disabled={busy}
className="inline-flex items-center gap-1 rounded-full bg-warning-foreground/15 px-3 py-0.5 font-semibold transition-colors hover:bg-warning-foreground/25 disabled:opacity-60"
>
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Stop
</button>
</div>
);
}
+42 -5
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { FileText, Hash, Mail, Loader2, Copy, Sparkles } from "lucide-react";
import { FileText, Hash, Mail, Loader2, Copy, Sparkles, Download } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -16,6 +16,27 @@ const FORMATS: { key: Format; label: string; icon: React.ComponentType<{ classNa
{ key: "newsletter", label: "Newsletter", icon: Mail },
];
function wordCount(text: string): number {
const t = text.trim();
return t ? t.split(/\s+/).length : 0;
}
function slugify(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "content";
}
function downloadMarkdown(filename: string, markdown: string) {
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export function RepurposeClient({
episodeId,
initial,
@@ -64,13 +85,29 @@ export function RepurposeClient({
<CardContent className="flex-1">
{c ? (
<div className="space-y-3">
<p className="font-medium">{c.title}</p>
<div className="flex items-start justify-between gap-2">
<p className="font-medium">{c.title}</p>
<span className="shrink-0 whitespace-nowrap text-xs text-muted-foreground">
{wordCount(c.body).toLocaleString()} words · {c.body.length.toLocaleString()} chars
</span>
</div>
<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 className="flex items-center gap-1">
<Button size="sm" variant="ghost" onClick={() => copy(`${c.title}\n\n${c.body}`)}>
<Copy className="h-4 w-4" /> Copy
</Button>
<Button
size="sm"
variant="ghost"
onClick={() =>
downloadMarkdown(`${slugify(c.title)}.md`, `# ${c.title}\n\n${c.body}\n`)
}
>
<Download className="h-4 w-4" /> Download .md
</Button>
</div>
</div>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">
+106 -39
View File
@@ -1,12 +1,13 @@
"use client";
import { useState, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Loader2, Save, RefreshCw, AudioLines } from "lucide-react";
import { Loader2, Save, RefreshCw, AudioLines, Clipboard, Clock } 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 { Badge } from "@/components/ui/badge";
import {
updateScriptAction,
regenerateAction,
@@ -27,6 +28,25 @@ interface Script {
sections: Section[];
}
// Average speaking pace used to estimate spoken duration from word count.
const WORDS_PER_MINUTE = 150;
function wordCount(text: string): number {
const t = text.trim();
return t ? t.split(/\s+/).length : 0;
}
function estimateDuration(words: number): string {
const totalSec = Math.round((words / WORDS_PER_MINUTE) * 60);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
function sectionWords(section: Section): number {
return section.turns.reduce((n, t) => n + wordCount(t.text), 0);
}
export function ScriptEditor({
episodeId,
script,
@@ -43,6 +63,11 @@ export function ScriptEditor({
const [busySection, setBusySection] = useState<string | null>(null);
const [rerecording, setRerecording] = useState(false);
const totalWords = useMemo(
() => sections.reduce((n, s) => n + sectionWords(s), 0),
[sections]
);
function updateTurn(si: number, ti: number, text: string) {
setSections((prev) =>
prev.map((s, i) =>
@@ -98,13 +123,47 @@ export function ScriptEditor({
}
}
function copyTranscript() {
const lines: string[] = [script.title, ""];
for (const section of sections) {
lines.push(section.title, "");
for (const turn of section.turns) {
const name = speakerNames[turn.speakerKey] ?? turn.speakerKey;
lines.push(`${name}: ${turn.text}`, "");
}
}
navigator.clipboard
.writeText(lines.join("\n").trimEnd())
.then(() => toast.success("Transcript copied"))
.catch(() => toast.error("Could not copy"));
}
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">
{/* Sticky save bar — dirty-aware. */}
<div className="sticky top-2 z-10 flex flex-wrap items-center justify-between gap-3 rounded-2xl border bg-card/95 px-4 py-3 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-card/80">
<div className="flex items-center gap-3">
<h2 className="font-display text-lg font-extrabold tracking-tight">Script</h2>
<span className="hidden items-center gap-1.5 text-xs text-muted-foreground sm:inline-flex">
<Clock className="h-3.5 w-3.5" />
{totalWords.toLocaleString()} words · ~{estimateDuration(totalWords)}
</span>
{dirty && (
<Badge variant="warning" className="hidden sm:inline-flex">
Unsaved changes
</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" onClick={copyTranscript}>
<Clipboard className="h-4 w-4" /> Copy full transcript
</Button>
<Button variant="outline" size="sm" onClick={rerecord} disabled={rerecording}>
{rerecording ? <Loader2 className="h-4 w-4 animate-spin" /> : <AudioLines className="h-4 w-4" />}
{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}>
@@ -114,40 +173,48 @@ export function ScriptEditor({
</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))}
/>
{sections.map((section, si) => {
const words = sectionWords(section);
return (
<Card key={section.id}>
<CardHeader className="flex flex-row items-center justify-between gap-2 py-3">
<div className="min-w-0">
<CardTitle className="truncate text-sm">{section.title}</CardTitle>
<p className="mt-0.5 text-xs text-muted-foreground">
{words.toLocaleString()} words · {estimateDuration(words)}
</p>
</div>
))}
</CardContent>
</Card>
))}
<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>
);
}
+202 -3
View File
@@ -2,20 +2,62 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { Loader2, 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { authClient } from "@/lib/auth/auth-client";
import { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
import { authClient, signOut } from "@/lib/auth/auth-client";
import { VOICE_CATALOG } from "@/lib/ai/voices";
import { LANGUAGES } from "@/lib/episodes/options";
import { savePreferencesAction, deleteAccountAction } from "@/app/(app)/settings/actions";
export function SettingsClient({ name, email }: { name: string; email: string }) {
const NO_VOICE = "__none__";
interface Preferences {
defaultVoiceId: string | null;
defaultLanguage: string;
emailOnEpisodeReady: boolean;
productEmails: boolean;
}
export function SettingsClient({
name,
email,
preferences,
}: {
name: string;
email: string;
preferences: Preferences;
}) {
const router = useRouter();
const [displayName, setDisplayName] = useState(name);
const [savingProfile, setSavingProfile] = useState(false);
const [savingPw, setSavingPw] = useState(false);
// Defaults
const [voiceId, setVoiceId] = useState(preferences.defaultVoiceId ?? NO_VOICE);
const [language, setLanguage] = useState(preferences.defaultLanguage);
const [savingDefaults, setSavingDefaults] = useState(false);
// Notifications
const [emailOnReady, setEmailOnReady] = useState(preferences.emailOnEpisodeReady);
const [productEmails, setProductEmails] = useState(preferences.productEmails);
const [savingNotif, setSavingNotif] = useState(false);
// Danger zone
const [confirmEmail, setConfirmEmail] = useState("");
async function saveProfile(e: React.FormEvent) {
e.preventDefault();
setSavingProfile(true);
@@ -45,6 +87,38 @@ export function SettingsClient({ name, email }: { name: string; email: string })
}
}
async function saveDefaults() {
setSavingDefaults(true);
const res = await savePreferencesAction({
defaultVoiceId: voiceId === NO_VOICE ? null : voiceId,
defaultLanguage: language,
});
setSavingDefaults(false);
if (res.ok) toast.success("Defaults saved");
else toast.error(res.error ?? "Could not save");
}
async function saveNotifications(next: Partial<Preferences>) {
const emailVal = next.emailOnEpisodeReady ?? emailOnReady;
const productVal = next.productEmails ?? productEmails;
setEmailOnReady(emailVal);
setProductEmails(productVal);
setSavingNotif(true);
const res = await savePreferencesAction({
emailOnEpisodeReady: emailVal,
productEmails: productVal,
});
setSavingNotif(false);
if (!res.ok) {
toast.error(res.error ?? "Could not save");
// Revert optimistic state.
setEmailOnReady(preferences.emailOnEpisodeReady);
setProductEmails(preferences.productEmails);
} else {
toast.success("Notification preferences saved");
}
}
return (
<div className="max-w-xl space-y-6">
<Card>
@@ -92,6 +166,131 @@ export function SettingsClient({ name, email }: { name: string; email: string })
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Defaults</CardTitle>
<CardDescription>
Pre-select a voice and language when creating new episodes.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="default-voice">Default host voice</Label>
<Select value={voiceId} onValueChange={setVoiceId}>
<SelectTrigger id="default-voice">
<SelectValue placeholder="Choose a voice" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_VOICE}>No default (choose each time)</SelectItem>
{VOICE_CATALOG.map((v) => (
<SelectItem key={v.id} value={v.id}>
{v.name}
{v.accent ? ` · ${v.accent}` : ""}
{v.description ? `${v.description}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="default-language">Default language</Label>
<Select value={language} onValueChange={setLanguage}>
<SelectTrigger id="default-language">
<SelectValue placeholder="Choose a language" />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={saveDefaults} disabled={savingDefaults}>
{savingDefaults ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save defaults
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>Choose which emails you want to receive.</CardDescription>
</CardHeader>
<CardContent className="space-y-1">
<div className="flex items-center justify-between gap-4 py-3">
<div className="space-y-0.5">
<p className="text-sm font-semibold">Episode ready</p>
<p className="text-xs text-muted-foreground">
Email me when an episode finishes generating.
</p>
</div>
<Switch
checked={emailOnReady}
disabled={savingNotif}
onCheckedChange={(v) => saveNotifications({ emailOnEpisodeReady: v })}
aria-label="Email me when an episode is ready"
/>
</div>
<div className="flex items-center justify-between gap-4 border-t py-3">
<div className="space-y-0.5">
<p className="text-sm font-semibold">Product updates</p>
<p className="text-xs text-muted-foreground">
Occasional product news, tips and announcements.
</p>
</div>
<Switch
checked={productEmails}
disabled={savingNotif}
onCheckedChange={(v) => saveNotifications({ productEmails: v })}
aria-label="Receive product update emails"
/>
</div>
</CardContent>
</Card>
<Card className="border-destructive/30">
<CardHeader>
<CardTitle className="text-destructive">Danger zone</CardTitle>
<CardDescription>
Permanently delete your account and all of your episodes. This cannot be undone.
</CardDescription>
</CardHeader>
<CardContent>
<ConfirmDialog
trigger={<Button variant="destructive">Delete account</Button>}
title="Delete your account?"
description="This permanently deletes your account, every episode, series, and all generated content. This action is irreversible."
confirmLabel="Delete my account"
successMessage="Account deleted"
body={
<div className="space-y-2">
<Label htmlFor="confirm-email">
Type <span className="font-semibold text-foreground">{email}</span> to confirm
</Label>
<Input
id="confirm-email"
value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)}
placeholder={email}
autoComplete="off"
/>
</div>
}
onConfirm={async () => {
const res = await deleteAccountAction(confirmEmail);
if (res.ok) {
await signOut();
router.push("/");
}
return res;
}}
/>
</CardContent>
</Card>
</div>
);
}
+2 -1
View File
@@ -36,7 +36,7 @@ const NAV: NavItem[] = [
{ label: "Settings", href: "/settings", icon: Settings },
];
export function SidebarNav({ plan }: { plan: PlanKey }) {
export function SidebarNav({ plan, onNavigate }: { plan: PlanKey; onNavigate?: () => void }) {
const pathname = usePathname();
const features = PLANS[plan].features;
@@ -49,6 +49,7 @@ export function SidebarNav({ plan }: { plan: PlanKey }) {
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 rounded-full px-4 py-2.5 text-sm font-medium transition-colors",
active
+101 -8
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, UserPlus, Building2, Save } from "lucide-react";
import { Loader2, UserPlus, Building2, Save, Mic, Plus } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -12,7 +12,42 @@ 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";
import { inviteMemberAction, saveBrandingAction } from "@/app/(app)/team/actions";
/**
* Pure client-side #rrggbb → "H S% L%" converter for the live branding preview.
* Mirrors `hexToHslTriplet` in lib/branding.ts (which is server-only). Returns
* null for invalid hex so the preview falls back to the default brand token.
*/
function hexToHslTriplet(hex: string): string | null {
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
if (!m) return null;
const int = parseInt(m[1], 16);
const r = ((int >> 16) & 255) / 255;
const g = ((int >> 8) & 255) / 255;
const b = (int & 255) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
default:
h = (r - g) / d + 4;
}
h /= 6;
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
}
interface Member {
id: string;
@@ -101,16 +136,16 @@ function MembersCard({ orgId, members, seats }: { orgId: string; members: Member
async function invite(e: React.FormEvent) {
e.preventDefault();
// Fast UX guard only — the server action is the real seat-limit authority.
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" });
const res = await inviteMemberAction(orgId, email.trim());
setBusy(false);
if (error) {
toast.error(error.message ?? "Could not invite");
if (!res.ok) {
toast.error(res.error ?? "Could not invite");
return;
}
toast.success(`Invitation sent to ${email}`);
@@ -129,7 +164,7 @@ function MembersCard({ orgId, members, seats }: { orgId: string; members: Member
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="divide-y rounded-lg border">
<div className="divide-y rounded-2xl border">
{members.map((m) => (
<div key={m.id} className="flex items-center gap-3 p-3">
<Avatar className="h-8 w-8">
@@ -182,6 +217,9 @@ function BrandingCard({ orgId, branding }: { orgId: string; branding: Branding |
}
}
const previewHsl = hexToHslTriplet(primaryColor);
const previewName = brandName.trim() || "Your brand";
return (
<Card>
<CardHeader>
@@ -197,9 +235,64 @@ function BrandingCard({ orgId, branding }: { orgId: string; branding: Branding |
</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 className="flex items-center gap-2">
<span
aria-hidden
className="h-9 w-9 shrink-0 rounded-xl border border-border"
style={{ backgroundColor: previewHsl ? `hsl(${previewHsl})` : "hsl(var(--brand))" }}
/>
<Input id="color" placeholder="#7c3aed" value={primaryColor} onChange={(e) => setPrimaryColor(e.target.value)} />
</div>
</div>
</div>
{/* Live white-label preview — applies the chosen colour to a mini app mock. */}
<div className="space-y-2">
<Label>Live preview</Label>
<div
className="overflow-hidden rounded-2xl border border-border bg-background"
style={previewHsl ? ({ "--brand": previewHsl, "--ring": previewHsl } as React.CSSProperties) : undefined}
>
<div className="flex items-center justify-between gap-2 border-b border-border bg-background/80 px-3 py-2.5">
<div className="flex items-center gap-2 font-display text-sm font-bold tracking-tight">
{logoUrl.trim() ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoUrl} alt={previewName} className="h-6 w-auto max-w-[120px] object-contain" />
) : (
<>
<span className="flex h-6 w-6 items-center justify-center rounded-lg bg-brand text-brand-foreground">
<Mic className="h-3.5 w-3.5" />
</span>
<span className="truncate">{previewName}</span>
</>
)}
</div>
<span className="inline-flex items-center gap-1 rounded-full bg-brand px-2.5 py-1 text-xs font-semibold text-brand-foreground">
<Plus className="h-3 w-3" /> New
</span>
</div>
<div className="flex gap-3 p-3">
<div className="hidden w-28 shrink-0 flex-col gap-1 sm:flex">
<span className="rounded-full bg-brand/10 px-2.5 py-1.5 text-xs font-semibold text-brand">Dashboard</span>
<span className="rounded-full px-2.5 py-1.5 text-xs font-medium text-muted-foreground">Episodes</span>
<span className="rounded-full px-2.5 py-1.5 text-xs font-medium text-muted-foreground">Billing</span>
</div>
<div className="flex-1 space-y-2">
<p className="font-display text-sm font-bold tracking-tight">Welcome back</p>
<div className="rounded-xl border border-border p-2.5">
<div className="h-1.5 w-2/3 rounded-full bg-brand" />
<div className="mt-1.5 h-1.5 w-1/3 rounded-full bg-secondary" />
</div>
<span className="inline-block text-xs font-semibold text-brand underline-offset-2">
View usage
</span>
</div>
</div>
</div>
{primaryColor.trim() && !previewHsl && (
<p className="text-xs text-warning">Enter a 6-digit hex colour (e.g. #116DFF).</p>
)}
</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)} />
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
/**
* Light/dark toggle rendered inside the user menu. Uses `onSelect` with
* `preventDefault` so picking it doesn't close the menu, letting the user see
* the theme flip. Guards against hydration mismatch by waiting for mount.
*/
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const isDark = mounted && resolvedTheme === "dark";
return (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setTheme(isDark ? "light" : "dark");
}}
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{isDark ? "Light mode" : "Dark mode"}
</DropdownMenuItem>
);
}
+2
View File
@@ -13,6 +13,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { signOut } from "@/lib/auth/auth-client";
import { ThemeToggle } from "./theme-toggle";
interface UserMenuProps {
name: string;
@@ -64,6 +65,7 @@ export function UserMenu({ name, email, image, isAdmin }: UserMenuProps) {
</Link>
</DropdownMenuItem>
)}
<ThemeToggle />
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleSignOut} className="text-destructive focus:text-destructive">
<LogOut className="h-4 w-4" /> Sign out
+254
View File
@@ -0,0 +1,254 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Play, Pause, Download, Volume2, VolumeX, FileArchive } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function formatTime(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return "0:00";
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
/**
* Deterministic pseudo-waveform bars derived from the source URL — a lightweight,
* decode-free stand-in so the player always renders a waveform without fetching
* and decoding the audio. Purely decorative; progress fills it as playback moves.
*/
function bars(seed: string, count: number): number[] {
let h = 2166136261;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
const out: number[] = [];
for (let i = 0; i < count; i++) {
h ^= h << 13;
h ^= h >>> 17;
h ^= h << 5;
const n = (h >>> 0) / 4294967295;
out.push(0.22 + n * 0.78);
}
return out;
}
export interface WaveformPlayerProps {
/** Fully-qualified audio source URL (authed asset route or public route). */
src: string;
/** Optional direct download URL for the MP3 (defaults to `${src}?download=1`). */
downloadUrl?: string;
/** Server-known duration (seconds) used until metadata loads. */
durationSec?: number | null;
/** When set, shows a "Download everything (.zip)" button hitting this URL. */
exportUrl?: string;
/** Hide the download controls (e.g. on public pages). */
hideDownloads?: boolean;
className?: string;
}
export function WaveformPlayer({
src,
downloadUrl,
durationSec,
exportUrl,
hideDownloads = false,
className,
}: WaveformPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(durationSec ?? 0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const waveform = useRef(bars(src, 96)).current;
const progress = duration > 0 ? current / duration : 0;
// Paint the canvas waveform with a played/unplayed split.
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
canvas.width = w * dpr;
canvas.height = h * dpr;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const styles = getComputedStyle(canvas);
const played = `hsl(${styles.getPropertyValue("--brand").trim() || "217 100% 53%"})`;
const unplayed = `hsl(${styles.getPropertyValue("--border").trim() || "0 0% 89%"})`;
const n = waveform.length;
const gap = 2;
const barW = Math.max(1, (w - gap * (n - 1)) / n);
for (let i = 0; i < n; i++) {
const bh = waveform[i] * h;
const x = i * (barW + gap);
const y = (h - bh) / 2;
ctx.fillStyle = (i + 0.5) / n <= progress ? played : unplayed;
ctx.beginPath();
const r = Math.min(barW / 2, 1.5);
if (typeof ctx.roundRect === "function") ctx.roundRect(x, y, barW, bh, r);
else ctx.rect(x, y, barW, bh);
ctx.fill();
}
}, [waveform, progress]);
useEffect(() => {
draw();
}, [draw]);
useEffect(() => {
const onResize = () => draw();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [draw]);
function togglePlay() {
const a = audioRef.current;
if (!a) return;
if (a.paused) void a.play();
else a.pause();
}
function seekToClientX(clientX: number, el: HTMLElement) {
const a = audioRef.current;
if (!a || !duration) return;
const rect = el.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
a.currentTime = ratio * duration;
setCurrent(a.currentTime);
}
function toggleMute() {
const a = audioRef.current;
if (!a) return;
a.muted = !a.muted;
setMuted(a.muted);
}
function onVolume(v: number) {
const a = audioRef.current;
setVolume(v);
if (a) {
a.volume = v;
a.muted = v === 0;
setMuted(v === 0);
}
}
return (
<div className={cn("space-y-4", className)}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio
ref={audioRef}
src={src}
preload="metadata"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onTimeUpdate={(e) => setCurrent(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => {
if (Number.isFinite(e.currentTarget.duration)) setDuration(e.currentTarget.duration);
}}
onEnded={() => setPlaying(false)}
/>
<div className="flex items-center gap-3">
<Button
type="button"
variant="default"
size="icon"
onClick={togglePlay}
aria-label={playing ? "Pause" : "Play"}
className="h-12 w-12 shrink-0"
>
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 translate-x-0.5" />}
</Button>
<div className="min-w-0 flex-1">
{/* Canvas waveform doubles as the scrubber. */}
<div
role="slider"
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
aria-valuenow={Math.round(current)}
tabIndex={0}
className="relative h-12 w-full cursor-pointer touch-none select-none"
onPointerDown={(e) => {
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
seekToClientX(e.clientX, e.currentTarget);
}}
onPointerMove={(e) => {
if (e.buttons === 1) seekToClientX(e.clientX, e.currentTarget);
}}
onKeyDown={(e) => {
const a = audioRef.current;
if (!a || !duration) return;
if (e.key === "ArrowRight") a.currentTime = Math.min(duration, a.currentTime + 5);
else if (e.key === "ArrowLeft") a.currentTime = Math.max(0, a.currentTime - 5);
else if (e.key === " ") {
e.preventDefault();
togglePlay();
}
}}
>
<canvas ref={canvasRef} className="h-full w-full" />
</div>
<div className="mt-1 flex items-center justify-between text-xs tabular-nums text-muted-foreground">
<span>{formatTime(current)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
<div className="hidden items-center gap-2 sm:flex">
<button
type="button"
onClick={toggleMute}
aria-label={muted ? "Unmute" : "Mute"}
className="rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{muted || volume === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<input
type="range"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onChange={(e) => onVolume(Number(e.target.value))}
aria-label="Volume"
className="h-1 w-20 cursor-pointer accent-brand"
/>
</div>
</div>
{!hideDownloads && (
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm">
<a href={downloadUrl ?? `${src}?download=1`} download>
<Download className="h-4 w-4" /> Download MP3
</a>
</Button>
{exportUrl && (
<Button asChild variant="ghost" size="sm">
<a href={exportUrl}>
<FileArchive className="h-4 w-4" /> Download everything (.zip)
</a>
</Button>
)}
</div>
)}
</div>
);
}
+3 -1
View File
@@ -10,12 +10,14 @@ 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 { safeRedirect } from "@/lib/utils";
import { GoogleButton } from "./google-button";
export function SignInForm({ googleEnabled }: { googleEnabled: boolean }) {
const router = useRouter();
const params = useSearchParams();
const redirectTo = params.get("redirect") || "/dashboard";
// Validate the ?redirect param to prevent open-redirect attacks.
const redirectTo = safeRedirect(params.get("redirect"));
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
+3
View File
@@ -26,6 +26,9 @@ export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
password: String(form.get("password")),
});
if (error) {
// Accepted tradeoff (L8): the raw Better Auth message can reveal that an
// email is already registered (account enumeration). We keep the specific
// message for UX clarity; the signup endpoint is rate-limited server-side.
toast.error(error.message ?? "Could not create account");
setLoading(false);
return;
+60
View File
@@ -0,0 +1,60 @@
export interface LegalSection {
heading: string;
paragraphs: string[];
bullets?: string[];
}
/** Shared layout for long-form legal documents (Privacy, Terms). */
export function LegalDoc({
title,
updated,
intro,
sections,
}: {
title: string;
updated: string;
intro: string;
sections: LegalSection[];
}) {
return (
<div className="container max-w-3xl py-20 md:py-24">
<p className="text-[13px] font-semibold uppercase tracking-[0.04em] text-brand">Legal</p>
<h1 className="mt-3 font-display text-4xl font-extrabold tracking-tight md:text-5xl">
{title}
</h1>
<p className="mt-3 text-sm text-muted-foreground">Last updated: {updated}</p>
<p className="mt-6 text-lg leading-relaxed text-muted-foreground">{intro}</p>
<div className="mt-12 space-y-10">
{sections.map((s, i) => (
<section key={s.heading} className="space-y-3">
<h2 className="font-display text-xl font-bold tracking-tight">
<span className="text-muted-foreground/50">{i + 1}.</span> {s.heading}
</h2>
{s.paragraphs.map((p, j) => (
<p key={j} className="leading-relaxed text-muted-foreground">
{p}
</p>
))}
{s.bullets && (
<ul className="ml-1 space-y-2">
{s.bullets.map((b) => (
<li key={b} className="flex gap-2.5 text-muted-foreground">
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-brand" />
<span className="leading-relaxed">{b}</span>
</li>
))}
</ul>
)}
</section>
))}
</div>
<p className="mt-12 border-t border-border pt-6 text-sm italic text-muted-foreground">
This document is provided for transparency about how PodcastYes operates. It is a general
template and is not legal advice; have it reviewed by qualified counsel for your
jurisdiction before relying on it.
</p>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { ThemeProvider as NextThemesProvider, type ThemeProviderProps } from "next-themes";
/**
* App-wide theme provider. Wraps next-themes with class-based dark mode so the
* `.dark` token overrides in globals.css apply. `disableTransitionOnChange`
* prevents a flash of color transitions when toggling.
*/
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="light"
enableSystem
disableTransitionOnChange
{...props}
>
{children}
</NextThemesProvider>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-foreground/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl border border-border bg-background p-6 shadow-xl duration-200 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}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col gap-1.5 text-left", className)} {...props} />;
}
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
);
}
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("font-display text-lg font-bold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+45
View File
@@ -0,0 +1,45 @@
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Shared empty-state panel: a brand-tinted icon tile, a title, muted
* description, and an optional call-to-action. Used across the dashboard,
* episodes, series, and API-keys surfaces.
*/
export function EmptyState({
icon: Icon,
title,
description,
action,
className,
bordered = true,
}: {
icon: LucideIcon;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
/** Wrap in a dashed border panel (default). Set false when already inside a Card. */
bordered?: boolean;
}) {
return (
<div
className={cn(
"flex flex-col items-center gap-3 px-6 py-16 text-center",
bordered && "rounded-2xl border border-dashed border-border",
className
)}
>
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand/10 text-brand">
<Icon className="h-6 w-6" />
</span>
<div className="space-y-1">
<p className="font-display text-lg font-bold tracking-tight">{title}</p>
{description && (
<p className="mx-auto max-w-md text-sm text-muted-foreground">{description}</p>
)}
</div>
{action && <div className="pt-1">{action}</div>}
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { cn } from "@/lib/utils";
/** Base shimmer block. Compose larger layouts from these. */
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-xl bg-secondary", className)} {...props} />;
}
/** A page-header placeholder (title + description). */
export function HeaderSkeleton() {
return (
<div className="mb-8 space-y-2">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-4 w-80 max-w-full" />
</div>
);
}
/** A single card-shaped placeholder. */
export function CardSkeleton({ className }: { className?: string }) {
return <Skeleton className={cn("h-28 rounded-2xl", className)} />;
}
/** A responsive row of stat-card placeholders. */
export function StatRowSkeleton({ count = 3 }: { count?: number }) {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: count }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
);
}
/** A grid of square episode-card placeholders. */
export function EpisodeGridSkeleton({ count = 8 }: { count?: number }) {
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="overflow-hidden rounded-2xl border border-border bg-card">
<Skeleton className="aspect-square rounded-none" />
<div className="space-y-2 p-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
);
}
/** A stacked list of row placeholders (for lists/tables). */
export function ListSkeleton({ rows = 4 }: { rows?: number }) {
return (
<div className="divide-y rounded-2xl border border-border">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center justify-between gap-3 p-4">
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-2/5" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-6 w-20 rounded-full" />
</div>
))}
</div>
);
}