Comprehensive admin + user dashboards (production-ready)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user