/* ===========================================================
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();