import { useEffect, type ButtonHTMLAttributes, type ReactNode } from "react";
import { Loader2, X } from "lucide-react";
export function cx(...c: (string | false | null | undefined)[]) {
return c.filter(Boolean).join(" ");
}
type Variant = "primary" | "secondary" | "ghost" | "danger" | "warn";
const variants: Record = {
primary: "bg-accent text-white hover:bg-violet-500",
secondary: "bg-panel-2 border border-line hover:bg-[#222633] text-gray-100",
ghost: "hover:bg-panel-2 text-gray-300",
danger: "bg-red-600/90 text-white hover:bg-red-500",
warn: "bg-amber-500/90 text-black hover:bg-amber-400",
};
export function Button({
variant = "secondary",
loading,
className,
children,
size = "md",
...props
}: ButtonHTMLAttributes & { variant?: Variant; loading?: boolean; size?: "sm" | "md" }) {
return (
);
}
export function Card({ children, className }: { children: ReactNode; className?: string }) {
return {children}
;
}
const tones = {
gray: "bg-gray-500/15 text-gray-300 border-gray-500/25",
green: "bg-emerald-500/15 text-emerald-300 border-emerald-500/25",
blue: "bg-sky-500/15 text-sky-300 border-sky-500/25",
amber: "bg-amber-500/15 text-amber-300 border-amber-500/25",
red: "bg-red-500/15 text-red-300 border-red-500/25",
violet: "bg-violet-500/15 text-violet-300 border-violet-500/25",
};
export function Badge({ tone = "gray", children, className }: { tone?: keyof typeof tones; children: ReactNode; className?: string }) {
return (
{children}
);
}
export const statusTone: Record = {
linked: "green",
identical: "blue",
different: "amber",
similar: "violet",
unmatched: "gray",
error: "red",
};
export function Spinner({ className }: { className?: string }) {
return ;
}
export function Modal({
open,
onClose,
title,
children,
wide,
}: {
open: boolean;
onClose: () => void;
title: ReactNode;
children: ReactNode;
wide?: boolean;
}) {
useEffect(() => {
if (!open) return;
const h = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [open, onClose]);
if (!open) return null;
return (
e.stopPropagation()}
>
{title}
{children}
);
}
export function Field({ label, hint, children }: { label: string; hint?: ReactNode; children: ReactNode }) {
return (
);
}
export const inputCls =
"w-full h-9 rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent focus:ring-1 focus:ring-accent placeholder:text-gray-600";
export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: ReactNode }) {
return (
);
}
export function Alert({ tone, children, icon }: { tone: "amber" | "red" | "blue" | "green"; children: ReactNode; icon?: ReactNode }) {
const c = {
amber: "border-amber-500/30 bg-amber-500/10 text-amber-200",
red: "border-red-500/30 bg-red-500/10 text-red-200",
blue: "border-sky-500/30 bg-sky-500/10 text-sky-200",
green: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
}[tone];
return ;
}
export function Empty({ icon, title, children }: { icon: ReactNode; title: string; children?: ReactNode }) {
return (
{icon}
{title}
{children &&
{children}
}
);
}
export function formatBytes(n: number) {
if (!n) return "0 B";
const u = ["B", "KB", "MB", "GB", "TB"];
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), u.length - 1);
return `${(n / 1024 ** i).toFixed(i ? 1 : 0)} ${u[i]}`;
}
export function formatAgo(unix: number | null | undefined) {
if (!unix) return "never";
const s = Math.floor(Date.now() / 1000 - unix);
if (s < 60) return "just now";
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
export function formatDuration(sec: number) {
if (!sec) return "–";
const m = Math.floor(sec / 60);
return `${m}:${String(Math.round(sec % 60)).padStart(2, "0")}`;
}