- Quorla logo, navy/coral colours and page titles in the app (PDF reports keep agency branding) - Connections: Move button reassigns an account to another client without reconnecting - Fix double @ on YouTube handles - HANDOFF.md with current status and next steps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
206 lines
8.1 KiB
JavaScript
206 lines
8.1 KiB
JavaScript
import { useEffect, useRef } from "react";
|
|
import clsx from "clsx";
|
|
import { Loader2, X } from "lucide-react";
|
|
|
|
export const cx = clsx;
|
|
|
|
// ---------- formatting ----------
|
|
const nf = new Intl.NumberFormat("en-CA");
|
|
export const fmt = (n) => (n == null || Number.isNaN(n) ? "—" : nf.format(n));
|
|
export const fmtCompact = (n) =>
|
|
n == null ? "—" : n >= 10000 ? new Intl.NumberFormat("en-CA", { notation: "compact", maximumFractionDigits: 1 }).format(n) : nf.format(n);
|
|
export const fmtDate = (iso, opts = { month: "short", day: "numeric" }) => (iso ? new Date(iso).toLocaleDateString("en-CA", opts) : "—");
|
|
export const fmtDateTime = (iso) =>
|
|
iso ? new Date(iso).toLocaleString("en-CA", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : "—";
|
|
export function timeAgo(iso) {
|
|
if (!iso) return "never";
|
|
const s = (Date.now() - new Date(iso)) / 1000;
|
|
if (s < 60) return "just now";
|
|
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
|
|
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
|
|
const d = Math.floor(s / 86400);
|
|
return d === 1 ? "yesterday" : `${d} days ago`;
|
|
}
|
|
export const plural = (n, word) => `${fmt(n)} ${word}${n === 1 ? "" : "s"}`;
|
|
|
|
// ---------- primitives ----------
|
|
export function Button({ variant = "secondary", size = "md", loading, icon: Icon, className, children, ...props }) {
|
|
return (
|
|
<button
|
|
{...props}
|
|
disabled={props.disabled || loading}
|
|
className={cx(
|
|
"inline-flex items-center justify-center gap-1.5 rounded-md font-medium whitespace-nowrap transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
|
|
size === "sm" ? "h-7 px-2.5 text-[12.5px]" : "h-9 px-3.5 text-[13.5px]",
|
|
variant === "primary" && "bg-brand text-white hover:bg-brand-deep",
|
|
variant === "secondary" && "bg-white text-ink border border-line-strong hover:bg-canvas",
|
|
variant === "ghost" && "text-ink-2 hover:bg-canvas",
|
|
variant === "danger" && "bg-white text-bad border border-line-strong hover:bg-bad-soft",
|
|
className
|
|
)}
|
|
>
|
|
{loading ? <Loader2 className="size-3.5 animate-spin" /> : Icon ? <Icon className="size-3.5" strokeWidth={2.2} /> : null}
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function Panel({ title, action, children, className, bodyClass, subtitle }) {
|
|
return (
|
|
<section className={cx("panel", className)}>
|
|
{(title || action) && (
|
|
<header className="flex items-center justify-between gap-3 px-4 pt-3.5 pb-3 border-b border-line">
|
|
<div>
|
|
<h2 className="text-[14px] font-bold">{title}</h2>
|
|
{subtitle && <p className="text-[12px] text-muted mt-0.5">{subtitle}</p>}
|
|
</div>
|
|
{action}
|
|
</header>
|
|
)}
|
|
<div className={bodyClass}>{children}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const TONES = {
|
|
neutral: "bg-canvas text-ink-2 border-line",
|
|
brand: "bg-brand-soft text-brand-deep border-[#cfe0f1]",
|
|
ok: "bg-ok-soft text-ok border-[#cfe6d7]",
|
|
warn: "bg-warn-soft text-warn border-[#f1dcb8]",
|
|
bad: "bg-bad-soft text-bad border-[#f2cdc8]",
|
|
};
|
|
export function Badge({ tone = "neutral", children, className, dot }) {
|
|
return (
|
|
<span className={cx("inline-flex items-center gap-1.5 h-[22px] px-2 rounded-full border text-[11.5px] font-medium whitespace-nowrap", TONES[tone], className)}>
|
|
{dot && <span className="size-1.5 rounded-full bg-current" />}
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** Headline number with label and optional comparison/note. */
|
|
export function Stat({ label, value, note, tone, loading }) {
|
|
return (
|
|
<div className="min-w-0">
|
|
<div className="eyebrow truncate">{label}</div>
|
|
{loading ? (
|
|
<div className="skeleton h-7 w-20 mt-1.5" />
|
|
) : (
|
|
<div className={cx("font-display text-[24px] font-extrabold num leading-tight mt-1", tone === "bad" ? "text-bad" : tone === "warn" ? "text-warn" : "text-ink")}>
|
|
{value}
|
|
</div>
|
|
)}
|
|
{note && <div className="text-[12px] text-muted mt-0.5 truncate">{note}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function StatRow({ children, className }) {
|
|
return <div className={cx("panel grid divide-x divide-line", className)}>{children}</div>;
|
|
}
|
|
export const StatCell = ({ children }) => <div className="px-5 py-4 min-w-0">{children}</div>;
|
|
|
|
export function Spinner({ label }) {
|
|
return (
|
|
<div className="flex items-center gap-2 text-muted text-[13px] py-10 justify-center">
|
|
<Loader2 className="size-4 animate-spin" /> {label || "Loading…"}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Empty({ icon: Icon, title, children, action }) {
|
|
return (
|
|
<div className="flex flex-col items-center text-center px-6 py-12">
|
|
{Icon && <Icon className="size-6 text-faint mb-3" strokeWidth={1.6} />}
|
|
<div className="font-display font-bold text-[14px]">{title}</div>
|
|
{children && <p className="text-muted text-[13px] max-w-sm mt-1">{children}</p>}
|
|
{action && <div className="mt-4">{action}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ErrorNote({ error, className }) {
|
|
if (!error) return null;
|
|
return <div className={cx("rounded-md border border-[#f2cdc8] bg-bad-soft text-bad px-3 py-2 text-[13px]", className)}>{String(error.message || error)}</div>;
|
|
}
|
|
|
|
export function Tabs({ tabs, value, onChange }) {
|
|
return (
|
|
<div className="flex gap-1 border-b border-line">
|
|
{tabs.map((t) => (
|
|
<button
|
|
key={t.value}
|
|
onClick={() => onChange(t.value)}
|
|
className={cx(
|
|
"relative px-3 h-9 text-[13.5px] font-medium transition-colors",
|
|
value === t.value ? "text-ink" : "text-muted hover:text-ink"
|
|
)}
|
|
>
|
|
{t.label}
|
|
{t.count != null && <span className="ml-1.5 text-[11.5px] text-faint num">{t.count}</span>}
|
|
{value === t.value && <span className="absolute left-2 right-2 -bottom-px h-[2px] bg-brand rounded-full" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Modal dialog on top of the native <dialog> element (focus trap + Esc for free). */
|
|
export function Dialog({ open, onClose, title, children, footer, wide }) {
|
|
const ref = useRef(null);
|
|
useEffect(() => {
|
|
const d = ref.current;
|
|
if (!d) return;
|
|
if (open && !d.open) d.showModal();
|
|
if (!open && d.open) d.close();
|
|
}, [open]);
|
|
return (
|
|
<dialog
|
|
ref={ref}
|
|
onClose={onClose}
|
|
onClick={(e) => e.target === ref.current && onClose()}
|
|
className={cx("m-auto rounded-xl p-0 backdrop:bg-[#1f2024]/40 shadow-[0_20px_60px_-15px_rgba(15,23,42,0.35)] border border-line", wide ? "w-[min(880px,94vw)]" : "w-[min(520px,94vw)]")}
|
|
>
|
|
{open && (
|
|
<div className="flex flex-col max-h-[86vh]">
|
|
<header className="flex items-center justify-between px-5 py-3.5 border-b border-line">
|
|
<h2 className="text-[15px] font-bold">{title}</h2>
|
|
<button onClick={onClose} className="p-1 rounded hover:bg-canvas text-muted" aria-label="Close">
|
|
<X className="size-4" />
|
|
</button>
|
|
</header>
|
|
<div className="px-5 py-4 overflow-y-auto">{children}</div>
|
|
{footer && <footer className="flex justify-end gap-2 px-5 py-3 border-t border-line bg-[#fafbfc] rounded-b-xl">{footer}</footer>}
|
|
</div>
|
|
)}
|
|
</dialog>
|
|
);
|
|
}
|
|
|
|
export function Field({ label, hint, children }) {
|
|
return (
|
|
<label className="block">
|
|
<span className="block text-[12.5px] font-medium text-ink-2 mb-1">{label}</span>
|
|
{children}
|
|
{hint && <span className="block text-[12px] text-muted mt-1">{hint}</span>}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
/** Tiny bar sparkline (no axes) for tables. */
|
|
export function Spark({ values, width = 96, height = 26 }) {
|
|
if (!values?.length) return <span className="text-faint">—</span>;
|
|
const max = Math.max(...values, 1);
|
|
const w = width / values.length;
|
|
return (
|
|
<svg width={width} height={height} aria-hidden="true" className="block">
|
|
{values.map((v, i) => {
|
|
const h = Math.max(2, (v / max) * (height - 2));
|
|
return <rect key={i} x={i * w + 1} y={height - h} width={Math.max(2, w - 2)} height={h} rx={1} fill={i === values.length - 1 ? "#fc6d45" : "#c9c7e0"} />;
|
|
})}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export const STATUS_TONE = { queued: "neutral", running: "brand", succeeded: "ok", failed: "bad", cancelled: "neutral", interrupted: "warn" };
|