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.
This commit is contained in:
parent
992fbdfa20
commit
45c0e0f914
1 changed files with 174 additions and 6 deletions
|
|
@ -26,6 +26,16 @@ const ADMIN_TREE = [
|
||||||
{ id: "settings", label: "Settings", icon: "settings" },
|
{ 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 }) {
|
function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup }) {
|
||||||
const isGroup = item.group && item.children;
|
const isGroup = item.group && item.children;
|
||||||
const isOpen = isGroup && openGroups.has(item.id);
|
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 (
|
||||||
|
<div className="search-wrap">
|
||||||
|
<div className={`search ${showDropdown ? 'is-open' : ''}`}>
|
||||||
|
<Icon name="search" className="search-icon" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={q}
|
||||||
|
onChange={e => { setQ(e.target.value); setOpen(true); }}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setTimeout(() => setOpen(false), 120)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="Search assets, projects, recorders…"
|
||||||
|
/>
|
||||||
|
<span className="kbd">{isMac ? '⌘K' : 'Ctrl K'}</span>
|
||||||
|
</div>
|
||||||
|
{showDropdown && (
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
className="search-results"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<div className="search-empty">No results for “{q}”</div>
|
||||||
|
) : results.map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={r.kind + '-' + i + '-' + (r.item ? (r.item.id || r.item.name) : (r.target || r.label))}
|
||||||
|
className={`search-result ${i === sel ? 'active' : ''}`}
|
||||||
|
onClick={() => handleSelect(r)}
|
||||||
|
onMouseEnter={() => setSel(i)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="search-result-icon"
|
||||||
|
style={r.color ? { color: r.color } : null}
|
||||||
|
>
|
||||||
|
<Icon name={r.icon} size={14} />
|
||||||
|
</span>
|
||||||
|
<div className="search-result-text">
|
||||||
|
<div className="search-result-label">{r.label}</div>
|
||||||
|
{r.sub && <div className="search-result-sub">{r.sub}</div>}
|
||||||
|
</div>
|
||||||
|
<span className={`search-result-kind kind-${r.kind}`}>{r.kind}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject }) {
|
||||||
return (
|
return (
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="crumb">
|
<div className="crumb">
|
||||||
|
|
@ -146,11 +316,7 @@ function Topbar({ crumbs, onNavigate, right }) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
<div className="search">
|
<GlobalSearch onNavigate={onNavigate} onOpenAsset={onOpenAsset} onOpenProject={onOpenProject} />
|
||||||
<Icon name="search" className="search-icon" />
|
|
||||||
<input placeholder="Search assets, projects, comments…" />
|
|
||||||
<span className="kbd">⌘K</span>
|
|
||||||
</div>
|
|
||||||
<div className="status-pip">
|
<div className="status-pip">
|
||||||
<span className="dot" />
|
<span className="dot" />
|
||||||
<span>cluster healthy</span>
|
<span>cluster healthy</span>
|
||||||
|
|
@ -185,4 +351,6 @@ function Field({ label, value, select, children }) {
|
||||||
window.Sidebar = Sidebar;
|
window.Sidebar = Sidebar;
|
||||||
window.Topbar = Topbar;
|
window.Topbar = Topbar;
|
||||||
window.NAV_TREE = NAV_TREE;
|
window.NAV_TREE = NAV_TREE;
|
||||||
|
window.NAV_FLAT = NAV_FLAT;
|
||||||
window.Field = Field;
|
window.Field = Field;
|
||||||
|
window.GlobalSearch = GlobalSearch;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue