// ============================================================================
// UI primitives — icons, badges, score ring, toast host, helpers
// ============================================================================
const { useState, useEffect, useRef, useCallback, createContext, useContext } = React;
// ---- minimal geometric icons (no hand-drawn illustration) ------------------
function Icon({ name, size = 16, stroke = 1.75, style }) {
const p = { fill: 'none', stroke: 'currentColor', strokeWidth: stroke, strokeLinecap: 'round', strokeLinejoin: 'round' };
const paths = {
check: ,
x: ,
arrow: ,
chevron: ,
play: ,
code: ,
grid: ,
folder: ,
user: ,
users: ,
book: ,
file: ,
lock: ,
logout: ,
plus: ,
trash: ,
filter: ,
bolt: ,
dot: ,
};
return {paths[name]} ;
}
function Badge({ kind, children, style }) {
return {children} ;
}
function RoleBadge({ role }) {
return {role} ;
}
function PassBadge({ passed }) {
return passed
? PASS
: FAIL ;
}
function Spinner({ children }) {
return {children} ;
}
// circular score gauge --------------------------------------------------------
function ScoreRing({ value, total, passed, size = 116 }) {
const r = (size - 14) / 2;
const c = 2 * Math.PI * r;
const pct = total ? value / total : 0;
const col = passed ? 'var(--pass)' : (pct > 0 ? 'var(--warn)' : 'var(--fail)');
return (
);
}
// ---- toast bus -------------------------------------------------------------
const ToastCtx = createContext(null);
function ToastHost({ children }) {
const [items, setItems] = useState([]);
const push = useCallback((msg, kind) => {
const id = Math.random().toString(36).slice(2);
setItems((x) => [...x, { id, msg, kind }]);
setTimeout(() => setItems((x) => x.filter((t) => t.id !== id)), 3400);
}, []);
return (
{children}
{items.map((t) => (
{t.msg}
))}
);
}
const useToast = () => useContext(ToastCtx);
// ---- time formatting -------------------------------------------------------
// DB timestamps are UTC ("YYYY-MM-DD HH:MM:SS"). Render them in the course
// timezone — UTC+8 (China) — regardless of where the viewer's browser is.
function fmtTime(utc) {
if (!utc) return '';
const d = new Date(String(utc).replace(' ', 'T') + 'Z'); // parse as UTC, not local
if (isNaN(d.getTime())) return utc;
return d.toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' }); // → "2026-06-19 20:30:01"
}
// ---- empty / loading states ------------------------------------------------
function Loading({ label = 'Loading' }) {
return (
{label}…
);
}
function Empty({ icon = 'dot', title, sub }) {
return (
);
}
// ---- modal overlay ---------------------------------------------------------
// Dismiss on Escape or backdrop click; the inner panel stops propagation.
function Modal({ title, sub, onClose, children, wide }) {
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
e.stopPropagation()} className="panel fade-up" style={{ width: wide ? 880 : 560, maxWidth: '100%', maxHeight: '86vh', display: 'flex', flexDirection: 'column' }}>
{children}
);
}
Object.assign(window, { Icon, Badge, RoleBadge, PassBadge, Spinner, ScoreRing, ToastHost, useToast, Loading, Empty, Modal, fmtTime });