// shell.jsx - app shell: sidebar nav, topbar, route container const NAV_TREE = [ { id: "home", label: "Home", icon: "home" }, { id: "library", label: "Library", icon: "library" }, { id: "projects", label: "Projects", icon: "folder" }, { id: "ingest", label: "Ingest", icon: "upload", group: true, children: [ { id: "upload", label: "Upload", icon: "upload" }, { id: "recorders", label: "Recorders", icon: "record" }, { id: "schedule", label: "Schedule", icon: "jobs" }, { id: "capture", label: "Capture", icon: "capture" }, { id: "monitors", label: "Monitors", icon: "monitor" }, ], }, { id: "jobs", label: "Jobs", icon: "jobs", badge: { kind: "neutral", text: "3" } }, { id: "editor", label: "Editor", icon: "editor", badge: { kind: "dev", text: "DEV" } }, ]; const ADMIN_TREE = [ { id: "users", label: "Users", icon: "users" }, { id: "tokens", label: "Tokens", icon: "token" }, { id: "containers", label: "Containers", icon: "container" }, { id: "cluster", label: "Cluster", icon: "cluster" }, { id: "settings", label: "Settings", icon: "settings" }, ]; const NAV_FLAT = (() => { const out = []; const visit = (arr) => arr.forEach(n => { if (n.group && n.children) { visit(n.children); return; } out.push({ id: n.id, label: n.label, icon: n.icon }); }); visit(NAV_TREE); visit(ADMIN_TREE); return out; })(); function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup }) { const isGroup = item.group && item.children; const isOpen = isGroup && openGroups.has(item.id); const isActive = active === item.id || (isGroup && item.children.some(c => c.id === active)); return ( <>
{ if (isGroup) toggleGroup(item.id); else onSelect(item.id); }} > {item.label} {item.badge && ( {item.badge.text} )} {isGroup && }
{isGroup && isOpen && (
{item.children.map(c => (
onSelect(c.id)} > {c.label} {c.badge && {c.badge.text}}
))}
)} ); } function Sidebar({ active, onNavigate }) { const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"])); const toggleGroup = (id) => { setOpenGroups(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; React.useEffect(() => { const ingestChildren = ["upload", "recorders", "schedule", "capture", "monitors"]; if (ingestChildren.includes(active)) { setOpenGroups(prev => prev.has("ingest") ? prev : new Set([...prev, "ingest"])); } }, [active]); return ( ); } function assetSearchIcon(a) { const t = (a.type || a.media_type || '').toLowerCase(); if (t.includes('audio')) return 'audio'; if (t.includes('image')) return 'image'; return 'film'; } function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) { const [q, setQ] = React.useState(''); const [open, setOpen] = React.useState(false); const [sel, setSel] = React.useState(0); const inputRef = React.useRef(null); const listRef = React.useRef(null); const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform); React.useEffect(() => { const handler = (e) => { const key = e.key && e.key.toLowerCase(); if ((e.metaKey || e.ctrlKey) && key === 'k') { e.preventDefault(); if (inputRef.current) { inputRef.current.focus(); inputRef.current.select(); setOpen(true); } } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, []); const results = React.useMemo(() => { const term = q.trim().toLowerCase(); if (!term) return []; const D = window.ZAMPP_DATA || {}; const out = []; (D.ASSETS || []).forEach(a => { const hay = ((a.name || '') + ' ' + (a.project || '') + ' ' + (a.filename || '')).toLowerCase(); if (hay.includes(term)) { const sub = [a.project, a.res, a.duration].filter(x => x && x !== '—').join(' · '); out.push({ kind: 'asset', icon: assetSearchIcon(a), label: a.name, sub, item: a }); } }); (D.PROJECTS || []).forEach(p => { if ((p.name || '').toLowerCase().includes(term)) { out.push({ kind: 'project', icon: 'folder', label: p.name, sub: (p.assets || 0) + ' assets', item: p, color: p.color }); } }); (D.RECORDERS || []).forEach(r => { const hay = ((r.name || '') + ' ' + (r.url || '') + ' ' + (r.source || '')).toLowerCase(); if (hay.includes(term)) { out.push({ kind: 'recorder', icon: 'record', label: r.name || r.url || 'Recorder', sub: [r.status, r.source].filter(Boolean).join(' · '), item: r }); } }); (D.JOBS || []).forEach(j => { const hay = ((j.asset || '') + ' ' + (j.kind || '') + ' ' + (j.status || '')).toLowerCase(); if (hay.includes(term)) { out.push({ kind: 'job', icon: 'jobs', label: j.asset || j.kind || 'Job', sub: [j.kind, j.status].filter(Boolean).join(' · '), item: j }); } }); (D.USERS || []).forEach(u => { const hay = ((u.name || '') + ' ' + (u.email || '') + ' ' + (u.username || '')).toLowerCase(); if (hay.includes(term)) { out.push({ kind: 'user', icon: 'users', label: u.name, sub: u.email || u.role, item: u }); } }); NAV_FLAT.forEach(n => { if (n.label.toLowerCase().includes(term)) { out.push({ kind: 'nav', icon: n.icon, label: n.label, sub: 'Go to ' + n.label, target: n.id }); } }); return out.slice(0, 40); }, [q, open]); React.useEffect(() => { setSel(0); }, [q]); React.useEffect(() => { if (!listRef.current) return; const el = listRef.current.querySelector('.search-result.active'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' }); }, [sel]); const handleSelect = (r) => { if (!r) return; setOpen(false); setQ(''); if (inputRef.current) inputRef.current.blur(); if (r.kind === 'asset' && onOpenAsset) onOpenAsset(r.item); else if (r.kind === 'project' && onOpenProject) onOpenProject(r.item); else if (r.kind === 'recorder') onNavigate && onNavigate('recorders'); else if (r.kind === 'job') onNavigate && onNavigate('jobs'); else if (r.kind === 'user') onNavigate && onNavigate('users'); else if (r.kind === 'nav') onNavigate && onNavigate(r.target); }; const onKeyDown = (e) => { if (e.key === 'Escape') { setOpen(false); setQ(''); if (inputRef.current) inputRef.current.blur(); return; } if (!open || results.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(s + 1, results.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(0, s - 1)); } else if (e.key === 'Enter') { e.preventDefault(); handleSelect(results[sel]); } }; const showDropdown = open && q.trim().length > 0; return (
{ setQ(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 120)} onKeyDown={onKeyDown} placeholder="Search assets, projects, recorders…" /> {isMac ? '⌘K' : 'Ctrl K'}
{showDropdown && (
e.preventDefault()} > {results.length === 0 ? (
No results for “{q}”
) : results.map((r, i) => (
handleSelect(r)} onMouseEnter={() => setSel(i)} >
{r.label}
{r.sub &&
{r.sub}
}
{r.kind}
))}
)}
); } function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject }) { // Light cluster ping — the badge in the topbar should reflect reality, // not just look reassuring. /metrics/home returns cluster online/total. const [clusterHealthy, setClusterHealthy] = React.useState(true); React.useEffect(() => { let cancelled = false; const ping = () => { window.ZAMPP_API.fetch('/metrics/home?hours=1') .then(d => { if (cancelled) return; const c = d?.cards?.cluster; setClusterHealthy(!c || c.online >= c.total); }) .catch(() => {}); }; ping(); const t = setInterval(ping, 30_000); return () => { cancelled = true; clearInterval(t); }; }, []); return (
{crumbs.map((c, i) => ( {i > 0 && } c.to && onNavigate && onNavigate(c.to)} > {c.label} ))}
{clusterHealthy ? 'cluster healthy' : 'cluster degraded'}
{right}
); } // General-purpose read-only form field used by recorder modal and other forms. // Renders as a labeled select (when select=true) or text input. function Field({ label, value, select, children }) { return (
{children || (select ? ( ) : ( ) )}
); } window.Sidebar = Sidebar; window.Topbar = Topbar; window.NAV_TREE = NAV_TREE; window.NAV_FLAT = NAV_FLAT; window.Field = Field; window.GlobalSearch = GlobalSearch;