metatroncubeswdev c309581df0 Initial commit: Quorla social media manager
Dashboard (content library, publishing queue with approvals, inbox, team
management, AI runs), Meta connector, branded PDF reports, agent skills,
and Docker packaging (Dockerfile, docker-compose with Caddy HTTPS,
backup and path-relocation scripts).

Client data, live data and secrets are excluded via .gitignore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:02:29 -04:00

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 ? "#3a7bbf" : "#b9d2ea"} />;
})}
</svg>
);
}
export const STATUS_TONE = { queued: "neutral", running: "brand", succeeded: "ok", failed: "bad", cancelled: "neutral", interrupted: "warn" };