);
}
function Toggle({ on, onChange, size = 18 }) {
return (
);
}
function Select({ value, onChange, options, style }) {
return (
);
}
function Field({ label, hint, children }) {
return (
);
}
// A small "?" that shows an explanation when clicked, so explanations do
// not fill the page; it closes on a click elsewhere, Escape or scrolling.
// Drawn on top of everything (a portal), so a card or list that clips its
// content does not cut it off. Never put it inside another button.
function Help({ children, label = "What does this mean?", width = 320 }) {
const [at, setAt] = useState(null); // where the explanation shows, while open
const btn = useRef(null);
const pop = useRef(null);
useEffect(() => {
if (!at) return;
const inside = (t) => (btn.current && btn.current.contains(t)) || (pop.current && pop.current.contains(t));
const away = (e) => { if (!inside(e.target)) setAt(null); };
const key = (e) => { if (e.key === "Escape") { e.stopPropagation(); setAt(null); if (btn.current) btn.current.focus(); } };
const moved = (e) => { if (!(pop.current && e.target && e.target.nodeType === 1 && pop.current.contains(e.target))) setAt(null); };
const resized = () => setAt(null);
document.addEventListener("mousedown", away);
window.addEventListener("keydown", key, true);
window.addEventListener("scroll", moved, true);
window.addEventListener("resize", resized);
return () => {
document.removeEventListener("mousedown", away);
window.removeEventListener("keydown", key, true);
window.removeEventListener("scroll", moved, true);
window.removeEventListener("resize", resized);
};
}, [at]);
const toggle = (e) => {
e.preventDefault(); e.stopPropagation();
if (at) { setAt(null); return; }
const r = btn.current.getBoundingClientRect();
const left = Math.max(12, Math.min(r.left - 12, window.innerWidth - width - 12));
// Above the "?" when there is little room below it.
setAt(window.innerHeight - r.bottom < 220 ? { left, bottom: window.innerHeight - r.top + 6 } : { left, top: r.bottom + 6 });
};
const stop = (e) => e.stopPropagation();
return (
{at && ReactDOM.createPortal(
{children}
,
document.body)}
);
}
function Empty({ icon = "Inbox", text }) {
return (
);
}
// resetKey: the list's search, filter and sort; the first page comes back
// when it changes. Entries that arrive or go keep the page (clamped).
function usePaged(rows, size = 12, resetKey) {
const [page, setPage] = useState(0);
const pageCount = Math.max(1, Math.ceil(rows.length / size));
const p = Math.min(page, pageCount - 1);
const prevKey = useRef(resetKey);
useEffect(() => { if (prevKey.current !== resetKey) { prevKey.current = resetKey; setPage(0); } }, [resetKey]);
return { slice: rows.slice(p * size, p * size + size), page: p, pageCount, setPage, total: rows.length, size };
}
// Confirmation dialog with an optional text field (e.g. a reject reason).
// onConfirm(text) may be async: while it runs the dialog is busy; if it
// throws, the server's error is shown and the dialog STAYS OPEN. It closes
// only after onConfirm resolves.
function ConfirmModal({ open, title, children, confirmLabel = "Confirm", tone = "primary", input, onConfirm, onClose }) {
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
useEffect(() => { if (open) { setText(""); setBusy(false); setErr(""); } }, [open]);
if (!open) return null;
const missing = !!(input && input.required && !text.trim());
const close = () => { if (!busy) onClose(); };
const go = async () => {
if (missing || busy) return;
setBusy(true); setErr("");
try {
await onConfirm(text.trim());
setBusy(false);
onClose();
} catch (e) {
setErr((e && e.message) || "failed");
setBusy(false);
}
};
return (
{title}
{children &&
{children}
}
{input && (
)}
{err &&
{err}
}
);
}
// Copy text to the clipboard. navigator.clipboard needs a secure context
// (HTTPS); the console may be served over plain HTTP, so fall back to a
// hidden textarea + execCommand("copy"). Resolves true only on success.
async function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
try { await navigator.clipboard.writeText(text); return true; } catch (e) { /* fall back */ }
}
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed"; ta.style.top = "-1000px"; ta.style.left = "0"; ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand("copy"); } catch (e) { ok = false; }
document.body.removeChild(ta);
return ok;
}
Object.assign(window, { Modal, Toggle, Select, Field, Empty, Help, Pager, usePaged, ConfirmModal, copyText });