Comprehensive admin + user dashboards (production-ready)

This commit is contained in:
Leon Serfaty
2026-06-07 17:54:30 -04:00
parent 155507f21a
commit f033f00379
122 changed files with 7878 additions and 805 deletions
+24 -17
View File
@@ -2,11 +2,13 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Copy, KeyRound, Trash2, Check } from "lucide-react";
import { Loader2, Copy, KeyRound, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
import { EmptyState } from "@/components/ui/empty-state";
import { createApiKeyAction, revokeApiKeyAction } from "@/app/(app)/api-keys/actions";
interface KeyRow {
@@ -37,17 +39,6 @@ export function ApiKeysClient({ keys }: { keys: KeyRow[] }) {
router.refresh();
}
async function revoke(id: string) {
if (!confirm("Revoke this key? Apps using it will stop working.")) return;
const res = await revokeApiKeyAction(id);
if (res.ok) {
toast.success("Key revoked");
router.refresh();
} else {
toast.error(res.error ?? "Could not revoke");
}
}
return (
<div className="max-w-2xl space-y-6">
{newKey && (
@@ -88,9 +79,13 @@ export function ApiKeysClient({ keys }: { keys: KeyRow[] }) {
</Card>
{keys.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">No API keys yet.</p>
<EmptyState
icon={KeyRound}
title="No API keys yet"
description="Create a key above to start generating episodes programmatically."
/>
) : (
<div className="divide-y rounded-lg border">
<div className="divide-y rounded-2xl border">
{keys.map((k) => (
<div key={k.id} className="flex items-center justify-between gap-3 p-4">
<div className="min-w-0">
@@ -100,9 +95,21 @@ export function ApiKeysClient({ keys }: { keys: KeyRow[] }) {
{k.lastUsedAt ? ` · last used ${new Date(k.lastUsedAt).toLocaleDateString()}` : " · never used"}
</p>
</div>
<Button variant="ghost" size="sm" className="text-destructive" onClick={() => revoke(k.id)}>
<Trash2 className="h-4 w-4" /> Revoke
</Button>
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm" className="text-destructive">
<Trash2 className="h-4 w-4" /> Revoke
</Button>
}
title="Revoke this key?"
description={`Apps using "${k.name}" will immediately stop working. This cannot be undone.`}
confirmLabel="Revoke key"
successMessage="Key revoked"
onConfirm={async () => {
const res = await revokeApiKeyAction(k.id);
return { ok: res.ok, error: res.error };
}}
/>
</div>
))}
</div>
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useState } from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Menu, X, Mic } from "lucide-react";
import type { PlanKey } from "@/lib/billing/plans";
import { SidebarNav } from "./sidebar-nav";
/**
* Left-drawer navigation for phones. Mirrors `AdminMobileNav` (radix dialog),
* wrapping the shared `SidebarNav` and closing on every link tap. Only rendered
* below the `md` breakpoint.
*/
export function AppMobileNav({ plan, workspaceName }: { plan: PlanKey; workspaceName: string }) {
const [open, setOpen] = useState(false);
return (
<DialogPrimitive.Root open={open} onOpenChange={setOpen}>
<DialogPrimitive.Trigger className="inline-flex h-10 w-10 items-center justify-center rounded-full text-foreground hover:bg-secondary md:hidden">
<Menu className="h-5 w-5" />
<span className="sr-only">Open menu</span>
</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-foreground/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=open]:fade-in-0 md:hidden" />
<DialogPrimitive.Content className="fixed inset-y-0 left-0 z-50 w-72 overflow-y-auto border-r border-border bg-background shadow-xl duration-200 data-[state=open]:animate-in data-[state=open]:slide-in-from-left md:hidden">
<div className="flex items-center justify-between border-b border-border px-4 py-4">
<DialogPrimitive.Title className="flex items-center gap-2 font-display font-bold tracking-tight">
<span className="flex h-8 w-8 items-center justify-center rounded-2xl bg-brand text-brand-foreground">
<Mic className="h-4 w-4" />
</span>
<span className="truncate">{workspaceName}</span>
</DialogPrimitive.Title>
<DialogPrimitive.Close className="rounded-full p-1 text-muted-foreground hover:text-foreground">
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
<SidebarNav plan={plan} onNavigate={() => setOpen(false)} />
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
+13 -25
View File
@@ -1,39 +1,27 @@
"use client";
import { Download } from "lucide-react";
import { Button } from "@/components/ui/button";
function formatDuration(sec?: number | null): string {
if (!sec) return "";
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
import { WaveformPlayer } from "./waveform-player";
/**
* Episode audio player — a thin wrapper around the custom WaveformPlayer so the
* authed asset route and (optional) ZIP export are wired up for the editor.
*/
export function AudioPlayer({
storageKey,
durationSec,
episodeId,
}: {
storageKey: string;
durationSec?: number | null;
episodeId?: string;
}) {
const src = `/api/assets/${storageKey}`;
return (
<div className="space-y-3">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio controls src={src} className="w-full" preload="metadata" />
<div className="flex items-center justify-between">
{durationSec ? (
<span className="text-xs text-muted-foreground">Length {formatDuration(durationSec)}</span>
) : (
<span />
)}
<Button asChild variant="outline" size="sm">
<a href={`${src}?download=1`} download>
<Download className="h-4 w-4" /> Download MP3
</a>
</Button>
</div>
</div>
<WaveformPlayer
src={src}
downloadUrl={`${src}?download=1`}
durationSec={durationSec}
exportUrl={episodeId ? `/api/episodes/${episodeId}/export` : undefined}
/>
);
}
+15 -12
View File
@@ -7,6 +7,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
import { cn, formatPrice } from "@/lib/utils";
import { PLAN_ORDER, PLANS, type PlanKey } from "@/lib/billing/plans";
import type { BillingInterval } from "@/lib/billing/catalog";
@@ -83,19 +84,21 @@ export function BillingClient({
</Button>
)}
{!subscription.cancelAtPeriodEnd && (
<Button
variant="ghost"
className="text-destructive"
onClick={() => {
if (confirm("Cancel your subscription at the end of the period?")) {
go(cancelSubscriptionAction, "cancel");
}
<ConfirmDialog
trigger={
<Button variant="ghost" className="text-destructive">
Cancel
</Button>
}
title="Cancel subscription?"
description="Your plan stays active until the end of the current billing period, then reverts to Free. You can resubscribe any time."
confirmLabel="Cancel subscription"
successMessage="Subscription will cancel at period end"
onConfirm={async () => {
const res = await cancelSubscriptionAction();
return res.ok ? { ok: true } : { ok: false, error: res.error };
}}
disabled={busy === "cancel"}
>
{busy === "cancel" && <Loader2 className="h-4 w-4 animate-spin" />}
Cancel
</Button>
/>
)}
</div>
</CardContent>
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Command } from "cmdk";
import { useTheme } from "next-themes";
import {
LayoutDashboard,
Mic2,
ListMusic,
BarChart3,
CreditCard,
Users,
KeyRound,
Settings,
Plus,
Moon,
Sun,
Search,
} from "lucide-react";
interface PaletteRoute {
label: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
keywords?: string[];
}
const ROUTES: PaletteRoute[] = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard, keywords: ["home"] },
{ label: "Episodes", href: "/episodes", icon: Mic2, keywords: ["library", "podcasts"] },
{ label: "Series", href: "/series", icon: ListMusic, keywords: ["season"] },
{ label: "Usage", href: "/usage", icon: BarChart3, keywords: ["limits", "quota"] },
{ label: "Billing", href: "/billing", icon: CreditCard, keywords: ["plan", "upgrade", "subscription"] },
{ label: "Team", href: "/team", icon: Users, keywords: ["members", "workspace", "branding"] },
{ label: "API keys", href: "/api-keys", icon: KeyRound, keywords: ["developer", "token"] },
{ label: "Settings", href: "/settings", icon: Settings, keywords: ["account", "profile"] },
];
/**
* Global ⌘K / Ctrl-K command palette. Provides "New episode", jump-to-route
* navigation, and a theme toggle. Mounted once in the app layout.
*/
export function CommandPalette() {
const router = useRouter();
const { resolvedTheme, setTheme } = useTheme();
const [open, setOpen] = useState(false);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((o) => !o);
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
function run(action: () => void) {
setOpen(false);
action();
}
const isDark = resolvedTheme === "dark";
return (
<Command.Dialog
open={open}
onOpenChange={setOpen}
label="Command palette"
shouldFilter
contentClassName="fixed left-1/2 top-[20vh] z-[61] w-[92vw] max-w-lg -translate-x-1/2 overflow-hidden rounded-2xl border border-border bg-popover text-popover-foreground shadow-lg outline-none data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
overlayClassName="fixed inset-0 z-[60] bg-foreground/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=open]:fade-in-0"
>
<div className="flex items-center gap-2 border-b border-border px-4">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<Command.Input
placeholder="Search actions and pages…"
className="h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<Command.List className="max-h-[55vh] overflow-y-auto p-2">
<Command.Empty className="py-8 text-center text-sm text-muted-foreground">
No results found.
</Command.Empty>
<Command.Group
heading="Actions"
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-muted-foreground"
>
<PaletteItem
label="New episode"
keywords={["create", "generate"]}
icon={Plus}
onSelect={() => run(() => router.push("/episodes/new"))}
/>
<PaletteItem
label={isDark ? "Switch to light mode" : "Switch to dark mode"}
keywords={["theme", "dark", "light", "appearance"]}
icon={isDark ? Sun : Moon}
onSelect={() => run(() => setTheme(isDark ? "light" : "dark"))}
/>
</Command.Group>
<Command.Group
heading="Go to"
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-muted-foreground"
>
{ROUTES.map((r) => (
<PaletteItem
key={r.href}
label={r.label}
keywords={r.keywords}
icon={r.icon}
onSelect={() => run(() => router.push(r.href))}
/>
))}
</Command.Group>
</Command.List>
</Command.Dialog>
);
}
function PaletteItem({
label,
keywords,
icon: Icon,
onSelect,
}: {
label: string;
keywords?: string[];
icon: React.ComponentType<{ className?: string }>;
onSelect: () => void;
}) {
return (
<Command.Item
value={`${label} ${(keywords ?? []).join(" ")}`}
onSelect={onSelect}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm outline-none data-[selected=true]:bg-secondary data-[selected=true]:text-foreground"
>
<Icon className="h-4 w-4 text-muted-foreground" />
{label}
</Command.Item>
);
}
+185 -23
View File
@@ -3,8 +3,30 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { MoreVertical, ImageIcon, RefreshCw, Trash2, Loader2 } from "lucide-react";
import {
MoreVertical,
ImageIcon,
RefreshCw,
Trash2,
Loader2,
Share2,
FileArchive,
Copy,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
DropdownMenu,
DropdownMenuContent,
@@ -12,11 +34,30 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { regenerateAction, deleteEpisodeAction } from "@/app/(app)/episodes/actions";
import {
regenerateAction,
deleteEpisodeAction,
setEpisodeShareAction,
} from "@/app/(app)/episodes/actions";
export function EpisodeActions({ episodeId }: { episodeId: string }) {
export function EpisodeActions({
episodeId,
initialShareId = null,
}: {
episodeId: string;
initialShareId?: string | null;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [shareId, setShareId] = useState<string | null>(initialShareId);
const [shareToggling, setShareToggling] = useState(false);
const [copied, setCopied] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const shareUrl =
shareId && typeof window !== "undefined" ? `${window.location.origin}/p/${shareId}` : "";
async function regen(type: "art" | "full") {
setBusy(true);
@@ -30,11 +71,38 @@ export function EpisodeActions({ episodeId }: { episodeId: string }) {
}
}
async function del() {
if (!confirm("Delete this episode? This cannot be undone.")) return;
async function toggleShare(enabled: boolean) {
setShareToggling(true);
const res = await setEpisodeShareAction(episodeId, enabled);
setShareToggling(false);
if (!res.ok) {
toast.error(res.error ?? "Could not update sharing");
return;
}
setShareId(res.shareId ?? null);
toast.success(enabled ? "Public link enabled" : "Sharing turned off");
router.refresh();
}
function copyLink() {
if (!shareUrl) return;
navigator.clipboard
.writeText(shareUrl)
.then(() => {
setCopied(true);
toast.success("Link copied");
setTimeout(() => setCopied(false), 1500);
})
.catch(() => toast.error("Could not copy"));
}
async function confirmDelete() {
setDeleting(true);
const res = await deleteEpisodeAction(episodeId);
setDeleting(false);
if (res.ok) {
toast.success("Episode deleted");
setDeleteOpen(false);
router.push("/episodes");
router.refresh();
} else {
@@ -43,24 +111,118 @@ export function EpisodeActions({ episodeId }: { episodeId: string }) {
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <MoreVertical className="h-4 w-4" />}
<>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShareOpen(true)}>
<Share2 className="h-4 w-4" /> Share
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onSelect={() => regen("art")}>
<ImageIcon className="h-4 w-4" /> Regenerate cover art
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => regen("full")}>
<RefreshCw className="h-4 w-4" /> Regenerate everything
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={del} className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4" /> Delete episode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button asChild variant="outline" size="icon" title="Download everything (.zip)">
<a href={`/api/episodes/${episodeId}/export`} aria-label="Export ZIP">
<FileArchive className="h-4 w-4" />
</a>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" disabled={busy}>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreVertical className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onSelect={() => regen("art")}>
<ImageIcon className="h-4 w-4" /> Regenerate cover art
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => regen("full")}>
<RefreshCw className="h-4 w-4" /> Regenerate everything
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={`/api/episodes/${episodeId}/export`}>
<FileArchive className="h-4 w-4" /> Export ZIP
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setDeleteOpen(true)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4" /> Delete episode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Share dialog */}
<Dialog open={shareOpen} onOpenChange={setShareOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Share this episode</DialogTitle>
<DialogDescription>
Turn on a public link to let anyone listen no account needed.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-xl border bg-secondary/40 px-4 py-3">
<div className="space-y-0.5">
<p className="text-sm font-semibold">Public link</p>
<p className="text-xs text-muted-foreground">
{shareId ? "Anyone with the link can listen." : "Currently private."}
</p>
</div>
<div className="flex items-center gap-2">
{shareToggling && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
<Switch
checked={!!shareId}
disabled={shareToggling}
onCheckedChange={toggleShare}
aria-label="Toggle public link"
/>
</div>
</div>
{shareId && (
<div className="space-y-2">
<Label htmlFor="share-url">Public URL</Label>
<div className="flex gap-2">
<Input id="share-url" readOnly value={shareUrl} className="font-mono text-xs" />
<Button type="button" variant="outline" size="icon" onClick={copyLink}>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this episode?</DialogTitle>
<DialogDescription>
This permanently removes the script, audio, cover art and all repurposed content. This
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={deleting}>
Cancel
</Button>
</DialogClose>
<Button variant="destructive" onClick={confirmDelete} disabled={deleting}>
{deleting && <Loader2 className="h-4 w-4 animate-spin" />}
Delete episode
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ export interface EpisodeCardData {
export function EpisodeCard({ episode }: { episode: EpisodeCardData }) {
return (
<Link href={`/episodes/${episode.id}`}>
<Card className="group overflow-hidden transition-shadow hover:shadow-md">
<Card className="group overflow-hidden transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<div className="relative aspect-square bg-muted">
{episode.coverArtKey ? (
// eslint-disable-next-line @next/next/no-img-element
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Eye, Loader2 } from "lucide-react";
import { authClient } from "@/lib/auth/auth-client";
/**
* Sticky banner shown while an admin is impersonating a user. Reads the live
* session; renders nothing for normal sessions. "Stop" ends impersonation and
* returns to the admin surface.
*/
export function ImpersonationBanner() {
const { data: session } = authClient.useSession();
const router = useRouter();
const [busy, setBusy] = useState(false);
// `impersonatedBy` is set on the session row by the better-auth admin plugin.
const impersonatedBy = session?.session
? (session.session as { impersonatedBy?: string | null }).impersonatedBy
: null;
if (!impersonatedBy) return null;
const who = session?.user?.email ?? session?.user?.name ?? "this user";
async function stop() {
setBusy(true);
try {
await authClient.admin.stopImpersonating();
} finally {
router.push("/admin");
router.refresh();
}
}
return (
<div className="sticky top-0 z-40 flex items-center justify-center gap-3 bg-warning px-4 py-2 text-center text-sm font-medium text-warning-foreground">
<span className="inline-flex items-center gap-1.5">
<Eye className="h-4 w-4" />
Viewing as {who}
</span>
<button
onClick={stop}
disabled={busy}
className="inline-flex items-center gap-1 rounded-full bg-warning-foreground/15 px-3 py-0.5 font-semibold transition-colors hover:bg-warning-foreground/25 disabled:opacity-60"
>
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Stop
</button>
</div>
);
}
+42 -5
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { FileText, Hash, Mail, Loader2, Copy, Sparkles } from "lucide-react";
import { FileText, Hash, Mail, Loader2, Copy, Sparkles, Download } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -16,6 +16,27 @@ const FORMATS: { key: Format; label: string; icon: React.ComponentType<{ classNa
{ key: "newsletter", label: "Newsletter", icon: Mail },
];
function wordCount(text: string): number {
const t = text.trim();
return t ? t.split(/\s+/).length : 0;
}
function slugify(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "content";
}
function downloadMarkdown(filename: string, markdown: string) {
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export function RepurposeClient({
episodeId,
initial,
@@ -64,13 +85,29 @@ export function RepurposeClient({
<CardContent className="flex-1">
{c ? (
<div className="space-y-3">
<p className="font-medium">{c.title}</p>
<div className="flex items-start justify-between gap-2">
<p className="font-medium">{c.title}</p>
<span className="shrink-0 whitespace-nowrap text-xs text-muted-foreground">
{wordCount(c.body).toLocaleString()} words · {c.body.length.toLocaleString()} chars
</span>
</div>
<div className="max-h-96 overflow-y-auto whitespace-pre-wrap rounded-md bg-muted/40 p-3 text-sm text-muted-foreground">
{c.body}
</div>
<Button size="sm" variant="ghost" onClick={() => copy(`${c.title}\n\n${c.body}`)}>
<Copy className="h-4 w-4" /> Copy
</Button>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" onClick={() => copy(`${c.title}\n\n${c.body}`)}>
<Copy className="h-4 w-4" /> Copy
</Button>
<Button
size="sm"
variant="ghost"
onClick={() =>
downloadMarkdown(`${slugify(c.title)}.md`, `# ${c.title}\n\n${c.body}\n`)
}
>
<Download className="h-4 w-4" /> Download .md
</Button>
</div>
</div>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">
+106 -39
View File
@@ -1,12 +1,13 @@
"use client";
import { useState, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Loader2, Save, RefreshCw, AudioLines } from "lucide-react";
import { Loader2, Save, RefreshCw, AudioLines, Clipboard, Clock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
updateScriptAction,
regenerateAction,
@@ -27,6 +28,25 @@ interface Script {
sections: Section[];
}
// Average speaking pace used to estimate spoken duration from word count.
const WORDS_PER_MINUTE = 150;
function wordCount(text: string): number {
const t = text.trim();
return t ? t.split(/\s+/).length : 0;
}
function estimateDuration(words: number): string {
const totalSec = Math.round((words / WORDS_PER_MINUTE) * 60);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
function sectionWords(section: Section): number {
return section.turns.reduce((n, t) => n + wordCount(t.text), 0);
}
export function ScriptEditor({
episodeId,
script,
@@ -43,6 +63,11 @@ export function ScriptEditor({
const [busySection, setBusySection] = useState<string | null>(null);
const [rerecording, setRerecording] = useState(false);
const totalWords = useMemo(
() => sections.reduce((n, s) => n + sectionWords(s), 0),
[sections]
);
function updateTurn(si: number, ti: number, text: string) {
setSections((prev) =>
prev.map((s, i) =>
@@ -98,13 +123,47 @@ export function ScriptEditor({
}
}
function copyTranscript() {
const lines: string[] = [script.title, ""];
for (const section of sections) {
lines.push(section.title, "");
for (const turn of section.turns) {
const name = speakerNames[turn.speakerKey] ?? turn.speakerKey;
lines.push(`${name}: ${turn.text}`, "");
}
}
navigator.clipboard
.writeText(lines.join("\n").trimEnd())
.then(() => toast.success("Transcript copied"))
.catch(() => toast.error("Could not copy"));
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Script</h2>
<div className="flex gap-2">
{/* Sticky save bar — dirty-aware. */}
<div className="sticky top-2 z-10 flex flex-wrap items-center justify-between gap-3 rounded-2xl border bg-card/95 px-4 py-3 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-card/80">
<div className="flex items-center gap-3">
<h2 className="font-display text-lg font-extrabold tracking-tight">Script</h2>
<span className="hidden items-center gap-1.5 text-xs text-muted-foreground sm:inline-flex">
<Clock className="h-3.5 w-3.5" />
{totalWords.toLocaleString()} words · ~{estimateDuration(totalWords)}
</span>
{dirty && (
<Badge variant="warning" className="hidden sm:inline-flex">
Unsaved changes
</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" onClick={copyTranscript}>
<Clipboard className="h-4 w-4" /> Copy full transcript
</Button>
<Button variant="outline" size="sm" onClick={rerecord} disabled={rerecording}>
{rerecording ? <Loader2 className="h-4 w-4 animate-spin" /> : <AudioLines className="h-4 w-4" />}
{rerecording ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<AudioLines className="h-4 w-4" />
)}
Re-record audio
</Button>
<Button size="sm" onClick={save} disabled={!dirty || saving}>
@@ -114,40 +173,48 @@ export function ScriptEditor({
</div>
</div>
{sections.map((section, si) => (
<Card key={section.id}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<CardTitle className="text-sm">{section.title}</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => regenSection(section.id)}
disabled={busySection === section.id}
>
{busySection === section.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
Regenerate
</Button>
</CardHeader>
<CardContent className="space-y-3">
{section.turns.map((turn, ti) => (
<div key={ti} className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">
{speakerNames[turn.speakerKey] ?? turn.speakerKey}
</span>
<Textarea
value={turn.text}
onChange={(e) => updateTurn(si, ti, e.target.value)}
rows={Math.max(2, Math.ceil(turn.text.length / 80))}
/>
{sections.map((section, si) => {
const words = sectionWords(section);
return (
<Card key={section.id}>
<CardHeader className="flex flex-row items-center justify-between gap-2 py-3">
<div className="min-w-0">
<CardTitle className="truncate text-sm">{section.title}</CardTitle>
<p className="mt-0.5 text-xs text-muted-foreground">
{words.toLocaleString()} words · {estimateDuration(words)}
</p>
</div>
))}
</CardContent>
</Card>
))}
<Button
variant="ghost"
size="sm"
onClick={() => regenSection(section.id)}
disabled={busySection === section.id}
>
{busySection === section.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
Regenerate
</Button>
</CardHeader>
<CardContent className="space-y-3">
{section.turns.map((turn, ti) => (
<div key={ti} className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">
{speakerNames[turn.speakerKey] ?? turn.speakerKey}
</span>
<Textarea
value={turn.text}
onChange={(e) => updateTurn(si, ti, e.target.value)}
rows={Math.max(2, Math.ceil(turn.text.length / 80))}
/>
</div>
))}
</CardContent>
</Card>
);
})}
</div>
);
}
+202 -3
View File
@@ -2,20 +2,62 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { authClient } from "@/lib/auth/auth-client";
import { ConfirmDialog } from "@/components/admin/ui/confirm-dialog";
import { authClient, signOut } from "@/lib/auth/auth-client";
import { VOICE_CATALOG } from "@/lib/ai/voices";
import { LANGUAGES } from "@/lib/episodes/options";
import { savePreferencesAction, deleteAccountAction } from "@/app/(app)/settings/actions";
export function SettingsClient({ name, email }: { name: string; email: string }) {
const NO_VOICE = "__none__";
interface Preferences {
defaultVoiceId: string | null;
defaultLanguage: string;
emailOnEpisodeReady: boolean;
productEmails: boolean;
}
export function SettingsClient({
name,
email,
preferences,
}: {
name: string;
email: string;
preferences: Preferences;
}) {
const router = useRouter();
const [displayName, setDisplayName] = useState(name);
const [savingProfile, setSavingProfile] = useState(false);
const [savingPw, setSavingPw] = useState(false);
// Defaults
const [voiceId, setVoiceId] = useState(preferences.defaultVoiceId ?? NO_VOICE);
const [language, setLanguage] = useState(preferences.defaultLanguage);
const [savingDefaults, setSavingDefaults] = useState(false);
// Notifications
const [emailOnReady, setEmailOnReady] = useState(preferences.emailOnEpisodeReady);
const [productEmails, setProductEmails] = useState(preferences.productEmails);
const [savingNotif, setSavingNotif] = useState(false);
// Danger zone
const [confirmEmail, setConfirmEmail] = useState("");
async function saveProfile(e: React.FormEvent) {
e.preventDefault();
setSavingProfile(true);
@@ -45,6 +87,38 @@ export function SettingsClient({ name, email }: { name: string; email: string })
}
}
async function saveDefaults() {
setSavingDefaults(true);
const res = await savePreferencesAction({
defaultVoiceId: voiceId === NO_VOICE ? null : voiceId,
defaultLanguage: language,
});
setSavingDefaults(false);
if (res.ok) toast.success("Defaults saved");
else toast.error(res.error ?? "Could not save");
}
async function saveNotifications(next: Partial<Preferences>) {
const emailVal = next.emailOnEpisodeReady ?? emailOnReady;
const productVal = next.productEmails ?? productEmails;
setEmailOnReady(emailVal);
setProductEmails(productVal);
setSavingNotif(true);
const res = await savePreferencesAction({
emailOnEpisodeReady: emailVal,
productEmails: productVal,
});
setSavingNotif(false);
if (!res.ok) {
toast.error(res.error ?? "Could not save");
// Revert optimistic state.
setEmailOnReady(preferences.emailOnEpisodeReady);
setProductEmails(preferences.productEmails);
} else {
toast.success("Notification preferences saved");
}
}
return (
<div className="max-w-xl space-y-6">
<Card>
@@ -92,6 +166,131 @@ export function SettingsClient({ name, email }: { name: string; email: string })
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Defaults</CardTitle>
<CardDescription>
Pre-select a voice and language when creating new episodes.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="default-voice">Default host voice</Label>
<Select value={voiceId} onValueChange={setVoiceId}>
<SelectTrigger id="default-voice">
<SelectValue placeholder="Choose a voice" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_VOICE}>No default (choose each time)</SelectItem>
{VOICE_CATALOG.map((v) => (
<SelectItem key={v.id} value={v.id}>
{v.name}
{v.accent ? ` · ${v.accent}` : ""}
{v.description ? `${v.description}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="default-language">Default language</Label>
<Select value={language} onValueChange={setLanguage}>
<SelectTrigger id="default-language">
<SelectValue placeholder="Choose a language" />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={saveDefaults} disabled={savingDefaults}>
{savingDefaults ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save defaults
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>Choose which emails you want to receive.</CardDescription>
</CardHeader>
<CardContent className="space-y-1">
<div className="flex items-center justify-between gap-4 py-3">
<div className="space-y-0.5">
<p className="text-sm font-semibold">Episode ready</p>
<p className="text-xs text-muted-foreground">
Email me when an episode finishes generating.
</p>
</div>
<Switch
checked={emailOnReady}
disabled={savingNotif}
onCheckedChange={(v) => saveNotifications({ emailOnEpisodeReady: v })}
aria-label="Email me when an episode is ready"
/>
</div>
<div className="flex items-center justify-between gap-4 border-t py-3">
<div className="space-y-0.5">
<p className="text-sm font-semibold">Product updates</p>
<p className="text-xs text-muted-foreground">
Occasional product news, tips and announcements.
</p>
</div>
<Switch
checked={productEmails}
disabled={savingNotif}
onCheckedChange={(v) => saveNotifications({ productEmails: v })}
aria-label="Receive product update emails"
/>
</div>
</CardContent>
</Card>
<Card className="border-destructive/30">
<CardHeader>
<CardTitle className="text-destructive">Danger zone</CardTitle>
<CardDescription>
Permanently delete your account and all of your episodes. This cannot be undone.
</CardDescription>
</CardHeader>
<CardContent>
<ConfirmDialog
trigger={<Button variant="destructive">Delete account</Button>}
title="Delete your account?"
description="This permanently deletes your account, every episode, series, and all generated content. This action is irreversible."
confirmLabel="Delete my account"
successMessage="Account deleted"
body={
<div className="space-y-2">
<Label htmlFor="confirm-email">
Type <span className="font-semibold text-foreground">{email}</span> to confirm
</Label>
<Input
id="confirm-email"
value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)}
placeholder={email}
autoComplete="off"
/>
</div>
}
onConfirm={async () => {
const res = await deleteAccountAction(confirmEmail);
if (res.ok) {
await signOut();
router.push("/");
}
return res;
}}
/>
</CardContent>
</Card>
</div>
);
}
+2 -1
View File
@@ -36,7 +36,7 @@ const NAV: NavItem[] = [
{ label: "Settings", href: "/settings", icon: Settings },
];
export function SidebarNav({ plan }: { plan: PlanKey }) {
export function SidebarNav({ plan, onNavigate }: { plan: PlanKey; onNavigate?: () => void }) {
const pathname = usePathname();
const features = PLANS[plan].features;
@@ -49,6 +49,7 @@ export function SidebarNav({ plan }: { plan: PlanKey }) {
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 rounded-full px-4 py-2.5 text-sm font-medium transition-colors",
active
+101 -8
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, UserPlus, Building2, Save } from "lucide-react";
import { Loader2, UserPlus, Building2, Save, Mic, Plus } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -12,7 +12,42 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { authClient } from "@/lib/auth/auth-client";
import { saveBrandingAction } from "@/app/(app)/team/actions";
import { inviteMemberAction, saveBrandingAction } from "@/app/(app)/team/actions";
/**
* Pure client-side #rrggbb → "H S% L%" converter for the live branding preview.
* Mirrors `hexToHslTriplet` in lib/branding.ts (which is server-only). Returns
* null for invalid hex so the preview falls back to the default brand token.
*/
function hexToHslTriplet(hex: string): string | null {
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
if (!m) return null;
const int = parseInt(m[1], 16);
const r = ((int >> 16) & 255) / 255;
const g = ((int >> 8) & 255) / 255;
const b = (int & 255) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
default:
h = (r - g) / d + 4;
}
h /= 6;
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
}
interface Member {
id: string;
@@ -101,16 +136,16 @@ function MembersCard({ orgId, members, seats }: { orgId: string; members: Member
async function invite(e: React.FormEvent) {
e.preventDefault();
// Fast UX guard only — the server action is the real seat-limit authority.
if (members.length >= seats) {
toast.error(`Your plan includes ${seats} seats.`);
return;
}
setBusy(true);
await authClient.organization.setActive({ organizationId: orgId });
const { error } = await authClient.organization.inviteMember({ email: email.trim(), role: "member" });
const res = await inviteMemberAction(orgId, email.trim());
setBusy(false);
if (error) {
toast.error(error.message ?? "Could not invite");
if (!res.ok) {
toast.error(res.error ?? "Could not invite");
return;
}
toast.success(`Invitation sent to ${email}`);
@@ -129,7 +164,7 @@ function MembersCard({ orgId, members, seats }: { orgId: string; members: Member
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="divide-y rounded-lg border">
<div className="divide-y rounded-2xl border">
{members.map((m) => (
<div key={m.id} className="flex items-center gap-3 p-3">
<Avatar className="h-8 w-8">
@@ -182,6 +217,9 @@ function BrandingCard({ orgId, branding }: { orgId: string; branding: Branding |
}
}
const previewHsl = hexToHslTriplet(primaryColor);
const previewName = brandName.trim() || "Your brand";
return (
<Card>
<CardHeader>
@@ -197,9 +235,64 @@ function BrandingCard({ orgId, branding }: { orgId: string; branding: Branding |
</div>
<div className="space-y-2">
<Label htmlFor="color">Primary colour (hex)</Label>
<Input id="color" placeholder="#7c3aed" value={primaryColor} onChange={(e) => setPrimaryColor(e.target.value)} />
<div className="flex items-center gap-2">
<span
aria-hidden
className="h-9 w-9 shrink-0 rounded-xl border border-border"
style={{ backgroundColor: previewHsl ? `hsl(${previewHsl})` : "hsl(var(--brand))" }}
/>
<Input id="color" placeholder="#7c3aed" value={primaryColor} onChange={(e) => setPrimaryColor(e.target.value)} />
</div>
</div>
</div>
{/* Live white-label preview — applies the chosen colour to a mini app mock. */}
<div className="space-y-2">
<Label>Live preview</Label>
<div
className="overflow-hidden rounded-2xl border border-border bg-background"
style={previewHsl ? ({ "--brand": previewHsl, "--ring": previewHsl } as React.CSSProperties) : undefined}
>
<div className="flex items-center justify-between gap-2 border-b border-border bg-background/80 px-3 py-2.5">
<div className="flex items-center gap-2 font-display text-sm font-bold tracking-tight">
{logoUrl.trim() ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoUrl} alt={previewName} className="h-6 w-auto max-w-[120px] object-contain" />
) : (
<>
<span className="flex h-6 w-6 items-center justify-center rounded-lg bg-brand text-brand-foreground">
<Mic className="h-3.5 w-3.5" />
</span>
<span className="truncate">{previewName}</span>
</>
)}
</div>
<span className="inline-flex items-center gap-1 rounded-full bg-brand px-2.5 py-1 text-xs font-semibold text-brand-foreground">
<Plus className="h-3 w-3" /> New
</span>
</div>
<div className="flex gap-3 p-3">
<div className="hidden w-28 shrink-0 flex-col gap-1 sm:flex">
<span className="rounded-full bg-brand/10 px-2.5 py-1.5 text-xs font-semibold text-brand">Dashboard</span>
<span className="rounded-full px-2.5 py-1.5 text-xs font-medium text-muted-foreground">Episodes</span>
<span className="rounded-full px-2.5 py-1.5 text-xs font-medium text-muted-foreground">Billing</span>
</div>
<div className="flex-1 space-y-2">
<p className="font-display text-sm font-bold tracking-tight">Welcome back</p>
<div className="rounded-xl border border-border p-2.5">
<div className="h-1.5 w-2/3 rounded-full bg-brand" />
<div className="mt-1.5 h-1.5 w-1/3 rounded-full bg-secondary" />
</div>
<span className="inline-block text-xs font-semibold text-brand underline-offset-2">
View usage
</span>
</div>
</div>
</div>
{primaryColor.trim() && !previewHsl && (
<p className="text-xs text-warning">Enter a 6-digit hex colour (e.g. #116DFF).</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="logo">Logo URL</Label>
<Input id="logo" placeholder="https://…" value={logoUrl} onChange={(e) => setLogoUrl(e.target.value)} />
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
/**
* Light/dark toggle rendered inside the user menu. Uses `onSelect` with
* `preventDefault` so picking it doesn't close the menu, letting the user see
* the theme flip. Guards against hydration mismatch by waiting for mount.
*/
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const isDark = mounted && resolvedTheme === "dark";
return (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setTheme(isDark ? "light" : "dark");
}}
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{isDark ? "Light mode" : "Dark mode"}
</DropdownMenuItem>
);
}
+2
View File
@@ -13,6 +13,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { signOut } from "@/lib/auth/auth-client";
import { ThemeToggle } from "./theme-toggle";
interface UserMenuProps {
name: string;
@@ -64,6 +65,7 @@ export function UserMenu({ name, email, image, isAdmin }: UserMenuProps) {
</Link>
</DropdownMenuItem>
)}
<ThemeToggle />
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleSignOut} className="text-destructive focus:text-destructive">
<LogOut className="h-4 w-4" /> Sign out
+254
View File
@@ -0,0 +1,254 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Play, Pause, Download, Volume2, VolumeX, FileArchive } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function formatTime(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return "0:00";
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
/**
* Deterministic pseudo-waveform bars derived from the source URL — a lightweight,
* decode-free stand-in so the player always renders a waveform without fetching
* and decoding the audio. Purely decorative; progress fills it as playback moves.
*/
function bars(seed: string, count: number): number[] {
let h = 2166136261;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
const out: number[] = [];
for (let i = 0; i < count; i++) {
h ^= h << 13;
h ^= h >>> 17;
h ^= h << 5;
const n = (h >>> 0) / 4294967295;
out.push(0.22 + n * 0.78);
}
return out;
}
export interface WaveformPlayerProps {
/** Fully-qualified audio source URL (authed asset route or public route). */
src: string;
/** Optional direct download URL for the MP3 (defaults to `${src}?download=1`). */
downloadUrl?: string;
/** Server-known duration (seconds) used until metadata loads. */
durationSec?: number | null;
/** When set, shows a "Download everything (.zip)" button hitting this URL. */
exportUrl?: string;
/** Hide the download controls (e.g. on public pages). */
hideDownloads?: boolean;
className?: string;
}
export function WaveformPlayer({
src,
downloadUrl,
durationSec,
exportUrl,
hideDownloads = false,
className,
}: WaveformPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(durationSec ?? 0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const waveform = useRef(bars(src, 96)).current;
const progress = duration > 0 ? current / duration : 0;
// Paint the canvas waveform with a played/unplayed split.
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
canvas.width = w * dpr;
canvas.height = h * dpr;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const styles = getComputedStyle(canvas);
const played = `hsl(${styles.getPropertyValue("--brand").trim() || "217 100% 53%"})`;
const unplayed = `hsl(${styles.getPropertyValue("--border").trim() || "0 0% 89%"})`;
const n = waveform.length;
const gap = 2;
const barW = Math.max(1, (w - gap * (n - 1)) / n);
for (let i = 0; i < n; i++) {
const bh = waveform[i] * h;
const x = i * (barW + gap);
const y = (h - bh) / 2;
ctx.fillStyle = (i + 0.5) / n <= progress ? played : unplayed;
ctx.beginPath();
const r = Math.min(barW / 2, 1.5);
if (typeof ctx.roundRect === "function") ctx.roundRect(x, y, barW, bh, r);
else ctx.rect(x, y, barW, bh);
ctx.fill();
}
}, [waveform, progress]);
useEffect(() => {
draw();
}, [draw]);
useEffect(() => {
const onResize = () => draw();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [draw]);
function togglePlay() {
const a = audioRef.current;
if (!a) return;
if (a.paused) void a.play();
else a.pause();
}
function seekToClientX(clientX: number, el: HTMLElement) {
const a = audioRef.current;
if (!a || !duration) return;
const rect = el.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
a.currentTime = ratio * duration;
setCurrent(a.currentTime);
}
function toggleMute() {
const a = audioRef.current;
if (!a) return;
a.muted = !a.muted;
setMuted(a.muted);
}
function onVolume(v: number) {
const a = audioRef.current;
setVolume(v);
if (a) {
a.volume = v;
a.muted = v === 0;
setMuted(v === 0);
}
}
return (
<div className={cn("space-y-4", className)}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio
ref={audioRef}
src={src}
preload="metadata"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onTimeUpdate={(e) => setCurrent(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => {
if (Number.isFinite(e.currentTarget.duration)) setDuration(e.currentTarget.duration);
}}
onEnded={() => setPlaying(false)}
/>
<div className="flex items-center gap-3">
<Button
type="button"
variant="default"
size="icon"
onClick={togglePlay}
aria-label={playing ? "Pause" : "Play"}
className="h-12 w-12 shrink-0"
>
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 translate-x-0.5" />}
</Button>
<div className="min-w-0 flex-1">
{/* Canvas waveform doubles as the scrubber. */}
<div
role="slider"
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
aria-valuenow={Math.round(current)}
tabIndex={0}
className="relative h-12 w-full cursor-pointer touch-none select-none"
onPointerDown={(e) => {
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
seekToClientX(e.clientX, e.currentTarget);
}}
onPointerMove={(e) => {
if (e.buttons === 1) seekToClientX(e.clientX, e.currentTarget);
}}
onKeyDown={(e) => {
const a = audioRef.current;
if (!a || !duration) return;
if (e.key === "ArrowRight") a.currentTime = Math.min(duration, a.currentTime + 5);
else if (e.key === "ArrowLeft") a.currentTime = Math.max(0, a.currentTime - 5);
else if (e.key === " ") {
e.preventDefault();
togglePlay();
}
}}
>
<canvas ref={canvasRef} className="h-full w-full" />
</div>
<div className="mt-1 flex items-center justify-between text-xs tabular-nums text-muted-foreground">
<span>{formatTime(current)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
<div className="hidden items-center gap-2 sm:flex">
<button
type="button"
onClick={toggleMute}
aria-label={muted ? "Unmute" : "Mute"}
className="rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{muted || volume === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<input
type="range"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onChange={(e) => onVolume(Number(e.target.value))}
aria-label="Volume"
className="h-1 w-20 cursor-pointer accent-brand"
/>
</div>
</div>
{!hideDownloads && (
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm">
<a href={downloadUrl ?? `${src}?download=1`} download>
<Download className="h-4 w-4" /> Download MP3
</a>
</Button>
{exportUrl && (
<Button asChild variant="ghost" size="sm">
<a href={exportUrl}>
<FileArchive className="h-4 w-4" /> Download everything (.zip)
</a>
</Button>
)}
</div>
)}
</div>
);
}