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