From 45c0e0f9145bf6bd2dbc0bc583c7e789720f953b Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Fri, 22 May 2026 23:52:49 -0400 Subject: [PATCH] feat(web-ui): wire global search in topbar with results dropdown Replaces the static topbar input with a working command-palette-style search that queries ZAMPP_DATA across assets, projects, recorders, jobs, users, and nav targets. Cmd/Ctrl+K focuses the input, arrow keys move selection, Enter opens, Esc dismisses. Selecting an asset opens the asset detail; project opens project view; other kinds navigate. --- services/web-ui/public/shell.jsx | 180 +++++++++++++++++++++++++++++-- 1 file changed, 174 insertions(+), 6 deletions(-) diff --git a/services/web-ui/public/shell.jsx b/services/web-ui/public/shell.jsx index b4ee54e..97353d0 100644 --- a/services/web-ui/public/shell.jsx +++ b/services/web-ui/public/shell.jsx @@ -26,6 +26,16 @@ const ADMIN_TREE = [ { 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); @@ -128,7 +138,167 @@ function Sidebar({ active, onNavigate }) { ); } -function Topbar({ crumbs, onNavigate, right }) { +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 }) { return (
@@ -146,11 +316,7 @@ function Topbar({ crumbs, onNavigate, right }) { ))}
-
- - - ⌘K -
+
cluster healthy @@ -185,4 +351,6 @@ function Field({ label, value, select, children }) { window.Sidebar = Sidebar; window.Topbar = Topbar; window.NAV_TREE = NAV_TREE; +window.NAV_FLAT = NAV_FLAT; window.Field = Field; +window.GlobalSearch = GlobalSearch;