// shell.jsx - app shell: sidebar nav, topbar, route container
// Sidebar IA: grouped sections. Renderer prints each section's label, then
// its items. Items inside `children` of a `group:true` node still render as
// the existing expandable submenu (only used for the Capture-SDK admin tools
// today, but kept general).
const NAV_SECTIONS = [
{
label: "Workspace",
items: [
{ id: "home", label: "Home", icon: "home" },
{ id: "dashboard", label: "Dashboard", icon: "layout" },
{ id: "projects", label: "Projects", icon: "folder" },
{ id: "library", label: "Library", icon: "library" },
],
},
{
label: "Ingest",
items: [
{ id: "upload", label: "Upload", icon: "upload" },
{ id: "youtube", label: "YouTube", icon: "download" },
{ id: "recorders", label: "Recorders", icon: "record" },
{ id: "schedule", label: "Schedule", icon: "jobs" },
{ id: "monitors", label: "Monitors", icon: "monitor" },
],
},
{
label: "Operations",
items: [
{ id: "capture", label: "Capture", icon: "capture" },
{ id: "jobs", label: "Jobs", icon: "jobs" },
],
},
{
label: "Admin",
items: [
{ id: "users", label: "Users", icon: "users" },
{ id: "tokens", label: "Tokens", icon: "token" },
{ id: "billing", label: "Billing", icon: "dollar" },
{ id: "containers", label: "Containers", icon: "container" },
{ id: "cluster", label: "Cluster", icon: "cluster" },
{ id: "settings", label: "Settings", icon: "settings" },
],
},
];
// No hidden routes currently; Billing (the satirical pricing page) lives in
// the Admin section above. Real API token management is at /tokens.
const NAV_HIDDEN = [];
// Back-compat: NAV_TREE and ADMIN_TREE were used by other modules.
// NAV_FLAT is consumed by topbar search and the keyboard router.
const NAV_TREE = NAV_SECTIONS.slice(0, 3).flatMap(s => s.items);
const ADMIN_TREE = NAV_SECTIONS[3].items;
const NAV_FLAT = (() => {
const out = [];
NAV_SECTIONS.forEach(s => s.items.forEach(n => out.push({ id: n.id, label: n.label, icon: n.icon })));
NAV_HIDDEN.forEach(n => out.push({ id: n.id, label: n.label, icon: n.icon }));
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, me, collapsed, onToggle }) {
const [openGroups, setOpenGroups] = React.useState(new Set([]));
const [jobsBadge, setJobsBadge] = React.useState(null);
// Live jobs count (#130): poll /jobs?status=active and render the result
// as the sidebar badge on the Jobs item. Falls back to hidden on error.
React.useEffect(() => {
let cancelled = false;
const tick = () => {
window.ZAMPP_API.fetch('/jobs?status=active&limit=200')
.then(d => {
if (cancelled) return;
const list = Array.isArray(d) ? d : (d?.jobs || d?.items || []);
const n = Array.isArray(list) ? list.length : 0;
setJobsBadge(n > 0 ? { kind: n > 5 ? 'warning' : 'neutral', text: n > 99 ? '99+' : String(n) } : null);
})
.catch(() => setJobsBadge(null));
};
tick();
const id = setInterval(tick, 10000);
return () => { cancelled = true; clearInterval(id); };
}, []);
// (Capture live-signal badge previously lived here; it now belongs in the
// topbar status pip alongside the cluster pip. See issue #149.)
// Apply the live Jobs badge to the Operations section, and gate the Admin
// section to admins only (RBAC v2). Non-admins never see Users/Cluster/etc.
// This is UX only — the API enforces the same rules server-side.
const isAdmin = me?.role === 'admin';
const sections = React.useMemo(
() => NAV_SECTIONS
.filter(sec => sec.label !== 'Admin' || isAdmin)
.map(sec => ({
...sec,
items: sec.items.map(n => (n.id === 'jobs' && jobsBadge) ? { ...n, badge: jobsBadge } : n),
})),
[jobsBadge, isAdmin]
);
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", "youtube", "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;
const listboxId = 'global-search-listbox';
return (
{ setQ(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 120)}
onKeyDown={onKeyDown}
placeholder="Search assets, projects, recorders…"
type="search"
role="combobox"
aria-label="Search assets, projects, recorders, jobs, and users"
aria-expanded={showDropdown}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={showDropdown && results[sel] ? `${listboxId}-opt-${sel}` : undefined}
/>
{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, onToggleSidebar }) {
// 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 (
{onToggleSidebar && (
)}
{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 (
{label}
{children || (select
? (
{value}
) : (
)
)}
);
}
window.Sidebar = Sidebar;
window.Topbar = Topbar;
window.NAV_TREE = NAV_TREE;
window.NAV_FLAT = NAV_FLAT;
window.NAV_SECTIONS = NAV_SECTIONS;
window.Field = Field;
window.GlobalSearch = GlobalSearch;