File created successfully at: /tmp/app.jsx (file state is current in your context — no need to Read it back)

// app.jsx — main shell

const ACCENT = '#5B7CFA';

function App() {
  const [route, setRoute] = React.useState('home');
  const [openAsset, setOpenAsset] = React.useState(null);
  const [openProject, setOpenProject] = React.useState(null);
  const [showNewRecorder, setShowNewRecorder] = React.useState(false);
  const [dataReady, setDataReady] = React.useState(false);
  const [contextMenu, setContextMenu] = React.useState(null);

  React.useEffect(() => {
    document.documentElement.style.setProperty('--accent', ACCENT);
    document.documentElement.style.setProperty('--accent-soft', hexToRgba(ACCENT, 0.14));
    document.documentElement.style.setProperty('--accent-soft-2', hexToRgba(ACCENT, 0.22));
    document.documentElement.style.setProperty('--accent-text', lighten(ACCENT, 0.25));
    document.documentElement.style.setProperty('--accent-hover', lighten(ACCENT, 0.08));
  }, []);

  React.useEffect(() => {
    window.ZAMPP_API.loadData()
      .then(() => setDataReady(true))
      .catch(err => { console.error('[Dragonflight] load failed:', err); setDataReady(true); });
  }, []);

  React.useEffect(() => {
    window.openContextMenu = (x, y, items) => {
      if (!items || items.length === 0) return;
      setContextMenu({ x, y, items });
    };
    window.closeContextMenu = () => setContextMenu(null);
    return () => { delete window.openContextMenu; delete window.closeContextMenu; };
  }, []);

  const navigate = (id) => { setOpenAsset(null); setRoute(id); };
  const openProjectFromAnywhere = (p) => { setOpenAsset(null); setOpenProject(p); setRoute('library'); };

  const crumbs = React.useMemo(() => {
    if (openAsset) return [
      { label: 'Library', to: 'library' },
      { label: openAsset.project || 'Library', to: 'library' },
      { label: openAsset.name },
    ];
    if (openProject) return [
      { label: 'Projects', to: 'projects' },
      { label: openProject.name },
    ];
    const labels = {
      home: ['Home'], library: ['Library'], projects: ['Projects'],
      upload: ['Ingest', 'Upload'], recorders: ['Ingest', 'Recorders'],
      schedule: ['Ingest', 'Schedule'],
      capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
      jobs: ['Jobs'], editor: ['Editor'],
      users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
      containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
      settings: ['Admin', 'Settings'],
    };
    return (labels[route] || ['Home']).map(label => ({ label }));
  }, [route, openAsset, openProject]);

  if (!dataReady) {
    return (
      <>

Loading Dragonflight…

      </>
    );
  }

  let content;
  if (openAsset) {
    content = <AssetDetail asset={openAsset} onClose={() => setOpenAsset(null)} />;
  } else {
    switch (route) {
      case 'home':       content = <Home navigate={navigate} />; break;
      case 'library':   content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} />; break;
      case 'projects':  content = <Projects navigate={navigate} onOpenProject={openProjectFromAnywhere} />; break;
      case 'upload':    content = <Upload navigate={navigate} />; break;
      case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;
      case 'schedule':  content = <Schedule navigate={navigate} />; break;
      case 'capture':   content = <Capture navigate={navigate} />; break;
      case 'monitors':  content = <Monitors navigate={navigate} />; break;
      case 'jobs':      content = <Jobs navigate={navigate} />; break;
      case 'editor':    content = <Editor />; break;
      case 'users':     content = <Users />; break;
      case 'tokens':    content = <Tokens />; break;
      case 'containers':content = <Containers />; break;
      case 'cluster':   content = <Cluster />; break;
      case 'settings':  content = <Settings />; break;
      default:          content = <Home navigate={navigate} />;
    }
  }

  return (

      <Sidebar active={openAsset ? 'library' : route} onNavigate={navigate} />

        {!openAsset && (
          <Topbar
            crumbs={crumbs}
            onNavigate={navigate}
            onOpenAsset={setOpenAsset}
            onOpenProject={openProjectFromAnywhere}
          />
        )}
        {content}

      {showNewRecorder && <NewRecorderModal open={showNewRecorder} onClose={() => setShowNewRecorder(false)} />}
      <ContextMenu menu={contextMenu} onClose={() => setContextMenu(null)} />

  );
}

function hexToRgba(hex, a) {
  const h = hex.replace('#', '');
  const r = parseInt(h.slice(0, 2), 16);
  const g = parseInt(h.slice(2, 4), 16);
  const b = parseInt(h.slice(4, 6), 16);
  return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
}
function lighten(hex, amt) {
  const h = hex.replace('#', '');
  const r = Math.min(255, parseInt(h.slice(0, 2), 16) + Math.round(amt * 255));
  const g = Math.min(255, parseInt(h.slice(2, 4), 16) + Math.round(amt * 255));
  const b = Math.min(255, parseInt(h.slice(4, 6), 16) + Math.round(amt * 255));
  return 'rgb(' + r + ',' + g + ',' + b + ')';
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);


[Request interrupted by user]


The search feature doesn't actually do anything on https://forge.wilddragon.net/zgaetano/dragonflight  please wire up the search bar and make it work properly.  Additionally, I'd like right click to o…


Wire up search bar and add right-click context menu


No response requested.


please finish the search fixes - disregard the right click task.


Got it — dropping the context menu work, just shipping the search.


TodoWrite

Explore screens to understand searchable data shapes

Exploring screens to understand searchable data shapes

Wire up topbar search bar with results dropdown + ⌘K shortcut

Wiring up topbar search bar with results dropdown + ⌘K shortcut

Add CSS styles for search results dropdown

Adding CSS styles for search results dropdown

Commit changes to forgejo


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Explore screens to understand searchable data shapes

Exploring screens to understand searchable data shapes

Wire up topbar search bar with results dropdown + ⌘K shortcut

Wiring up topbar search bar with results dropdown + ⌘K shortcut

Add reusable right-click context menu component

Adding reusable right-click context menu component

Add context menu actions to asset/project cards

Adding context menu actions to asset/project cards

Add CSS styles for search results dropdown + context menu

Adding CSS styles for search results dropdown + context menu

Commit changes to forgejo

Commit changes to forgejo


Write

// 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 (
    <>
      <div
        className={`nav-item ${isActive && !isGroup ? "active" : ""} ${isGroup ? "has-children" : ""} ${isOpen ? "open" : ""}`}
        onClick={() => {
          if (isGroup) toggleGroup(item.id);
          else onSelect(item.id);
        }}
      >
        <Icon name={item.icon} size={15} className="nav-icon" />
        {item.label}
        {item.badge && (
          <span className={`nav-badge ${item.badge.kind}`}>{item.badge.text}
        )}
        {isGroup && <Icon name="chevron" size={12} className="nav-caret" />}

      {isGroup && isOpen && (

          {item.children.map(c => (
            <div
              key={c.id}
              className={`nav-item ${active === c.id ? "active" : ""}`}
              onClick={() => onSelect(c.id)}
            >
              <Icon name={c.icon} size={14} className="nav-icon" />
              {c.label}
              {c.badge && <span className={`nav-badge ${c.badge.kind}`}>{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 (

D

Dragonflight

v1.2.0

        {NAV_TREE.map(item => (
          <NavItem
            key={item.id}
            item={item}
            active={active}
            onSelect={onNavigate}
            openGroups={openGroups}
            toggleGroup={toggleGroup}
          />
        ))}

Admin

        {ADMIN_TREE.map(item => (
          <NavItem
            key={item.id}
            item={item}
            active={active}
            onSelect={onNavigate}
            openGroups={openGroups}
            toggleGroup={toggleGroup}
          />
        ))}

ZG

Zach Gaetano

admin · broadcast

        <button className="icon-btn" data-tip="Sign out"><Icon name="power" /></button>

  );
}

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 ${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…"
        />
        {isMac ? '⌘K' : 'Ctrl K'}

      {showDropdown && (

 e.preventDefault()}
        >
          {results.length === 0 ? (

No results for “{q}”

          ) : 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)}
            >

                <Icon name={r.icon} size={14} />

{r.label}

                {r.sub &&
{r.sub}
}

              <span className={`search-result-kind kind-${r.kind}`}>{r.kind}

          ))}

      )}

  );
}

function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject }) {
  return (

        {crumbs.map((c, i) => (
          <React.Fragment key={i}>
            {i > 0 && <Icon name="chevron" size={12} className="sep" />}
            <span
              className={i === crumbs.length - 1 ? "current" : ""}
              style={{ cursor: c.to && i < crumbs.length - 1 ? "pointer" : "default" }}
              onClick={() => c.to && onNavigate && onNavigate(c.to)}
            >
              {c.label}

          </React.Fragment>
        ))}

      <GlobalSearch onNavigate={onNavigate} onOpenAsset={onOpenAsset} onOpenProject={onOpenProject} />

        cluster healthy

      <button className="icon-btn" data-tip="Notifications">
        <Icon name="bell" />
      </button>
      {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
        ? (
          <select className="field-input" defaultValue={value} style={{ appearance: 'auto' }}>
            <option value={value}>{value}</option>
          </select>
        ) : (
          <input className="field-input" defaultValue={value} readOnly />
        )
      )}

  );
}

window.Sidebar = Sidebar;
window.Topbar = Topbar;
window.NAV_TREE = NAV_TREE;
window.NAV_FLAT = NAV_FLAT;
window.Field = Field;
window.GlobalSearch = GlobalSearch;


The file /tmp/shell.jsx has been updated successfully. (file state is current in your context — no need to Read it back)

// 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 (
    <>
      <div
        className={`nav-item ${isActive && !isGroup ? "active" : ""} ${isGroup ? "has-children" : ""} ${isOpen ? "open" : ""}`}
        onClick={() => {
          if (isGroup) toggleGroup(item.id);
          else onSelect(item.id);
        }}
      >
        <Icon name={item.icon} size={15} className="nav-icon" />
        {item.label}
        {item.badge && (
          <span className={`nav-badge ${item.badge.kind}`}>{item.badge.text}
        )}
        {isGroup && <Icon name="chevron" size={12} className="nav-caret" />}

      {isGroup && isOpen && (

          {item.children.map(c => (
            <div
              key={c.id}
              className={`nav-item ${active === c.id ? "active" : ""}`}
              onClick={() => onSelect(c.id)}
            >
              <Icon name={c.icon} size={14} className="nav-icon" />
              {c.label}
              {c.badge && <span className={`nav-badge ${c.badge.kind}`}>{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 (

D

Dragonflight

v1.2.0

        {NAV_TREE.map(item => (
          <NavItem
            key={item.id}
            item={item}
            active={active}
            onSelect={onNavigate}
            openGroups={openGroups}
            toggleGroup={toggleGroup}
          />
        ))}

Admin

        {ADMIN_TREE.map(item => (
          <NavItem
            key={item.id}
            item={item}
            active={active}
            onSelect={onNavigate}
            openGroups={openGroups}
            toggleGroup={toggleGroup}
          />
        ))}

ZG

Zach Gaetano

admin · broadcast

        <button className="icon-btn" data-tip="Sign out"><Icon name="power" /></button>

  );
}

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 ${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…"
        />
        {isMac ? '⌘K' : 'Ctrl K'}

      {showDropdown && (

 e.preventDefault()}
        >
          {results.length === 0 ? (

No results for “{q}”

          ) : 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)}
            >

                <Icon name={r.icon} size={14} />

{r.label}

                {r.sub &&
{r.sub}
}

              <span className={`search-result-kind kind-${r.kind}`}>{r.kind}

          ))}

      )}

  );
}

function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject }) {
  return (

        {crumbs.map((c, i) => (
          <React.Fragment key={i}>
            {i > 0 && <Icon name="chevron" size={12} className="sep" />}
            <span
              className={i === crumbs.length - 1 ? "current" : ""}
              style={{ cursor: c.to && i < crumbs.length - 1 ? "pointer" : "default" }}
              onClick={() => c.to && onNavigate && onNavigate(c.to)}
            >
              {c.label}

          </React.Fragment>
        ))}

      <GlobalSearch onNavigate={onNavigate} onOpenAsset={onOpenAsset} onOpenProject={onOpenProject} />

        cluster healthy

      <button className="icon-btn" data-tip="Notifications">
        <Icon name="bell" />
      </button>
      {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
        ? (
          <select className="field-input" defaultValue={value} style={{ appearance: 'auto' }}>
            <option value={value}>{value}</option>
          </select>
        ) : (
          <input className="field-input" defaultValue={value} readOnly />
        )
      )}

  );
}

window.Sidebar = Sidebar;
window.Topbar = Topbar;
window.NAV_TREE = NAV_TREE;
window.NAV_FLAT = NAV_FLAT;
window.Field = Field;
window.GlobalSearch = GlobalSearch;

-function ContextMenu({ menu, onClose }) {

-  const [pos, setPos] = React.useState({ left: 0, top: 0, ready: false });

-    const rect = ref.current.getBoundingClientRect();

-    if (left + rect.width + 6 > vw) left = Math.max(6, vw - rect.width - 6);

-    if (top + rect.height + 6 > vh) top = Math.max(6, vh - rect.height - 6);

-    setPos({ left, top, ready: true });

-    const onEsc = (e) => { if (e.key === 'Escape') onClose(); };

-    window.addEventListener('keydown', onEsc);

-    window.addEventListener('scroll', onScroll, true);

-    window.addEventListener('resize', onClose);

-      window.removeEventListener('keydown', onEsc);

-      window.removeEventListener('scroll', onScroll, true);

-      window.removeEventListener('resize', onClose);

-        className="context-menu-backdrop"

-        onContextMenu={(e) => { e.preventDefault(); onClose(); }}

-        style={{ left: pos.left, top: pos.top, visibility: pos.ready ? 'visible' : 'hidden' }}

-        onContextMenu={(e) => e.preventDefault()}

-          if (item.divider) return <div key={'d' + i} className="context-menu-divider" />;

-          if (item.header) return
{item.header}
;

-              className={`context-menu-item ${item.danger ? 'danger' : ''}`}

-              onClick={() => handleItem(item)}

-              {item.icon && <Icon name={item.icon} size={13} className="context-menu-icon" />}

-              {item.shortcut && {item.shortcut}}

window.NAV_TREE = NAV_TREE;
window.NAV_FLAT = NAV_FLAT;
window.Field = Field;
-window.ContextMenu = ContextMenu;
window.GlobalSearch = GlobalSearch;


Now the app.jsx — just plumb the search handlers, no context menu:


// app.jsx — main shell

const ACCENT = '#5B7CFA';

function App() {
  const [route, setRoute] = React.useState('home');
  const [openAsset, setOpenAsset] = React.useState(null);
  const [openProject, setOpenProject] = React.useState(null);
  const [showNewRecorder, setShowNewRecorder] = React.useState(false);
  const [dataReady, setDataReady] = React.useState(false);

  React.useEffect(() => {
    document.documentElement.style.setProperty('--accent', ACCENT);
    document.documentElement.style.setProperty('--accent-soft', hexToRgba(ACCENT, 0.14));
    document.documentElement.style.setProperty('--accent-soft-2', hexToRgba(ACCENT, 0.22));
    document.documentElement.style.setProperty('--accent-text', lighten(ACCENT, 0.25));
    document.documentElement.style.setProperty('--accent-hover', lighten(ACCENT, 0.08));
  }, []);

  React.useEffect(() => {
    window.ZAMPP_API.loadData()
      .then(() => setDataReady(true))
      .catch(err => { console.error('[Dragonflight] load failed:', err); setDataReady(true); });
  }, []);

  const navigate = (id) => { setOpenAsset(null); setRoute(id); };
  const openProjectFromAnywhere = (p) => { setOpenAsset(null); setOpenProject(p); setRoute('library'); };

  const crumbs = React.useMemo(() => {
    if (openAsset) return [
      { label: 'Library', to: 'library' },
      { label: openAsset.project || 'Library', to: 'library' },
      { label: openAsset.name },
    ];
    if (openProject) return [
      { label: 'Projects', to: 'projects' },
      { label: openProject.name },
    ];
    const labels = {
      home: ['Home'], library: ['Library'], projects: ['Projects'],
      upload: ['Ingest', 'Upload'], recorders: ['Ingest', 'Recorders'],
      schedule: ['Ingest', 'Schedule'],
      capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
      jobs: ['Jobs'], editor: ['Editor'],
      users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
      containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
      settings: ['Admin', 'Settings'],
    };
    return (labels[route] || ['Home']).map(label => ({ label }));
  }, [route, openAsset, openProject]);

  if (!dataReady) {
    return (
      <>

Loading Dragonflight…

      </>
    );
  }

  let content;
  if (openAsset) {
    content = <AssetDetail asset={openAsset} onClose={() => setOpenAsset(null)} />;
  } else {
    switch (route) {
      case 'home':       content = <Home navigate={navigate} />; break;
      case 'library':   content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} />; break;
      case 'projects':  content = <Projects navigate={navigate} onOpenProject={openProjectFromAnywhere} />; break;
      case 'upload':    content = <Upload navigate={navigate} />; break;
      case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;
      case 'schedule':  content = <Schedule navigate={navigate} />; break;
      case 'capture':   content = <Capture navigate={navigate} />; break;
      case 'monitors':  content = <Monitors navigate={navigate} />; break;
      case 'jobs':      content = <Jobs navigate={navigate} />; break;
      case 'editor':    content = <Editor />; break;
      case 'users':     content = <Users />; break;
      case 'tokens':    content = <Tokens />; break;
      case 'containers':content = <Containers />; break;
      case 'cluster':   content = <Cluster />; break;
      case 'settings':  content = <Settings />; break;
      default:          content = <Home navigate={navigate} />;
    }
  }

  return (

      <Sidebar active={openAsset ? 'library' : route} onNavigate={navigate} />

        {!openAsset && (
          <Topbar
            crumbs={crumbs}
            onNavigate={navigate}
            onOpenAsset={setOpenAsset}
            onOpenProject={openProjectFromAnywhere}
          />
        )}
        {content}

      {showNewRecorder && <NewRecorderModal open={showNewRecorder} onClose={() => setShowNewRecorder(false)} />}

  );
}

function hexToRgba(hex, a) {
  const h = hex.replace('#', '');
  const r = parseInt(h.slice(0, 2), 16);
  const g = parseInt(h.slice(2, 4), 16);
  const b = parseInt(h.slice(4, 6), 16);
  return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
}
function lighten(hex, amt) {
  const h = hex.replace('#', '');
  const r = Math.min(255, parseInt(h.slice(0, 2), 16) + Math.round(amt * 255));
  const g = Math.min(255, parseInt(h.slice(2, 4), 16) + Math.round(amt * 255));
  const b = Math.min(255, parseInt(h.slice(4, 6), 16) + Math.round(amt * 255));
  return 'rgb(' + r + ',' + g + ',' + b + ')';
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);


The file /tmp/app.jsx has been updated successfully. (file state is current in your context — no need to Read it back)

// app.jsx — main shell

const ACCENT = '#5B7CFA';

function App() {
  const [route, setRoute] = React.useState('home');
  const [openAsset, setOpenAsset] = React.useState(null);
  const [openProject, setOpenProject] = React.useState(null);
  const [showNewRecorder, setShowNewRecorder] = React.useState(false);
  const [dataReady, setDataReady] = React.useState(false);

  React.useEffect(() => {
    document.documentElement.style.setProperty('--accent', ACCENT);
    document.documentElement.style.setProperty('--accent-soft', hexToRgba(ACCENT, 0.14));
    document.documentElement.style.setProperty('--accent-soft-2', hexToRgba(ACCENT, 0.22));
    document.documentElement.style.setProperty('--accent-text', lighten(ACCENT, 0.25));
    document.documentElement.style.setProperty('--accent-hover', lighten(ACCENT, 0.08));
  }, []);

  React.useEffect(() => {
    window.ZAMPP_API.loadData()
      .then(() => setDataReady(true))
      .catch(err => { console.error('[Dragonflight] load failed:', err); setDataReady(true); });
  }, []);

  const navigate = (id) => { setOpenAsset(null); setRoute(id); };
  const openProjectFromAnywhere = (p) => { setOpenAsset(null); setOpenProject(p); setRoute('library'); };

  const crumbs = React.useMemo(() => {
    if (openAsset) return [
      { label: 'Library', to: 'library' },
      { label: openAsset.project || 'Library', to: 'library' },
      { label: openAsset.name },
    ];
    if (openProject) return [
      { label: 'Projects', to: 'projects' },
      { label: openProject.name },
    ];
    const labels = {
      home: ['Home'], library: ['Library'], projects: ['Projects'],
      upload: ['Ingest', 'Upload'], recorders: ['Ingest', 'Recorders'],
      schedule: ['Ingest', 'Schedule'],
      capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
      jobs: ['Jobs'], editor: ['Editor'],
      users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
      containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
      settings: ['Admin', 'Settings'],
    };
    return (labels[route] || ['Home']).map(label => ({ label }));
  }, [route, openAsset, openProject]);

  if (!dataReady) {
    return (
      <>

Loading Dragonflight…

      </>
    );
  }

  let content;
  if (openAsset) {
    content = <AssetDetail asset={openAsset} onClose={() => setOpenAsset(null)} />;
  } else {
    switch (route) {
      case 'home':       content = <Home navigate={navigate} />; break;
      case 'library':   content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} />; break;
      case 'projects':  content = <Projects navigate={navigate} onOpenProject={openProjectFromAnywhere} />; break;
      case 'upload':    content = <Upload navigate={navigate} />; break;
      case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;
      case 'schedule':  content = <Schedule navigate={navigate} />; break;
      case 'capture':   content = <Capture navigate={navigate} />; break;
      case 'monitors':  content = <Monitors navigate={navigate} />; break;
      case 'jobs':      content = <Jobs navigate={navigate} />; break;
      case 'editor':    content = <Editor />; break;
      case 'users':     content = <Users />; break;
      case 'tokens':    content = <Tokens />; break;
      case 'containers':content = <Containers />; break;
      case 'cluster':   content = <Cluster />; break;
      case 'settings':  content = <Settings />; break;
      default:          content = <Home navigate={navigate} />;
    }
  }

  return (

      <Sidebar active={openAsset ? 'library' : route} onNavigate={navigate} />

        {!openAsset && (
          <Topbar
            crumbs={crumbs}
            onNavigate={navigate}
            onOpenAsset={setOpenAsset}
            onOpenProject={openProjectFromAnywhere}
          />
        )}
        {content}

      {showNewRecorder && <NewRecorderModal open={showNewRecorder} onClose={() => setShowNewRecorder(false)} />}

  );
}

function hexToRgba(hex, a) {
  const h = hex.replace('#', '');
  const r = parseInt(h.slice(0, 2), 16);
  const g = parseInt(h.slice(2, 4), 16);
  const b = parseInt(h.slice(4, 6), 16);
  return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
}
function lighten(hex, amt) {
  const h = hex.replace('#', '');
  const r = Math.min(255, parseInt(h.slice(0, 2), 16) + Math.round(amt * 255));
  const g = Math.min(255, parseInt(h.slice(2, 4), 16) + Math.round(amt * 255));
  const b = Math.min(255, parseInt(h.slice(4, 6), 16) + Math.round(amt * 255));
  return 'rgb(' + r + ',' + g + ',' + b + ')';
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

-  const [contextMenu, setContextMenu] = React.useState(null);

-    window.openContextMenu = (x, y, items) => {

-      if (!items || items.length === 0) return;

-    window.closeContextMenu = () => setContextMenu(null);

-    return () => { delete window.openContextMenu; delete window.closeContextMenu; };

-      <ContextMenu menu={contextMenu} onClose={() => setContextMenu(null)} />

// app.jsx — main shell

const ACCENT = '#5B7CFA';

function App() {
  const [route, setRoute] = React.useState('home');
  const [openAsset, setOpenAsset] = React.useState(null);
  const [openProject, setOpenProject] = React.useState(null);
  const [showNewRecorder, setShowNewRecorder] = React.useState(false);
  const [dataReady, setDataReady] = React.useState(false);
  const [contextMenu, setContextMenu] = React.useState(null);

  React.useEffect(() => {
    document.documentElement.style.setProperty('--accent', ACCENT);
    document.documentElement.style.setProperty('--accent-soft', hexToRgba(ACCENT, 0.14));
    document.documentElement.style.setProperty('--accent-soft-2', hexToRgba(ACCENT, 0.22));
    document.documentElement.style.setProperty('--accent-text', lighten(ACCENT, 0.25));
    document.documentElement.style.setProperty('--accent-hover', lighten(ACCENT, 0.08));
  }, []);

  React.useEffect(() => {
    window.ZAMPP_API.loadData()
      .then(() => setDataReady(true))
      .catch(err => { console.error('[Dragonflight] load failed:', err); setDataReady(true); });
  }, []);

  React.useEffect(() => {
    window.openContextMenu = (x, y, items) => {
      if (!items || items.length === 0) return;
      setContextMenu({ x, y, items });
    };
    window.closeContextMenu = () => setContextMenu(null);
    return () => { delete window.openContextMenu; delete window.closeContextMenu; };
  }, []);

  const navigate = (id) => { setOpenAsset(null); setRoute(id); };
  const openProjectFromAnywhere = (p) => { setOpenAsset(null); setOpenProject(p); setRoute('library'); };

  const crumbs = React.useMemo(() => {
    if (openAsset) return [
      { label: 'Library', to: 'library' },
      { label: openAsset.project || 'Library', to: 'library' },
      { label: openAsset.name },
    ];
    if (openProject) return [
      { label: 'Projects', to: 'projects' },
      { label: openProject.name },
    ];
    const labels = {
      home: ['Home'], library: ['Library'], projects: ['Projects'],
      upload: ['Ingest', 'Upload'], recorders: ['Ingest', 'Recorders'],
      schedule: ['Ingest', 'Schedule'],
      capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
      jobs: ['Jobs'], editor: ['Editor'],
      users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
      containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
      settings: ['Admin', 'Settings'],
    };
    return (labels[route] || ['Home']).map(label => ({ label }));
  }, [route, openAsset, openProject]);

  if (!dataReady) {
    return (
      <>

Loading Dragonflight…

      </>
    );
  }

  let content;
  if (openAsset) {
    content = <AssetDetail asset={openAsset} onClose={() => setOpenAsset(null)} />;
  } else {
    switch (route) {
      case 'home':       content = <Home navigate={navigate} />; break;
      case 'library':   content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} />; break;
      case 'projects':  content = <Projects navigate={navigate} onOpenProject={openProjectFromAnywhere} />; break;
      case 'upload':    content = <Upload navigate={navigate} />; break;
      case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;
      case 'schedule':  content = <Schedule navigate={navigate} />; break;
      case 'capture':   content = <Capture navigate={navigate} />; break;
      case 'monitors':  content = <Monitors navigate={navigate} />; break;
      case 'jobs':      content = <Jobs navigate={navigate} />; break;
      case 'editor':    content = <Editor />; break;
      case 'users':     content = <Users />; break;
      case 'tokens':    content = <Tokens />; break;
      case 'containers':content = <Containers />; break;
      case 'cluster':   content = <Cluster />; break;
      case 'settings':  content = <Settings />; break;
      default:          content = <Home navigate={navigate} />;
    }
  }

  return (

      <Sidebar active={openAsset ? 'library' : route} onNavigate={navigate} />

        {!openAsset && (
          <Topbar
            crumbs={crumbs}
            onNavigate={navigate}
            onOpenAsset={setOpenAsset}
            onOpenProject={openProjectFromAnywhere}
          />
        )}
        {content}

      {showNewRecorder && <NewRecorderModal open={showNewRecorder} onClose={() => setShowNewRecorder(false)} />}
      <ContextMenu menu={contextMenu} onClose={() => setContextMenu(null)} />

  );
}

function hexToRgba(hex, a) {
  const h = hex.replace('#', '');
  const r = parseInt(h.slice(0, 2), 16);
  const g = parseInt(h.slice(2, 4), 16);
  const b = parseInt(h.slice(4, 6), 16);
  return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
}
function lighten(hex, amt) {
  const h = hex.replace('#', '');
  const r = Math.min(255, parseInt(h.slice(0, 2), 16) + Math.round(amt * 255));
  const g = Math.min(255, parseInt(h.slice(2, 4), 16) + Math.round(amt * 255));
  const b = Math.min(255, parseInt(h.slice(4, 6), 16) + Math.round(amt * 255));
  return 'rgb(' + r + ',' + g + ',' + b + ')';
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);


/* ========== Global Search ========== */
.search-wrap {
  position: relative;
  flex-shrink: 0;
}
.search-wrap .search {
  width: 280px;
}
.search-wrap .search.is-open {
  border-color: var(--accent);
  background: var(--bg-2);
}
.search-results {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  width: 440px;
  max-width: 90vw;
  max-height: 460px;
  overflow-y: auto;
  background: var(--bg-1);
  border: 1px solid var(--border-stronger);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-pop);
  padding: 4px;
  z-index: 60;
}
.search-empty {
  padding: 18px 12px;
  text-align: center;
  font-size: 12px;
  color: var(--text-3);
}
.search-result {
  display: grid;
  grid-template-columns: 24px 1fr auto;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  border-radius: var(--r-sm);
  cursor: pointer;
  color: var(--text-1);
}
.search-result.active {
  background: var(--accent-soft);
}
.search-result-icon {
  width: 24px; height: 24px;
  border-radius: 5px;
  background: var(--bg-3);
  color: var(--text-2);
  display: grid; place-items: center;
  flex-shrink: 0;
}
.search-result.active .search-result-icon {
  background: var(--accent-soft-2);
  color: var(--accent-text);
}
.search-result-text {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 1px;
}
.search-result-label {
  font-size: 12.5px;
  font-weight: 500;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.search-result-sub {
  font-size: 11px;
  color: var(--text-3);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.search-result-kind {
  font-family: var(--font-mono);
  font-size: 9.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-4);
  background: var(--bg-2);
  border: 1px solid var(--border);
  padding: 2px 6px;
  border-radius: 99px;
  flex-shrink: 0;
}
.search-result-kind.kind-asset { color: #B4C3FF; border-color: rgba(91,124,250,0.25); }
.search-result-kind.kind-project { color: #FFD89B; border-color: rgba(245,166,35,0.25); }
.search-result-kind.kind-recorder { color: #FFAFAF; border-color: rgba(255,91,91,0.25); }
.search-result-kind.kind-job { color: #9EE7D2; border-color: rgba(45,212,168,0.25); }
.search-result-kind.kind-user { color: #D5B8FF; border-color: rgba(181,124,250,0.25); }
.search-result-kind.kind-nav { color: var(--text-2); }


File created successfully at: /tmp/styles-rest.css.append (file state is current in your context — no need to Read it back)

/* ========== Global Search ========== */
.search-wrap {
  position: relative;
  flex-shrink: 0;
}
.search-wrap .search {
  width: 280px;
}
.search-wrap .search.is-open {
  border-color: var(--accent);
  background: var(--bg-2);
}
.search-results {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  width: 440px;
  max-width: 90vw;
  max-height: 460px;
  overflow-y: auto;
  background: var(--bg-1);
  border: 1px solid var(--border-stronger);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-pop);
  padding: 4px;
  z-index: 60;
}
.search-empty {
  padding: 18px 12px;
  text-align: center;
  font-size: 12px;
  color: var(--text-3);
}
.search-result {
  display: grid;
  grid-template-columns: 24px 1fr auto;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  border-radius: var(--r-sm);
  cursor: pointer;
  color: var(--text-1);
}
.search-result.active {
  background: var(--accent-soft);
}
.search-result-icon {
  width: 24px; height: 24px;
  border-radius: 5px;
  background: var(--bg-3);
  color: var(--text-2);
  display: grid; place-items: center;
  flex-shrink: 0;
}
.search-result.active .search-result-icon {
  background: var(--accent-soft-2);
  color: var(--accent-text);
}
.search-result-text {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 1px;
}
.search-result-label {
  font-size: 12.5px;
  font-weight: 500;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.search-result-sub {
  font-size: 11px;
  color: var(--text-3);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.search-result-kind {
  font-family: var(--font-mono);
  font-size: 9.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-4);
  background: var(--bg-2);
  border: 1px solid var(--border);
  padding: 2px 6px;
  border-radius: 99px;
  flex-shrink: 0;
}
.search-result-kind.kind-asset { color: #B4C3FF; border-color: rgba(91,124,250,0.25); }
.search-result-kind.kind-project { color: #FFD89B; border-color: rgba(245,166,35,0.25); }
.search-result-kind.kind-recorder { color: #FFAFAF; border-color: rgba(255,91,91,0.25); }
.search-result-kind.kind-job { color: #9EE7D2; border-color: rgba(45,212,168,0.25); }
.search-result-kind.kind-user { color: #D5B8FF; border-color: rgba(181,124,250,0.25); }
.search-result-kind.kind-nav { color: var(--text-2); }


I need to assemble the full new styles-rest.css and a small fix to styles-fixes.css. Let me read all the locally-prepared pieces and the existing styles-rest.css together.


1	// shell.jsx - app shell: sidebar nav, topbar, route container


Bash

wc -l /tmp/shell.jsx /tmp/app.jsx /tmp/styles-rest.css.append

Show line counts of prepared files


  356 /tmp/shell.jsx
  123 /tmp/app.jsx
   99 /tmp/styles-rest.css.append
  578 total


1	// shell.jsx - app shell: sidebar nav, topbar, route container
2
3	const NAV_TREE = [
4	  { id: "home", label: "Home", icon: "home" },
5	  { id: "library", label: "Library", icon: "library" },
6	  { id: "projects", label: "Projects", icon: "folder" },
7	  {
8	    id: "ingest", label: "Ingest", icon: "upload", group: true,
9	    children: [
10	      { id: "upload", label: "Upload", icon: "upload" },
11	      { id: "recorders", label: "Recorders", icon: "record" },
12	      { id: "schedule", label: "Schedule", icon: "jobs" },
13	      { id: "capture", label: "Capture", icon: "capture" },
14	      { id: "monitors", label: "Monitors", icon: "monitor" },
15	    ],
16	  },
17	  { id: "jobs", label: "Jobs", icon: "jobs", badge: { kind: "neutral", text: "3" } },
18	  { id: "editor", label: "Editor", icon: "editor", badge: { kind: "dev", text: "DEV" } },
19	];
20
21	const ADMIN_TREE = [
22	  { id: "users", label: "Users", icon: "users" },
23	  { id: "tokens", label: "Tokens", icon: "token" },
24	  { id: "containers", label: "Containers", icon: "container" },
25	  { id: "cluster", label: "Cluster", icon: "cluster" },
26	  { id: "settings", label: "Settings", icon: "settings" },
27	];
28
29	const NAV_FLAT = (() => {
30	  const out = [];
31	  const visit = (arr) => arr.forEach(n => {
32	    if (n.group && n.children) { visit(n.children); return; }
33	    out.push({ id: n.id, label: n.label, icon: n.icon });
34	  });
35	  visit(NAV_TREE); visit(ADMIN_TREE);
36	  return out;
37	})();
38
39	function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup }) {
40	  const isGroup = item.group && item.children;
41	  const isOpen = isGroup && openGroups.has(item.id);
42	  const isActive = active === item.id || (isGroup && item.children.some(c => c.id === active));
43
44	  return (
45	    <>
46	      <div
47	        className={`nav-item ${isActive && !isGroup ? "active" : ""} ${isGroup ? "has-children" : ""} ${isOpen ? "open" : ""}`}
48	        onClick={() => {
49	          if (isGroup) toggleGroup(item.id);
50	          else onSelect(item.id);
51	        }}
52	      >
53	        <Icon name={item.icon} size={15} className="nav-icon" />
54	        {item.label}
55	        {item.badge && (
56	          <span className={`nav-badge ${item.badge.kind}`}>{item.badge.text}
57	        )}
58	        {isGroup && <Icon name="chevron" size={12} className="nav-caret" />}
59

60	      {isGroup && isOpen && (
61

62	          {item.children.map(c => (
63	            <div
64	              key={c.id}
65	              className={`nav-item ${active === c.id ? "active" : ""}`}
66	              onClick={() => onSelect(c.id)}
67	            >
68	              <Icon name={c.icon} size={14} className="nav-icon" />
69	              {c.label}
70	              {c.badge && <span className={`nav-badge ${c.badge.kind}`}>{c.badge.text}}
71

72	          ))}
73

74	      )}
75	    </>
76	  );
77	}
78
79	function Sidebar({ active, onNavigate }) {
80	  const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"]));
81	  const toggleGroup = (id) => {
82	    setOpenGroups(prev => {
83	      const next = new Set(prev);
84	      if (next.has(id)) next.delete(id);
85	      else next.add(id);
86	      return next;
87	    });
88	  };
89
90	  React.useEffect(() => {
91	    const ingestChildren = ["upload", "recorders", "schedule", "capture", "monitors"];
92	    if (ingestChildren.includes(active)) {
93	      setOpenGroups(prev => prev.has("ingest") ? prev : new Set([...prev, "ingest"]));
94	    }
95	  }, [active]);
96
97	  return (
98
99

100
D

101

102
Dragonflight

103

104
v1.2.0

105

106

107	        {NAV_TREE.map(item => (
108	          <NavItem
109	            key={item.id}
110	            item={item}
111	            active={active}
112	            onSelect={onNavigate}
113	            openGroups={openGroups}
114	            toggleGroup={toggleGroup}
115	          />
116	        ))}
117
Admin

118	        {ADMIN_TREE.map(item => (
119	          <NavItem
120	            key={item.id}
121	            item={item}
122	            active={active}
123	            onSelect={onNavigate}
124	            openGroups={openGroups}
125	            toggleGroup={toggleGroup}
126	          />
127	        ))}
128

129

130
ZG

131

132
Zach Gaetano

133
admin · broadcast

134

135	        <button className="icon-btn" data-tip="Sign out"><Icon name="power" /></button>
136

137
138	  );
139	}
140
141	function assetSearchIcon(a) {
142	  const t = (a.type || a.media_type || '').toLowerCase();
143	  if (t.includes('audio')) return 'audio';
144	  if (t.includes('image')) return 'image';
145	  return 'film';
146	}
147
148	function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) {
149	  const [q, setQ] = React.useState('');
150	  const [open, setOpen] = React.useState(false);
151	  const [sel, setSel] = React.useState(0);
152	  const inputRef = React.useRef(null);
153	  const listRef = React.useRef(null);
154	  const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
155
156	  React.useEffect(() => {
157	    const handler = (e) => {
158	      const key = e.key && e.key.toLowerCase();
159	      if ((e.metaKey || e.ctrlKey) && key === 'k') {
160	        e.preventDefault();
161	        if (inputRef.current) {
162	          inputRef.current.focus();
163	          inputRef.current.select();
164	          setOpen(true);
165	        }
166	      }
167	    };
168	    window.addEventListener('keydown', handler);
169	    return () => window.removeEventListener('keydown', handler);
170	  }, []);
171
172	  const results = React.useMemo(() => {
173	    const term = q.trim().toLowerCase();
174	    if (!term) return [];
175	    const D = window.ZAMPP_DATA || {};
176	    const out = [];
177
178	    (D.ASSETS || []).forEach(a => {
179	      const hay = ((a.name || '') + ' ' + (a.project || '') + ' ' + (a.filename || '')).toLowerCase();
180	      if (hay.includes(term)) {
181	        const sub = [a.project, a.res, a.duration].filter(x => x && x !== '—').join(' · ');
182	        out.push({ kind: 'asset', icon: assetSearchIcon(a), label: a.name, sub, item: a });
183	      }
184	    });
185	    (D.PROJECTS || []).forEach(p => {
186	      if ((p.name || '').toLowerCase().includes(term)) {
187	        out.push({ kind: 'project', icon: 'folder', label: p.name, sub: (p.assets || 0) + ' assets', item: p, color: p.color });
188	      }
189	    });
190	    (D.RECORDERS || []).forEach(r => {
191	      const hay = ((r.name || '') + ' ' + (r.url || '') + ' ' + (r.source || '')).toLowerCase();
192	      if (hay.includes(term)) {
193	        out.push({ kind: 'recorder', icon: 'record', label: r.name || r.url || 'Recorder', sub: [r.status, r.source].filter(Boolean).join(' · '), item: r });
194	      }
195	    });
196	    (D.JOBS || []).forEach(j => {
197	      const hay = ((j.asset || '') + ' ' + (j.kind || '') + ' ' + (j.status || '')).toLowerCase();
198	      if (hay.includes(term)) {
199	        out.push({ kind: 'job', icon: 'jobs', label: j.asset || j.kind || 'Job', sub: [j.kind, j.status].filter(Boolean).join(' · '), item: j });
200	      }
201	    });
202	    (D.USERS || []).forEach(u => {
203	      const hay = ((u.name || '') + ' ' + (u.email || '') + ' ' + (u.username || '')).toLowerCase();
204	      if (hay.includes(term)) {
205	        out.push({ kind: 'user', icon: 'users', label: u.name, sub: u.email || u.role, item: u });
206	      }
207	    });
208	    NAV_FLAT.forEach(n => {
209	      if (n.label.toLowerCase().includes(term)) {
210	        out.push({ kind: 'nav', icon: n.icon, label: n.label, sub: 'Go to ' + n.label, target: n.id });
211	      }
212	    });
213
214	    return out.slice(0, 40);
215	  }, [q, open]);
216
217	  React.useEffect(() => { setSel(0); }, [q]);
218
219	  React.useEffect(() => {
220	    if (!listRef.current) return;
221	    const el = listRef.current.querySelector('.search-result.active');
222	    if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
223	  }, [sel]);
224
225	  const handleSelect = (r) => {
226	    if (!r) return;
227	    setOpen(false);
228	    setQ('');
229	    if (inputRef.current) inputRef.current.blur();
230	    if (r.kind === 'asset' && onOpenAsset) onOpenAsset(r.item);
231	    else if (r.kind === 'project' && onOpenProject) onOpenProject(r.item);
232	    else if (r.kind === 'recorder') onNavigate && onNavigate('recorders');
233	    else if (r.kind === 'job') onNavigate && onNavigate('jobs');
234	    else if (r.kind === 'user') onNavigate && onNavigate('users');
235	    else if (r.kind === 'nav') onNavigate && onNavigate(r.target);
236	  };
237
238	  const onKeyDown = (e) => {
239	    if (e.key === 'Escape') {
240	      setOpen(false); setQ('');
241	      if (inputRef.current) inputRef.current.blur();
242	      return;
243	    }
244	    if (!open || results.length === 0) return;
245	    if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(s + 1, results.length - 1)); }
246	    else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(0, s - 1)); }
247	    else if (e.key === 'Enter') { e.preventDefault(); handleSelect(results[sel]); }
248	  };
249
250	  const showDropdown = open && q.trim().length > 0;
251
252	  return (
253

254	      <div className={`search ${showDropdown ? 'is-open' : ''}`}>
255	        <Icon name="search" className="search-icon" />
256	        <input
257	          ref={inputRef}
258	          value={q}
259	          onChange={e => { setQ(e.target.value); setOpen(true); }}
260	          onFocus={() => setOpen(true)}
261	          onBlur={() => setTimeout(() => setOpen(false), 120)}
262	          onKeyDown={onKeyDown}
263	          placeholder="Search assets, projects, recorders…"
264	        />
265	        {isMac ? '⌘K' : 'Ctrl K'}
266

267	      {showDropdown && (
268
 e.preventDefault()}
272	        >
273	          {results.length === 0 ? (
274
No results for “{q}”

275	          ) : results.map((r, i) => (
276	            <div
277	              key={r.kind + '-' + i + '-' + (r.item ? (r.item.id || r.item.name) : (r.target || r.label))}
278	              className={`search-result ${i === sel ? 'active' : ''}`}
279	              onClick={() => handleSelect(r)}
280	              onMouseEnter={() => setSel(i)}
281	            >
282
286	                <Icon name={r.icon} size={14} />
287
288

289
{r.label}

290	                {r.sub &&
{r.sub}
}
291

292	              <span className={`search-result-kind kind-${r.kind}`}>{r.kind}
293

294	          ))}
295

296	      )}
297

298	  );
299	}
300
301	function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject }) {
302	  return (
303
304

305	        {crumbs.map((c, i) => (
306	          <React.Fragment key={i}>
307	            {i > 0 && <Icon name="chevron" size={12} className="sep" />}
308	            <span
309	              className={i === crumbs.length - 1 ? "current" : ""}
310	              style={{ cursor: c.to && i < crumbs.length - 1 ? "pointer" : "default" }}
311	              onClick={() => c.to && onNavigate && onNavigate(c.to)}
312	            >
313	              {c.label}
314
315	          </React.Fragment>
316	        ))}
317

318

319	      <GlobalSearch onNavigate={onNavigate} onOpenAsset={onOpenAsset} onOpenProject={onOpenProject} />
320

321
322	        cluster healthy
323

324	      <button className="icon-btn" data-tip="Notifications">
325	        <Icon name="bell" />
326	      </button>
327	      {right}
328
329	  );
330	}
331
332	// General-purpose read-only form field used by recorder modal and other forms.
333	// Renders as a labeled select (when select=true) or text input.
334	function Field({ label, value, select, children }) {
335	  return (
336

337	      {label}
338	      {children || (select
339	        ? (
340	          <select className="field-input" defaultValue={value} style={{ appearance: 'auto' }}>
341	            <option value={value}>{value}</option>
342	          </select>
343	        ) : (
344	          <input className="field-input" defaultValue={value} readOnly />
345	        )
346	      )}
347

348	  );
349	}
350
351	window.Sidebar = Sidebar;
352	window.Topbar = Topbar;
353	window.NAV_TREE = NAV_TREE;
354	window.NAV_FLAT = NAV_FLAT;
355	window.Field = Field;
356	window.GlobalSearch = GlobalSearch;
357


Now I'll push the changes. I'll do them as four sequential update_file calls since each needs the full content.


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.

// 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 (
    <>
      <div
        className={`nav-item ${isActive && !isGroup ? "active" : ""} ${isGroup ? "has-children" : ""} ${isOpen ? "open" : ""}`}
        onClick={() => {
          if (isGroup) toggleGroup(item.id);
          else onSelect(item.id);
        }}
      >
        <Icon name={item.icon} size={15} className="nav-icon" />
        {item.label}
        {item.badge && (
          <span className={`nav-badge ${item.badge.kind}`}>{item.badge.text}
        )}
        {isGroup && <Icon name="chevron" size={12} className="nav-caret" />}

      {isGroup && isOpen && (

          {item.children.map(c => (
            <div
              key={c.id}
              className={`nav-item ${active === c.id ? "active" : ""}`}
              onClick={() => onSelect(c.id)}
            >
              <Icon name={c.icon} size={14} className="nav-icon" />
              {c.label}
              {c.badge && <span className={`nav-badge ${c.badge.kind}`}>{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 (

D

Dragonflight

v1.2.0

        {NAV_TREE.map(item => (
          <NavItem
            key={item.id}
            item={item}
            active={active}
            onSelect={onNavigate}
            openGroups={openGroups}
            toggleGroup={toggleGroup}
          />
        ))}

Admin

        {ADMIN_TREE.map(item => (
          <NavItem
            key={item.id}
            item={item}
            active={active}
            onSelect={onNavigate}
            openGroups={openGroups}
            toggleGroup={toggleGroup}
          />
        ))}

ZG

Zach Gaetano

admin · broadcast

        <button className="icon-btn" data-tip="Sign out"><Icon name="power" /></button>

  );
}

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 ${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…"
        />
        {isMac ? '⌘K' : 'Ctrl K'}

      {showDropdown && (

 e.preventDefault()}
        >
          {results.length === 0 ? (

No results for “{q}”

          ) : 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)}
            >

                <Icon name={r.icon} size={14} />

{r.label}

                {r.sub &&
{r.sub}
}

              <span className={`search-result-kind kind-${r.kind}`}>{r.kind}

          ))}

      )}

  );
}

function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject }) {
  return (

        {crumbs.map((c, i) => (
          <React.Fragment key={i}>
            {i > 0 && <Icon name="chevron" size={12} className="sep" />}
            <span
              className={i === crumbs.length - 1 ? "current" : ""}
              style={{ cursor: c.to && i < crumbs.length - 1 ? "pointer" : "default" }}
              onClick={() => c.to && onNavigate && onNavigate(c.to)}
            >
              {c.label}

          </React.Fragment>
        ))}

      <GlobalSearch onNavigate={onNavigate} onOpenAsset={onOpenAsset} onOpenProject={onOpenProject} />

        cluster healthy

      <button className="icon-btn" data-tip="Notifications">
        <Icon name="bell" />
      </button>
      {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
        ? (
          <select className="field-input" defaultValue={value} style={{ appearance: 'auto' }}>
            <option value={value}>{value}</option>
          </select>
        ) : (
          <input className="field-input" defaultValue={value} readOnly />
        )
      )}

  );
}

window.Sidebar = Sidebar;
window.Topbar = Topbar;
window.NAV_TREE = NAV_TREE;
window.NAV_FLAT = NAV_FLAT;
window.Field = Field;
window.GlobalSearch = GlobalSearch;


OK | commit:45c0e0f | Updated services/web-ui/public/shell.jsx


Commit changes to forgejo


feat(web-ui): pass search-select handlers from App to Topbar

Wires onOpenAsset and onOpenProject through Topbar so that selecting
an asset/project from the global search opens the asset detail or
navigates to the project view. Adds openProjectFromAnywhere helper.

// app.jsx — main shell

const ACCENT = '#5B7CFA';

function App() {
  const [route, setRoute] = React.useState('home');
  const [openAsset, setOpenAsset] = React.useState(null);
  const [openProject, setOpenProject] = React.useState(null);
  const [showNewRecorder, setShowNewRecorder] = React.useState(false);
  const [dataReady, setDataReady] = React.useState(false);

  React.useEffect(() => {
    document.documentElement.style.setProperty('--accent', ACCENT);
    document.documentElement.style.setProperty('--accent-soft', hexToRgba(ACCENT, 0.14));
    document.documentElement.style.setProperty('--accent-soft-2', hexToRgba(ACCENT, 0.22));
    document.documentElement.style.setProperty('--accent-text', lighten(ACCENT, 0.25));
    document.documentElement.style.setProperty('--accent-hover', lighten(ACCENT, 0.08));
  }, []);

  React.useEffect(() => {
    window.ZAMPP_API.loadData()
      .then(() => setDataReady(true))
      .catch(err => { console.error('[Dragonflight] load failed:', err); setDataReady(true); });
  }, []);

  const navigate = (id) => { setOpenAsset(null); setRoute(id); };
  const openProjectFromAnywhere = (p) => { setOpenAsset(null); setOpenProject(p); setRoute('library'); };

  const crumbs = React.useMemo(() => {
    if (openAsset) return [
      { label: 'Library', to: 'library' },
      { label: openAsset.project || 'Library', to: 'library' },
      { label: openAsset.name },
    ];
    if (openProject) return [
      { label: 'Projects', to: 'projects' },
      { label: openProject.name },
    ];
    const labels = {
      home: ['Home'], library: ['Library'], projects: ['Projects'],
      upload: ['Ingest', 'Upload'], recorders: ['Ingest', 'Recorders'],
      schedule: ['Ingest', 'Schedule'],
      capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
      jobs: ['Jobs'], editor: ['Editor'],
      users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
      containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
      settings: ['Admin', 'Settings'],
    };
    return (labels[route] || ['Home']).map(label => ({ label }));
  }, [route, openAsset, openProject]);

  if (!dataReady) {
    return (
      <>

Loading Dragonflight…

      </>
    );
  }

  let content;
  if (openAsset) {
    content = <AssetDetail asset={openAsset} onClose={() => setOpenAsset(null)} />;
  } else {
    switch (route) {
      case 'home':       content = <Home navigate={navigate} />; break;
      case 'library':   content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} />; break;
      case 'projects':  content = <Projects navigate={navigate} onOpenProject={openProjectFromAnywhere} />; break;
      case 'upload':    content = <Upload navigate={navigate} />; break;
      case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;
      case 'schedule':  content = <Schedule navigate={navigate} />; break;
      case 'capture':   content = <Capture navigate={navigate} />; break;
      case 'monitors':  content = <Monitors navigate={navigate} />; break;
      case 'jobs':      content = <Jobs navigate={navigate} />; break;
      case 'editor':    content = <Editor />; break;
      case 'users':     content = <Users />; break;
      case 'tokens':    content = <Tokens />; break;
      case 'containers':content = <Containers />; break;
      case 'cluster':   content = <Cluster />; break;
      case 'settings':  content = <Settings />; break;
      default:          content = <Home navigate={navigate} />;
    }
  }

  return (

      <Sidebar active={openAsset ? 'library' : route} onNavigate={navigate} />

        {!openAsset && (
          <Topbar
            crumbs={crumbs}
            onNavigate={navigate}
            onOpenAsset={setOpenAsset}
            onOpenProject={openProjectFromAnywhere}
          />
        )}
        {content}

      {showNewRecorder && <NewRecorderModal open={showNewRecorder} onClose={() => setShowNewRecorder(false)} />}

  );
}

function hexToRgba(hex, a) {
  const h = hex.replace('#', '');
  const r = parseInt(h.slice(0, 2), 16);
  const g = parseInt(h.slice(2, 4), 16);
  const b = parseInt(h.slice(4, 6), 16);
  return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
}
function lighten(hex, amt) {
  const h = hex.replace('#', '');
  const r = Math.min(255, parseInt(h.slice(0, 2), 16) + Math.round(amt * 255));
  const g = Math.min(255, parseInt(h.slice(2, 4), 16) + Math.round(amt * 255));
  const b = Math.min(255, parseInt(h.slice(4, 6), 16) + Math.round(amt * 255));
  return 'rgb(' + r + ',' + g + ',' + b + ')';
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);


OK | commit:61d02d5 | Updated services/web-ui/public/app.jsx


Now the CSS file — appending search styles to the existing styles-rest.css: