// ==================== 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 ( ); }; // ---- Notification Drawer ---- const NotificationDrawer = ({ items, onClose, onMarkAllRead }) => { return ( <>
NOTIFICATIONS
{items.length === 0 ? (
✓ All caught up. No new notifications.
) : items.map((n, i) => (
{n.t}
{n.d}
{n.time}
))}
); }; // ---- 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()}>
setQ(e.target.value)} placeholder="Search gateways, actions, tickets..." /> ESC
{filtered.length === 0 ? (
No results found
) : filtered.map((c, i) => ( ))}
↑↓ 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.'}

setChatId(e.target.value)} inputMode="numeric" /> setToken(e.target.value)} />
{connected && }
{msg && (
{msg.ok ? '✓ ' : '✕ '}{msg.text}
)} {showGuide && (
{cfg && cfg.platform_bot_available && ( <>
Easy path · shared CITADEL bot (30 seconds)
  1. In Telegram, open @{botName} and press Start (send any message once, so the bot may write to you).
  2. Open @userinfobot and press Start. It replies with your numeric Id.
  3. Paste that number into Chat ID above. Leave Bot Token blank.
  4. 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)'}
  1. Open @BotFather, send /newbot, pick a name. It gives you a token like 123456789:AAF….
  2. Open your new bot and press Start once.
  3. Get your Chat ID from @userinfobot as above.
  4. 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 (
{label}
{value}
{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) => (
{e.action}
{e.time}
))}
); // ---- Network Topology Widget ---- const NetworkTopology = ({ nodes }) => (
NETWORK_TOPOLOGY
{nodes.map((n, i) => (
{n.label}
))}
Online Busy Alert
); // ---- Quick Actions Widget ---- const QuickActions = ({ actions, accent }) => (
QUICK_ACTIONS
{actions.map((a, i) => ( ))}
); // ---- 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}
{days.map(d =>
{d}
)}
{data.map((row, i) => (
{row.map((v, j) => { const intensity = v / max; return
; })}
))}
0006121823
); }; // ---- Section Header for sub-pages ---- const SubPageHeader = ({ title, gatewayId, subtitle, accentColor, onBack, actions }) => (

{title}

{gatewayId}

{subtitle}

{actions &&
{actions}
}
); // ---- Tabs ---- const Tabs = ({ tabs, active, onChange, accent = 'var(--gold)' }) => (
{tabs.map(t => ( ))}
); // ---- Segmented Control ---- const SegmentedControl = ({ options, value, onChange, accent = 'var(--gold)' }) => (
{options.map(o => ( ))}
); // ---- Two Column Layout for sub-pages ---- const TwoColumnLayout = ({ left, right, leftTitle, rightTitle, accentColor }) => (
{leftTitle}
{left}
{rightTitle}
{right}
); // ---- 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 (
{showLabel && {value}%}
); }; // ---- Chip (removable tag) ---- const Chip = ({ label, onRemove, color, selected, onClick }) => ( {label} {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

{emptyMsg}

; return (
{selectable && } {columns.map(c => ( ))} {sorted.map((r, i) => ( onRowClick && onRowClick(r, i)}> {selectable && } {columns.map(c => ( ))} ))}
onSelect(e.target.checked ? rows.map((_, i) => i) : [])} checked={selected.length === rows.length && rows.length > 0} /> c.sortable !== false && toggleSort(c.key)}> {c.label} {sort.key === c.key && {sort.dir === 'asc' ? '▲' : '▼'}}
e.stopPropagation()}> onSelect(e.target.checked ? [...selected, i] : selected.filter(x => x !== i))} /> {c.render ? c.render(r[c.key], r) : r[c.key]}
); }; // ---- Search Bar ---- const SearchBar = ({ value, onChange, placeholder = 'Search...', onSubmit }) => (
onChange(e.target.value)} placeholder={placeholder} onKeyDown={e => e.key === 'Enter' && onSubmit && onSubmit()} /> {value && }
); // ---- Filter Bar ---- const FilterBar = ({ filters, values, onChange, onClear }) => (
{filters.map(f => (
))} {onClear && }
); // ---- Toggle / Switch ---- const Toggle = ({ checked, onChange, label }) => ( ); // ---- 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 (