// ============================================================================ // 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 ; } 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 (
{value}
/ {total}
); } // ---- 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 (
{title}
{sub &&
{sub}
}
); } // ---- 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' }}>
{title}
{sub &&
{sub}
}
{children}
); } Object.assign(window, { Icon, Badge, RoleBadge, PassBadge, Spinner, ScoreRing, ToastHost, useToast, Loading, Empty, Modal, fmtTime });