// ==================== CITADEL COMPONENTS ====================
// Shared primitives for the CITADEL_OS enterprise UI.
// Loaded into window for cross-file access (CITADEL.html uses no bundler).
// ---- Decorative Background ----
const CitadelBackground = () => (
);
// ====================================================================
// LIVE BACKEND HELPERS
// Frontend stays mock-driven by default. Pass `?live=1` in the URL
// (or set localStorage 'citadel_live'='1') to opt in to real API calls.
// ====================================================================
// Backend base URL. Local dev defaults to the local uvicorn; a hosted
// deploy (e.g. Vercel frontend → ngrok/Render backend) overrides it via
// window.CITADEL_API_BASE (set by config.js) or a ?api= query param.
const API_BASE = (() => {
try {
if (window.CITADEL_API_BASE) return String(window.CITADEL_API_BASE).replace(/\/+$/, '');
const q = new URLSearchParams(window.location.search).get('api');
if (q) return q.replace(/\/+$/, '');
} catch (e) {}
return 'http://127.0.0.1:8000';
})();
const isLiveMode = () => {
try {
const url = new URLSearchParams(window.location.search);
if (url.get('live') === '1') return true;
return window.localStorage.getItem('citadel_live') === '1';
} catch (e) { return false; }
};
const useLive = () => {
const [live] = React.useState(isLiveMode());
return live;
};
/**
* apiFetch(path, options?)
* - options.json → POST/PUT body (auto stringified, sets content-type)
* - options.form → multipart FormData (do NOT set content-type)
* - options.method → defaults to GET (or POST if json/form provided)
* - options.params → object → ?key=val querystring
* Returns parsed JSON, throws Error on non-2xx with .status set.
*/
/* ---- Auth session store (Phase 3) ------------------------------------- */
// One persisted session: { access_token, refresh_token, user:{id,username,role} }.
// apiFetch attaches the access token automatically and retries ONCE through
// a refresh on 401 — callers never handle token plumbing themselves.
const AUTH_KEY = 'citadel_auth_v1';
const czAuth = {
get: () => {
try { return JSON.parse(localStorage.getItem(AUTH_KEY)) || null; }
catch { return null; }
},
set: (session) => {
try { localStorage.setItem(AUTH_KEY, JSON.stringify(session)); } catch {}
},
clear: () => { try { localStorage.removeItem(AUTH_KEY); } catch {} },
user: () => (czAuth.get() || {}).user || null,
};
// Single-flight refresh: concurrent 401s share one refresh call instead of
// racing (a race would rotate the token twice and trip reuse detection).
let _refreshPromise = null;
const czRefresh = () => {
if (!_refreshPromise) {
const session = czAuth.get();
if (!session || !session.refresh_token) return Promise.reject(new Error('no session'));
_refreshPromise = fetch(`${API_BASE}/api/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'ngrok-skip-browser-warning': 'true' },
body: JSON.stringify({ refresh_token: session.refresh_token }),
}).then(async r => {
if (!r.ok) { czAuth.clear(); throw new Error('session expired'); }
const next = await r.json();
czAuth.set(next);
return next;
}).finally(() => { _refreshPromise = null; });
}
return _refreshPromise;
};
const czLogout = async () => {
const session = czAuth.get();
czAuth.clear();
if (session && session.refresh_token) {
try {
await fetch(`${API_BASE}/api/v1/auth/logout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'ngrok-skip-browser-warning': 'true' },
body: JSON.stringify({ refresh_token: session.refresh_token }),
});
} catch {}
}
};
const apiFetch = async (path, options = {}, _isRetry = false) => {
const { json, form, params, method, ...rest } = options;
let url = path.startsWith('http') ? path : `${API_BASE}${path}`;
if (params) {
const q = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') q.set(k, v);
});
const qs = q.toString();
if (qs) url += (url.includes('?') ? '&' : '?') + qs;
}
const session = czAuth.get();
const init = {
method: method || (json || form ? 'POST' : 'GET'),
...rest,
// ngrok's free tier serves a browser-warning interstitial unless this
// header is present — send it always (harmless off-ngrok) so the hosted
// frontend's API calls pass straight through to the tunnelled backend.
// Placed after ...rest and merged so caller headers (auth) are kept.
headers: {
'ngrok-skip-browser-warning': 'true',
// Bearer token attached automatically when a session exists; an
// explicit caller-supplied Authorization header still wins.
...(session && session.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}),
...(rest.headers || {}),
},
};
if (json !== undefined) {
init.body = JSON.stringify(json);
init.headers['Content-Type'] = 'application/json';
} else if (form !== undefined) {
init.body = form;
// do NOT set content-type — browser adds the multipart boundary
}
const r = await fetch(url, init);
// Expired access token → one silent refresh, then replay the request.
// Skipped for auth endpoints themselves and for anything already retried.
if (r.status === 401 && !_isRetry && session && session.refresh_token && !path.includes('/auth/')) {
try {
await czRefresh();
return apiFetch(path, options, true);
} catch {
// fall through — surface the original 401 to the caller
}
}
const ct = r.headers.get('content-type') || '';
const body = ct.includes('json') ? await r.json() : await r.text();
if (!r.ok) {
// FastAPI puts validation errors in body.detail as an array of objects.
// Flatten them so the user sees a real message rather than "[object Object]".
let msg = body?.error?.message;
if (!msg && Array.isArray(body?.detail)) {
msg = body.detail.map(d => `${(d.loc || []).join('.')}: ${d.msg}`).join('; ');
} else if (!msg && typeof body?.detail === 'string') {
msg = body.detail;
} else if (!msg && typeof body === 'string') {
msg = body;
}
const e = new Error(msg || `HTTP ${r.status}`);
e.status = r.status;
e.body = body;
throw e;
}
return body;
};
// Live-mode badge — drop into any header to show "LIVE" status
const LiveBadge = () => {
if (!isLiveMode()) return null;
return (
LIVE BACKEND
);
};
// ---- Top Navigation Bar ----
const NAV_NOTIFS = {
government: [
{ t: 'Document batch processed', d: '47 files complete', time: '2 min', type: 'success' },
{ t: 'Anomaly alert: Bridge sensor', d: 'Sector 12 • Confidence 89%', time: '8 min', type: 'alert' },
{ t: 'Resume screening batch done', d: '12 candidates shortlisted', time: '22 min', type: 'info' },
{ t: 'Challan issued', d: '₹2,500 • Plate MH-02-CD-5678', time: '1 hr', type: 'info' },
{ t: 'System backup completed', d: 'All replicas in sync', time: '2 hr', type: 'success' },
],
citizen: [
{ t: 'Ticket TKT-1024 resolved', d: 'Water supply restored', time: '10 min', type: 'success' },
{ t: 'Expense anomaly flagged', d: '₹8,500 — unusual pattern', time: '1 hr', type: 'alert' },
{ t: 'KB article updated', d: 'Passport renewal steps', time: '3 hr', type: 'info' },
],
};
const NavBar = ({ role, user, onLogout, onDashboard, onOpenCmd }) => {
const isGov = role === 'government';
const accent = isGov ? 'var(--gold)' : 'var(--red)';
const [notifOpen, setNotifOpen] = React.useState(false);
const [notifs, setNotifs] = React.useState(NAV_NOTIFS[isGov ? 'government' : 'citizen'] || []);
const notifCount = notifs.length;
const now = new Date();
const [clock, setClock] = React.useState(now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }));
React.useEffect(() => {
const t = setInterval(() => setClock(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })), 1000);
return () => clearInterval(t);
}, []);
return (
CITADEL_OS
● NETWORK: ACTIVE
● SECURE CONNECTION
◷ {clock}
setNotifOpen(o => !o)} title="Notifications" aria-label={`Notifications${notifCount ? ', ' + notifCount + ' unread' : ''}`} style={{ position: 'relative' }}>
{notifCount > 0 && {notifCount} }
{user}
{isGov ? 'OFFICIAL' : 'CITIZEN'}
LOGOUT
{notifOpen && (
setNotifOpen(false)}
onMarkAllRead={() => setNotifs([])}
/>
)}
);
};
// ---- Notification Drawer ----
const NotificationDrawer = ({ items, onClose, onMarkAllRead }) => {
return (
<>
NOTIFICATIONS
✕
{items.length === 0 ? (
✓ All caught up. No new notifications.
) : items.map((n, i) => (
))}
MARK ALL READ
>
);
};
// ---- Command Palette (⌘K) ----
const CommandPalette = ({ open, onClose, onJump, role }) => {
const [q, setQ] = React.useState('');
const inputRef = React.useRef(null);
React.useEffect(() => { if (open && inputRef.current) inputRef.current.focus(); }, [open]);
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
if (open) window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
const gov = [
{ label: 'Document Intelligence', page: 'doc-intel', icon: '📋', cat: 'Gateway' },
{ label: 'Resume Screening', page: 'resume', icon: '👥', cat: 'Gateway' },
{ label: 'Traffic Violations', page: 'traffic', icon: '🚦', cat: 'Gateway' },
{ label: 'Anomaly Monitoring', page: 'anomaly', icon: '📡', cat: 'Gateway' },
{ label: 'Dashboard', page: 'dashboard', icon: '⊞', cat: 'Navigation' },
];
const cit = [
{ label: 'RAG Chatbot', page: 'chatbot', icon: '🤖', cat: 'Gateway' },
{ label: 'Fake News Detector', page: 'fake-news', icon: '📰', cat: 'Gateway' },
{ label: 'Support Tickets', page: 'tickets', icon: '🎫', cat: 'Gateway' },
{ label: 'Expense Categorizer', page: 'expenses', icon: '💰', cat: 'Gateway' },
{ label: 'Dashboard', page: 'dashboard', icon: '⊞', cat: 'Navigation' },
];
const all = role === 'government' ? gov : cit;
const filtered = all.filter(c => !q || c.label.toLowerCase().includes(q.toLowerCase()));
return (
e.stopPropagation()}>
{filtered.length === 0 ? (
No results found
) : filtered.map((c, i) => (
{ onJump(c.page); onClose(); }}>
{c.icon}
↵
))}
↑↓ navigate
↵ select
ESC close
);
};
// ---- Gateway Card (for dashboard) ----
const GatewayCard = ({ title, gatewayId, icon, status, description, accentColor, onClick, index, metric }) => {
const [hovered, setHovered] = React.useState(false);
return (
setHovered(true)} onMouseLeave={() => setHovered(false)} onClick={onClick} role="button" tabIndex={0} onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onClick()}>
{title}
{gatewayId}
{icon}
{status}
{description}
{metric &&
{metric.value} {metric.label}
}
CLICK TO ACCESS →
);
};
// ---- Stat Card ----
const StatCard = ({ value, label, accentColor, trend, trendDir }) => (
{value}
{label}
{trend !== undefined && (
{trendDir === 'up' ? '▲' : trendDir === 'down' ? '▼' : '■'} {trend}
)}
);
// ---- Overview Stats (live landing KPIs, Phase 4) ----
// Replaces the hardcoded StatCard row with real cross-module counts from
// GET /api/v1/overview. A null source renders "—" (honest unknown), never
// an invented number; the fetch is skipped without a session.
const OverviewStats = ({ role }) => {
const [data, setData] = React.useState(null);
const [failed, setFailed] = React.useState(false);
React.useEffect(() => {
if (!czAuth.get()) { setFailed(true); return; }
apiFetch('/api/v1/overview').then(setData).catch(() => setFailed(true));
}, []);
// loading "…", null source → "—" (honest unknown), else the value.
// Rendered directly, not via AnimNum: a count-up animation freezes at 0
// when the tab is backgrounded during load (rAF throttling), which would
// show a wrong "0" on a governance dashboard.
const cell = (v) => {
if (failed) return '—';
if (data == null) return '…';
if (v == null) return '—';
return typeof v === 'number' ? v.toLocaleString('en-IN') : v;
};
const cards = role === 'gov'
? [
{ key: 'active_gateways', label: 'Active Gateways', accent: 'var(--gold)' },
{ key: 'sensors_online', label: 'Sensors Online', accent: 'var(--cyan)' },
{ key: 'active_alerts', label: 'Active Alerts', accent: 'var(--red)' },
{ key: 'pending_review', label: 'Pending Review', accent: 'var(--green)' },
]
: [
{ key: 'active_services', label: 'Active Services', accent: 'var(--red)' },
{ key: 'kb_documents', label: 'KB Corpora', accent: 'var(--gold)' },
{ key: 'community_issues', label: 'Community Issues', accent: 'var(--green)' },
{ key: 'analyses_run', label: 'Analyses Run', accent: 'var(--cyan)' },
];
return (
{cards.map(c => (
))}
);
};
// ---- Telegram self-setup (Phase 6, both portals) ----
// Any signed-in account can point CITADEL alerts at its own Telegram chat,
// reusing the platform's shared bot or its own BotFather bot. The test send
// delivers through the real Bot API, so you receive exactly as the demo does.
const TelegramSetup = ({ role }) => {
const accent = role === 'government' ? 'var(--gold)' : 'var(--red)';
const [cfg, setCfg] = React.useState(null);
const [chatId, setChatId] = React.useState('');
const [token, setToken] = React.useState('');
const [busy, setBusy] = React.useState('');
const [msg, setMsg] = React.useState(null); // {ok, text}
const [showGuide, setShowGuide] = React.useState(false);
const load = React.useCallback(() => {
apiFetch('/api/v1/telegram/config')
.then(c => { setCfg(c); if (c.chat_id) setChatId(c.chat_id); })
.catch(() => setCfg({ configured: false, platform_bot_available: false }));
}, []);
React.useEffect(() => { load(); }, [load]);
const save = () => {
if (!chatId.trim()) { setMsg({ ok: false, text: 'Enter your Chat ID first.' }); return; }
setBusy('save'); setMsg(null);
apiFetch('/api/v1/telegram/config', { method: 'PUT', json: { chat_id: chatId.trim(), bot_token: token.trim() || null } })
.then(c => { setCfg(c); setToken(''); setMsg({ ok: true, text: 'Saved. Send a test to confirm delivery.' }); })
.catch(e => setMsg({ ok: false, text: e.message || 'Save failed' }))
.finally(() => setBusy(''));
};
const test = () => {
setBusy('test'); setMsg(null);
apiFetch('/api/v1/telegram/test', { method: 'POST' })
.then(r => { setMsg({ ok: r.ok, text: r.ok ? 'Test alert delivered. Check your Telegram.' : r.detail }); if (r.ok) load(); })
.catch(e => setMsg({ ok: false, text: e.message || 'Test failed' }))
.finally(() => setBusy(''));
};
const disconnect = () => {
setBusy('dc'); setMsg(null);
apiFetch('/api/v1/telegram/config', { method: 'DELETE' })
.then(() => { setChatId(''); setToken(''); setMsg({ ok: true, text: 'Disconnected.' }); load(); })
.catch(e => setMsg({ ok: false, text: e.message || 'Failed' }))
.finally(() => setBusy(''));
};
const botName = (cfg && cfg.platform_bot_username) || 'citadel2005_bot';
const connected = cfg && cfg.configured;
const verified = connected && cfg.verified_at;
return (
TELEGRAM ALERTS
{verified ? 'VERIFIED' : connected ? 'SAVED · UNTESTED' : 'NOT CONNECTED'}
Receive challans, anomaly alerts and other notifications on your own Telegram.
{cfg && cfg.platform_bot_available
? ' Use the shared CITADEL bot (easiest) or bring your own.'
: ' This deployment has no shared bot, so create your own with @BotFather (guide below). Alerts go only to the chat you connect here.'}
CHAT ID *
setChatId(e.target.value)} inputMode="numeric" />
BOT TOKEN (optional. blank uses the CITADEL bot)
setToken(e.target.value)} />
{busy === 'save' ? 'SAVING…' : 'SAVE'}
{busy === 'test' ? 'SENDING…' : '✈ SEND TEST'}
{connected && ✕ }
{msg && (
{msg.ok ? '✓ ' : '✕ '}{msg.text}
)}
setShowGuide(g => !g)}>
{showGuide ? '▾' : '▸'} HOW TO GET YOUR CREDENTIALS
{showGuide && (
{cfg && cfg.platform_bot_available && (
<>
Easy path · shared CITADEL bot (30 seconds)
In Telegram, open @{botName} and press Start (send any message once, so the bot may write to you).
Open @userinfobot and press Start. It replies with your numeric Id .
Paste that number into Chat ID above. Leave Bot Token blank.
Press Save , then Send Test . The alert should arrive in your chat.
>
)}
{cfg && cfg.platform_bot_available ? 'Advanced path · your own bot' : 'Create your own bot (2 minutes)'}
Open @BotFather , send /newbot , pick a name. It gives you a token like 123456789:AAF….
Open your new bot and press Start once.
Get your Chat ID from @userinfobot as above.
Paste both the token and Chat ID , Save, then Send Test.
Your token is stored on the server and never shown back in full. For a group chat, add the bot to the group and use the group's negative Chat ID.
)}
);
};
// ---- KPI Card (stat + sparkline) ----
const KPICard = ({ value, label, data = [], color = 'var(--gold)', delta, deltaDir }) => {
const max = Math.max(...data, 1);
const pts = data.map((v, i) => `${(i / (data.length - 1)) * 100},${100 - (v / max) * 100}`).join(' ');
return (
{delta !== undefined && (
{deltaDir === 'up' ? '▲' : '▼'} {delta}
)}
{data.length > 0 && (
)}
);
};
// ---- Core Dynamics Widget ----
const CoreDynamics = () => {
const values = { neural: 94, quantum: 22, grid: 100 };
return (
CORE_DYNAMICS
NEURAL PROCESSING
{values.neural}%
QUANTUM STORAGE
{values.quantum}%
GRID STABILITY
{values.grid}%
SYSTEM STATUS
OPTIMAL
);
};
// ---- Session Log Widget ----
const SessionLog = ({ lines }) => {
const [cursor, setCursor] = React.useState(true);
React.useEffect(() => {
const i = setInterval(() => setCursor(c => !c), 500);
return () => clearInterval(i);
}, []);
return (
SESSION_LOG
{lines.map((l, i) => (
>
))}
>
█
);
};
// ---- Activity Timeline Widget ----
const ActivityTimeline = ({ events }) => (
RECENT_ACTIVITY
{events.map((e, i) => (
))}
);
// ---- Network Topology Widget ----
const NetworkTopology = ({ nodes }) => (
NETWORK_TOPOLOGY
{nodes.map((n, i) => (
))}
Online
Busy
Alert
);
// ---- Quick Actions Widget ----
const QuickActions = ({ actions, accent }) => (
QUICK_ACTIONS
{actions.map((a, i) => (
{a.icon}
{a.label}
))}
);
// ---- Mini Chart Widget ----
const MiniChart = ({ title, data, color, labels }) => {
const max = Math.max(...data);
return (
{title}
{data.map((v, i) => (
))}
{labels ? labels[0] : '7D AGO'}
{labels ? labels[labels.length - 1] : 'TODAY'}
);
};
// ---- Donut Chart ----
const Donut = ({ segments, size = 120, stroke = 20, centerLabel, centerValue }) => {
const total = segments.reduce((s, x) => s + x.value, 0);
const radius = (size - stroke) / 2;
const circ = 2 * Math.PI * radius;
let offset = 0;
return (
{segments.map((s, i) => {
const len = (s.value / total) * circ;
const el = ;
offset += len;
return el;
})}
{(centerValue || centerLabel) && (
{centerValue}
{centerLabel}
)}
{segments.map((s, i) => (
{s.label}
{Math.round((s.value / total) * 100)}%
))}
);
};
// ---- Heatmap (7×24 time-of-day) ----
const Heatmap = ({ data, title }) => {
const days = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'];
const max = Math.max(...data.flat(), 1);
return (
{title}
{data.map((row, i) => (
{row.map((v, j) => {
const intensity = v / max;
return
;
})}
))}
00 06 12 18 23
);
};
// ---- Section Header for sub-pages ----
const SubPageHeader = ({ title, gatewayId, subtitle, accentColor, onBack, actions }) => (
← DASHBOARD
{title}
{gatewayId}
{subtitle}
{actions &&
{actions}
}
);
// ---- Tabs ----
const Tabs = ({ tabs, active, onChange, accent = 'var(--gold)' }) => (
{tabs.map(t => (
onChange(t.key)}
>
{t.icon && {t.icon} }
{t.label}
{t.badge !== undefined && {t.badge} }
))}
);
// ---- Segmented Control ----
const SegmentedControl = ({ options, value, onChange, accent = 'var(--gold)' }) => (
{options.map(o => (
onChange(o)}>{o}
))}
);
// ---- Two Column Layout for sub-pages ----
const TwoColumnLayout = ({ left, right, leftTitle, rightTitle, accentColor }) => (
);
// ---- Animated Number ----
const AnimNum = ({ value, suffix = '' }) => {
const [display, setDisplay] = React.useState(0);
React.useEffect(() => {
let start = 0;
const end = typeof value === 'number' ? value : parseFloat(value);
if (isNaN(end)) { setDisplay(value); return; }
const dur = 1200;
const step = (ts) => {
if (!step.s) step.s = ts;
const p = Math.min((ts - step.s) / dur, 1);
setDisplay(Math.round(start + (end - start) * (1 - Math.pow(1 - p, 3))));
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}, [value]);
return <>{display}{suffix}>;
};
// ---- Badge ----
const Badge = ({ children, variant = 'default' }) => (
{children}
);
// ---- Status Pill ----
const StatusPill = ({ status, label }) => (
{label || status.toUpperCase()}
);
// ---- Severity Badge ----
const SeverityBadge = ({ level }) => (
{level.toUpperCase()}
);
// ---- Confidence Bar ----
const ConfidenceBar = ({ value, showLabel = true, compact = false }) => {
const color = value >= 90 ? 'var(--green)' : value >= 70 ? 'var(--gold)' : value >= 50 ? 'var(--cyan)' : 'var(--red)';
return (
);
};
// ---- Chip (removable tag) ----
const Chip = ({ label, onRemove, color, selected, onClick }) => (
{label}
{onRemove && { e.stopPropagation(); onRemove(); }}>✕ }
);
// ---- Avatar (initials) ----
const Avatar = ({ name, color, size = 32 }) => {
const initials = name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
const bgColors = ['var(--gold)', 'var(--red)', 'var(--cyan)', 'var(--green)'];
const bg = color || bgColors[name.length % bgColors.length];
return (
{initials}
);
};
// ---- Data Table ----
const DataTable = ({ columns, rows, onRowClick, selectable, selected = [], onSelect, emptyMsg = 'No data' }) => {
const [sort, setSort] = React.useState({ key: null, dir: 'asc' });
const sorted = React.useMemo(() => {
if (!sort.key) return rows;
return [...rows].sort((a, b) => {
const va = a[sort.key], vb = b[sort.key];
if (va == null) return 1;
if (vb == null) return -1;
const cmp = typeof va === 'number' ? va - vb : String(va).localeCompare(String(vb));
return sort.dir === 'asc' ? cmp : -cmp;
});
}, [rows, sort]);
const toggleSort = (k) => setSort(s => s.key === k ? { key: k, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key: k, dir: 'asc' });
if (rows.length === 0) return ;
return (
);
};
// ---- Search Bar ----
const SearchBar = ({ value, onChange, placeholder = 'Search...', onSubmit }) => (
⌕
onChange(e.target.value)} placeholder={placeholder} onKeyDown={e => e.key === 'Enter' && onSubmit && onSubmit()} />
{value && onChange('')}>✕ }
);
// ---- Filter Bar ----
const FilterBar = ({ filters, values, onChange, onClear }) => (
{filters.map(f => (
{f.label}
onChange(f.key, e.target.value)}>
All
{f.options.map(o => {o.label || o} )}
))}
{onClear &&
CLEAR }
);
// ---- Toggle / Switch ----
const Toggle = ({ checked, onChange, label }) => (
{label}
onChange(!checked)} role="switch" aria-checked={checked}>
);
// ---- Modal ----
// Scrim is a full-viewport flex container. The modal-shell is a CHILD of the scrim
// so flex centers it. Click on scrim closes; click on modal-shell stops propagation
// so users can interact with the form without accidentally dismissing.
const Modal = ({ open, onClose, title, children, footer, size = 'md' }) => {
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
if (open) window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
e.stopPropagation()}
>
{title}
✕
{children}
{footer &&
{footer}
}
);
};
// ---- Rich Text Editor (Quill) ----
// Word-like formal editor for document content. Lazy-mounts Quill (loaded
// via CDN in CITADEL.html). Falls back to plain textarea if Quill missing.
const RichEditor = ({ value, onChange, placeholder = 'Edit document content...', minHeight = 360 }) => {
const containerRef = React.useRef(null);
const quillRef = React.useRef(null);
const lastSetValueRef = React.useRef(value);
React.useEffect(() => {
if (!containerRef.current) return;
if (typeof window.Quill === 'undefined') {
// Quill CDN not yet loaded — bail; we'll render a textarea fallback below
return;
}
if (quillRef.current) return; // already initialized
const q = new window.Quill(containerRef.current, {
theme: 'snow',
placeholder,
modules: {
toolbar: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ align: [] }],
[{ color: [] }, { background: [] }],
['blockquote', 'code-block'],
['link'],
['clean'],
],
},
});
// Seed initial content
if (value) {
try {
// If it looks like HTML, paste as HTML; otherwise keep as plain text
if (/<[a-z][\s\S]*>/i.test(value)) {
q.clipboard.dangerouslyPasteHTML(value);
} else {
q.setText(value);
}
} catch (e) {
q.setText(value);
}
}
q.on('text-change', () => {
const html = q.root.innerHTML;
lastSetValueRef.current = html;
onChange?.(html);
});
quillRef.current = q;
}, []); // run once
// External value updates → only push into Quill if it diverges
React.useEffect(() => {
const q = quillRef.current;
if (!q) return;
if (value !== lastSetValueRef.current) {
lastSetValueRef.current = value;
const sel = q.getSelection();
if (/<[a-z][\s\S]*>/i.test(value || '')) {
q.clipboard.dangerouslyPasteHTML(value || '');
} else {
q.setText(value || '');
}
if (sel) q.setSelection(sel);
}
}, [value]);
// Fallback path when Quill hasn't loaded
if (typeof window.Quill === 'undefined') {
return (