/* =========================================================== App shell: top bar, sidebar, hash-router, theme, ⌘K =========================================================== */ const { useState: useStateA, useEffect: useEffectA, useMemo: useMemoA, useRef: useRefA, useCallback: useCallbackA } = React; /* --------- Theme (persisted) --------- */ function useTheme() { const [theme, setTheme] = useStateA(() => { try { return localStorage.getItem('newsroom.theme') || 'dark'; } catch { return 'dark'; } }); useEffectA(() => { document.documentElement.setAttribute('data-theme', theme); try { localStorage.setItem('newsroom.theme', theme); } catch {} }, [theme]); return [theme, setTheme]; } /* --------- Hash router --------- */ function useHashRoute() { const [path, setPath] = useStateA(() => window.location.hash.slice(1) || '/'); useEffectA(() => { const handler = () => setPath(window.location.hash.slice(1) || '/'); window.addEventListener('hashchange', handler); return () => window.removeEventListener('hashchange', handler); }, []); const nav = useCallbackA((to) => { window.location.hash = to; }, []); return [path, nav]; } /* --------- Sidebar (persisted) --------- */ function useSidebar() { const [collapsed, setCollapsed] = useStateA(() => { try { return localStorage.getItem('newsroom.sidebar') === 'collapsed'; } catch { return false; } }); useEffectA(() => { try { localStorage.setItem('newsroom.sidebar', collapsed ? 'collapsed' : 'expanded'); } catch {} }, [collapsed]); return [collapsed, setCollapsed]; } /* --------- Sidebar nav config --------- */ const NAV = [ { to: '/', label: 'Overview', icon: 'layout', match: p => p === '/' }, { to: '/pipeline', label: 'Pipeline', icon: 'funnel', match: p => p === '/pipeline' }, { to: '/runs', label: 'Runs', icon: 'list', match: p => p === '/runs' || /^\/runs\/[^/]+$/.test(p) }, { to: '/terminal', label: 'Terminal', icon: 'terminal', match: p => p === '/terminal' }, { to: '/ranking', label: 'Ranking', icon: 'trophy', match: p => p === '/ranking' }, { to: '/arena', label: 'Arena', icon: 'swords', match: p => p === '/arena' }, { to: '/visor', label: 'Visor', icon: 'retry', match: p => /\/visor$/.test(p) }, { to: '/sources', label: 'Sources', icon: 'newspaper',match: p => p === '/sources' }, { to: '/settings', label: 'Settings', icon: 'settings', match: p => p === '/settings' }, ]; /* --------- Command palette --------- */ function CommandPalette({ open, onClose, runs, nav, toggleTheme, toggleSidebar }) { const [q, setQ] = useStateA(''); const [sel, setSel] = useStateA(0); const inputRef = useRefA(null); useEffectA(() => { if (open) { setQ(''); setSel(0); setTimeout(() => inputRef.current?.focus(), 50); } }, [open]); const items = useMemoA(() => { const navItems = [ { kind: 'nav', label: 'Overview', sub: 'g o', to: '/' }, { kind: 'nav', label: 'Pipeline', sub: 'g p', to: '/pipeline' }, { kind: 'nav', label: 'Runs', sub: 'g r', to: '/runs' }, { kind: 'nav', label: 'Terminal', sub: 'g t', to: '/terminal' }, { kind: 'nav', label: 'Ranking', sub: 'g k', to: '/ranking' }, { kind: 'nav', label: 'Arena', sub: 'g a', to: '/arena' }, { kind: 'nav', label: 'Sources', sub: 'g s', to: '/sources' }, { kind: 'nav', label: 'Settings', sub: '', to: '/settings' }, ]; const actions = [ { kind: 'action', label: 'Abrir último run', sub: 'enter', do: () => nav(`/runs/${runs[0].id}`) }, { kind: 'action', label: 'Abrir visor del último', sub: '', do: () => nav(`/runs/${runs[0].id}/visor`) }, { kind: 'action', label: 'Cambiar tema (claro/oscuro)', sub: 't', do: () => toggleTheme() }, { kind: 'action', label: 'Plegar / desplegar sidebar', sub: '[', do: () => toggleSidebar() }, { kind: 'action', label: 'Copiar JSON del último run', sub: '', do: () => { navigator.clipboard?.writeText(JSON.stringify(runs[0], null, 2)); window.__toast?.('JSON copiado', 'ok'); } }, ]; const runItems = runs.map(r => ({ kind: 'run', label: r.editor.selected_titulo, sub: `${r.id} · ${fmtClock(r.timestamp)}`, to: `/runs/${r.id}`, })); const all = [...navItems, ...actions, ...runItems]; if (!q) return all; const lower = q.toLowerCase(); return all.filter(i => i.label.toLowerCase().includes(lower) || (i.sub||'').toLowerCase().includes(lower)); }, [q, runs, nav, toggleTheme, toggleSidebar]); const onKey = (e) => { if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(s+1, items.length-1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(s-1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); const it = items[sel]; if (!it) return; if (it.to) nav(it.to); else if (it.do) it.do(); onClose(); } else if (e.key === 'Escape') { onClose(); } }; if (!open) return null; return (
e.stopPropagation()}>
{ setQ(e.target.value); setSel(0); }} onKeyDown={onKey} placeholder="Buscar acciones, runs, vistas…" /> esc
{items.length === 0 &&
Sin resultados
} {items.map((it, i) => { const groupLabel = i === 0 ? it.kind : (items[i-1].kind !== it.kind ? it.kind : null); return ( {groupLabel &&
{groupLabel === 'nav' ? 'Navegación' : groupLabel === 'action' ? 'Acciones' : 'Runs'}
}
setSel(i)} onClick={() => { if (it.to) nav(it.to); else if (it.do) it.do(); onClose(); }} > {it.kind === 'nav' && } {it.kind === 'action' && } {it.kind === 'run' && } {it.label} {it.sub && {it.sub}}
); })}
); } /* --------- Toasts --------- */ function ToastHost() { const [toasts, setToasts] = useStateA([]); useEffectA(() => { window.__toast = (msg, kind='info') => { const id = Date.now() + Math.random(); setToasts(t => [...t, { id, msg, kind }]); setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 3200); }; }, []); return (
{toasts.map(t => (
{t.kind === 'ok' ? : t.kind === 'warn' ? : t.kind === 'err' ? : } {t.msg}
))}
); } /* --------- App --------- */ function App() { const [theme, setTheme] = useTheme(); const [collapsed, setCollapsed] = useSidebar(); const [path, nav] = useHashRoute(); const [cmdOpen, setCmdOpen] = useStateA(false); const [runs, setRuns] = useStateA(() => getMockExecutionHistory()); const [logs, setLogs] = useStateA(() => getMockLogs()); // Poll bridge for real runs every 4s, fallback to mock when offline useEffectA(() => { if (!window.bridgeFetchRuns) return; let cancelled = false; async function poll() { const bridgeRuns = await window.bridgeFetchRuns(); if (!cancelled && bridgeRuns && bridgeRuns.length > 0) { setRuns(bridgeRuns); } } poll(); const id = setInterval(poll, 4000); return () => { cancelled = true; clearInterval(id); }; }, []); // Connect WebSocket for live terminal logs useEffectA(() => { if (!window.connectBridgeLogs) return; const disconnect = window.connectBridgeLogs( (line) => setLogs(prev => [line, ...prev.slice(0, 999)]), (status) => { if (status === 'connected') window.__toast?.('Bridge conectado', 'ok'); if (status === 'disconnected') window.__toast?.('Bridge desconectado — usando datos locales', 'warn'); if (status.startsWith('rotated:')) window.__toast?.('Nuevo log: ' + status.split(':')[1], 'info'); } ); return disconnect; }, []); // periodic toasts so the dashboard feels alive useEffectA(() => { const id1 = setTimeout(() => window.__toast?.('Run completado · publicado #24812', 'ok'), 4000); const id2 = setTimeout(() => window.__toast?.('Anomalía detectada en run-002', 'warn'), 11000); return () => { clearTimeout(id1); clearTimeout(id2); }; }, []); // keyboard useEffectA(() => { let gMode = false; const onKey = (e) => { // ⌘K / ctrl K if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setCmdOpen(o => !o); return; } if (e.key === 'Escape') { setCmdOpen(false); return; } if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.key.toLowerCase() === 'g') { gMode = true; setTimeout(() => gMode = false, 800); return; } if (gMode) { const map = { o: '/', p: '/pipeline', r: '/runs', t: '/terminal', k: '/ranking', a: '/arena', s: '/sources' }; const to = map[e.key.toLowerCase()]; if (to) { nav(to); gMode = false; } return; } if (e.key === 't') { setTheme(t => t === 'dark' ? 'light' : 'dark'); } if (e.key === '[') { setCollapsed(c => !c); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [nav]); // route resolution const view = useMemoA(() => { if (path === '/' || path === '') return ; if (path === '/pipeline') return ; if (path === '/runs') return ; if (path === '/terminal') return ; if (path === '/ranking') return ; if (path === '/arena') return ; if (path === '/sources') return ; if (path === '/settings') return ; if (path === '/visor') { nav(`/runs/${runs[0].id}/visor`); return null; } const m1 = path.match(/^\/runs\/([^/]+)\/visor$/); if (m1) { const r = runs.find(r => r.id === m1[1]); if (r) return ; } const m2 = path.match(/^\/runs\/([^/]+)$/); if (m2) { const r = runs.find(r => r.id === m2[1]); if (r) return ; } return
404
Ruta no encontrada: {path}
; }, [path, runs, logs, nav]); const latest = runs[0]; const sidebarLabel = (icon) => { const map = { layout: , funnel: , list: , terminal: , trophy: , swords: , retry: , newspaper: , settings: , }; return map[icon]; }; return (
{/* TOP BAR */}
setCollapsed(c => !c)} style={{cursor: 'pointer'}} title="Pulsa para plegar/desplegar la barra lateral">
SALA DE REDACCIÓN
NEWSROOM // MONITOR
N
Ollama ok
GPU 71%
Modelos 3
LLM lat 2.1s
Runs/h 3.4
bridge ws · publica cada 4s
{/* SIDEBAR */}
{!collapsed &&
Vistas
} {NAV.map(n => (
{ if (n.to === '/visor') nav(`/runs/${latest.id}/visor`); else nav(n.to); }} title={collapsed ? n.label : ''} > {sidebarLabel(n.icon)} {n.label} {n.to === '/runs' && {runs.length}} {n.to === '/terminal' && }
))}
Último run
{fmtClock(latest.timestamp)} {fmtAgo(latest.timestamp)}
{latest.editor.selected_titulo}
{/* MAIN */}
{view}
setCmdOpen(false)} runs={runs} nav={nav} toggleTheme={() => setTheme(theme === 'dark' ? 'light' : 'dark')} toggleSidebar={() => setCollapsed(c => !c)} />
); } const root = ReactDOM.createRoot(document.getElementById('root')); root.render();