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 open windows with additional options - lets start to wire that up.


## claude.ai Context7
Use this server to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service -- even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer -- your training data may not reflect recent changes. Prefer this over web search for library docs.

Do not use for: refactoring, writing scripts from scratch, debugging business logic, code review, or general programming concepts.


- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- simplify: Review changed code for reuse, quality, and efficiency, then fix any issues found.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled remote agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring remote agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. Also use when the user wants a one-time scheduled run ("run this once at 3pm", "remind me to check X tomorrow").
- claude-api: Build, debug, and optimize Claude API / Anthropic SDK apps. Apps built with this skill should include prompt caching. Also handles migrating existing Claude API code between Claude model versions (4.5 → 4.6, 4.6 → 4.7, retired-model replacements).
TRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks for the Claude API, Anthropic SDK, or Managed Agents; user adds/modifies/tunes a Claude feature (caching, thinking, compaction, tool use, batch, files, citations, memory) or model (Opus/Sonnet/Haiku) in a file; questions about prompt caching / cache hit rate in an Anthropic SDK project.
SKIP: file imports `openai`/other-provider SDK, filename like `*-openai.py`/`*-generic.py`, provider-neutral code, general programming/ML.
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a pull request
- security-review: Complete a security review of the pending changes on the current branch


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


📁 deploy
📁 docs
📁 services
📄 .env.example (577B)
📄 .gitignore (490B)
📄 README.md (1773B)
📄 docker-compose.gpu.yml (1115B)
📄 docker-compose.worker.yml (4145B)
📄 docker-compose.yml (3559B)
📄 setup-repo.sh (1092B)


📁 capture
📁 editor
📁 mam-api
📁 node-agent
📁 premiere-plugin
📁 web-ui
📁 worker


📁 public
📁 src
📄 .dockerignore (63B)
📄 .gitignore (27B)
📄 Dockerfile (687B)
📄 nginx.conf (3800B)
📄 package.json (536B)
📄 postcss.config.js (197B)
📄 tailwind.config.js (3043B)


📁 css
📁 fonts
📁 img
📁 js
📄 _primitives-smoke.html (12459B)
📄 api-tokens.html (15415B)
📄 app.jsx (5191B)
📄 capture.html (21359B)
📄 cluster.html (15141B)
📄 containers.html (12265B)
📄 data.jsx (5534B)
📄 edit.html (16742B)
📄 editor.html (8949B)
📄 favicon.ico (4492B)
📄 home.html (29349B)
📄 icons.jsx (8056B)
📄 index.html (2313B)
📄 jobs.html (33931B)
📄 login.html (10369B)
📄 modal-new-recorder.jsx (17801B)
📄 player.html (17746B)
📄 projects.html (25313B)
📄 recorders.html (70413B)
📄 screens-admin.jsx (75133B)
📄 screens-asset.jsx (19931B)
📄 screens-editor.jsx (7361B)
📄 screens-home.jsx (10677B)
📄 screens-ingest.jsx (35796B)
📄 screens-jobs.jsx (7132B)
📄 screens-library.jsx (9583B)
📄 screens-projects.jsx (6626B)
📄 settings.html (33212B)
📄 shell.jsx (6239B)
📄 styles-asset.css (9140B)
📄 styles-fixes.css (2125B)
📄 styles-modal.css (4529B)
📄 styles-rest.css (21794B)
📄 styles-screens.css (11352B)
📄 styles.css (12440B)
📄 tokens.html (37818B)
📄 tweaks-panel.jsx (18370B)
📄 upload.html (20736B)
📄 users.html (28557B)
📄 visuals.jsx (5519B)


File: services/web-ui/public/shell.jsx

// 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" },
];

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 Topbar({ crumbs, onNavigate, right }) {
  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>
        ))}

        <Icon name="search" className="search-icon" />
        <input placeholder="Search assets, projects, comments…" />
        ⌘K

        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.Field = Field;


File: services/web-ui/public/app.jsx

// 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 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={(p) => { setOpenProject(p); setRoute('library'); }} />; 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} />}
        {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 />);


File: services/web-ui/public/data.jsx

// data.jsx — API client; populates window.ZAMPP_DATA from real endpoints

const API = '/api/v1';

window.ZAMPP_DATA = {
  PROJECTS: [],
  ASSETS: [],
  RECORDERS: [],
  JOBS: [],
  NODES: [],
  USERS: [],
  BINS: [],
  COMMENTS: [],
};

async function apiFetch(path, opts = {}) {
  const res = await fetch(API + path, {
    credentials: 'include',
    headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
    ...opts,
  });
  if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
  return res.json();
}

function fmtDuration(ms) {
  if (!ms) return '—';
  const s = Math.round(ms / 1000);
  const h = Math.floor(s / 3600);
  const m = Math.floor((s % 3600) / 60);
  const sec = s % 60;
  if (h > 0) return h + ':' + String(m).padStart(2, '0') + ':' + String(sec).padStart(2, '0');
  return m + ':' + String(sec).padStart(2, '0');
}

function fmtSize(bytes) {
  if (!bytes) return '—';
  if (bytes >= 1e12) return (bytes / 1e12).toFixed(1) + ' TB';
  if (bytes >= 1e9) return (bytes / 1e9).toFixed(1) + ' GB';
  if (bytes >= 1e6) return Math.round(bytes / 1e6) + ' MB';
  return Math.round(bytes / 1e3) + ' KB';
}

function fmtRelative(iso) {
  if (!iso) return '—';
  const diff = (Date.now() - new Date(iso)) / 1000;
  if (diff < 60) return 'just now';
  if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
  if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
  return Math.floor(diff / 86400) + 'd ago';
}

const PROJECT_COLORS = ['#5B7CFA', '#2DD4A8', '#FF5B5B', '#F5A623', '#B57CFA', '#6B7280'];

function normalizeAsset(a, projectMap) {
  return {
    ...a,
    name: a.display_name || a.filename || 'Untitled',
    type: a.media_type || 'video',
    duration: fmtDuration(a.duration_ms),
    size: fmtSize(a.file_size),
    res: a.resolution || '—',
    updated: fmtRelative(a.updated_at),
    project: (projectMap && projectMap[a.project_id]) || '',
    comments: 0,
    progress: 0,
    tc: a.start_tc || null,
    seed: a.id ? a.id.charCodeAt(0) % 6 : 1,
  };
}

function normalizeRecorder(r) {
  let elapsed = '—';
  if (r.status === 'recording' && r.started_at) {
    const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
    elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
              String(Math.floor((s % 3600) / 60)).padStart(2, '0') + ':' +
              String(s % 60).padStart(2, '0');
  }
  const cfg = r.source_config || {};
  return {
    ...r,
    source: r.source_type || '—',
    url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '—',
    codec: r.recording_codec || '—',
    res: r.recording_resolution || '—',
    node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
    elapsed,
    bitrate: '—',
    health: 100,
    audio: false,
  };
}

function normalizeJob(j) {
  const statusMap = { waiting: 'queued', active: 'running', completed: 'done', failed: 'failed' };
  const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode' };
  const meta = j.metadata || {};
  return {
    ...j,
    status: statusMap[j.status] || j.status,
    kind: kindMap[j.type] || j.type || 'Job',
    asset: j.asset_name || meta.filename || '—',
    eta: '—',
    node: meta.node || '—',
    priority: meta.priority || 'normal',
    error: j.error || null,
    progress: j.progress || 0,
  };
}

async function loadData() {
  const [projectsR, assetsR, recordersR, jobsR, clusterR, usersR, binsR] = await Promise.allSettled([
    apiFetch('/projects'),
    apiFetch('/assets?limit=500'),
    apiFetch('/recorders'),
    apiFetch('/jobs'),
    apiFetch('/cluster'),
    apiFetch('/users'),
    apiFetch('/bins'),
  ]);

  const projectMap = {};
  if (projectsR.status === 'fulfilled') {
    window.ZAMPP_DATA.PROJECTS = (projectsR.value || []).map((p, i) => ({
      ...p,
      color: PROJECT_COLORS[i % PROJECT_COLORS.length],
      assets: 0,
      updated: fmtRelative(p.updated_at),
    }));
    window.ZAMPP_DATA.PROJECTS.forEach(p => { projectMap[p.id] = p.name; });
  }

  if (assetsR.status === 'fulfilled') {
    const raw = assetsR.value;
    const list = Array.isArray(raw) ? raw : (raw.assets || []);
    window.ZAMPP_DATA.ASSETS = list.map(a => normalizeAsset(a, projectMap));
    const counts = {};
    list.forEach(a => { if (a.project_id) counts[a.project_id] = (counts[a.project_id] || 0) + 1; });
    window.ZAMPP_DATA.PROJECTS = window.ZAMPP_DATA.PROJECTS.map(p => ({ ...p, assets: counts[p.id] || 0 }));
  }

  if (recordersR.status === 'fulfilled') {
    window.ZAMPP_DATA.RECORDERS = (recordersR.value || []).map(normalizeRecorder);
  }

  if (jobsR.status === 'fulfilled') {
    window.ZAMPP_DATA.JOBS = (jobsR.value || []).map(normalizeJob);
  }

  if (clusterR.status === 'fulfilled') {
    window.ZAMPP_DATA.NODES = clusterR.value || [];
  }

  if (usersR.status === 'fulfilled') {
    window.ZAMPP_DATA.USERS = (usersR.value || []).map(u => ({
      ...u,
      name: u.display_name || u.username || u.email || 'Unknown',
      initials: (u.display_name || u.username || '?').slice(0, 2).toUpperCase(),
      role: u.role || 'viewer',
      joined: fmtRelative(u.created_at),
      lastSeen: fmtRelative(u.last_seen || u.updated_at),
    }));
  }

  if (binsR.status === 'fulfilled') {
    window.ZAMPP_DATA.BINS = (binsR.value || []).map(b => ({
      ...b,
      count: b.asset_count || 0,
      icon: b.type || 'grid',
    }));
  }
}

window.ZAMPP_API = { fetch: apiFetch, loadData, fmtDuration, fmtSize, fmtRelative };


File: services/web-ui/public/index.html

<!doctype html>

<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dragonflight · Wild Dragon Broadcast</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" />
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="styles-screens.css" />
<link rel="stylesheet" href="styles-asset.css" />
<link rel="stylesheet" href="styles-rest.css" />
<link rel="stylesheet" href="styles-modal.css" />
<link rel="stylesheet" href="styles-fixes.css" />


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 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


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


File: services/web-ui/public/screens-library.jsx

// screens-library.jsx

function Library({ navigate, onOpenAsset, openProject }) {
  const { ASSETS: ALL_ASSETS, BINS, PROJECTS } = window.ZAMPP_DATA;
  const [view, setView] = React.useState('grid');
  const [filter, setFilter] = React.useState('all');
  const [search, setSearch] = React.useState('');

  let assets = openProject
    ? ALL_ASSETS.filter(function(a) { return a.project_id === openProject.id; })
    : ALL_ASSETS;
  if (filter !== 'all') assets = assets.filter(function(a) { return a.status === filter; });
  if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); });

  const displayTitle = openProject ? openProject.name : 'All Assets';
  const errorCount = ALL_ASSETS.filter(function(a) { return a.status === 'error'; }).length;
  const recentCount = ALL_ASSETS.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }).length;

  return (

Projects

            <div className={`rail-item ${!openProject ? 'active' : ''}`} onClick={function() { navigate('library'); }} style={{ cursor: 'pointer' }}>
              <Icon name="library" size={13} className="rail-icon" />
              All projects
              {ALL_ASSETS.length}

            {PROJECTS.slice(0, 8).map(function(p) {
              return (
                <div key={p.id} className={`rail-item ${openProject && openProject.id === p.id ? 'active' : ''}`} style={{ cursor: 'pointer' }}
                  onClick={function() { navigate('projects'); }}>

                  {p.name}
                  {p.assets}

              );
            })}

        {BINS.length > 0 && (

Bins

              {BINS.map(function(b) {
                return (

                    <Icon name={binIcon(b.icon)} size={13} className="rail-icon" />
                    {b.name}
                    {b.count}

                );
              })}

        )}

Smart filters

            {errorCount > 0 &&
<Icon name="alert" size={13} className="rail-icon" />Errors{errorCount}
}

<Icon name="clock" size={13} className="rail-icon" />Last 24h{recentCount}

<Icon name="check" size={13} className="rail-icon" />Ready{ALL_ASSETS.filter(function(a) { return a.status === 'ready'; }).length}

{displayTitle}

          · {assets.length} assets

            <Icon name="search" className="search-icon" />
            <input value={search} onChange={function(e) { setSearch(e.target.value); }} placeholder="Filter assets…" />

            {['all', 'ready', 'processing', 'live', 'error'].map(function(f) {
              return (
                <button key={f} className={filter === f ? 'active' : ''} onClick={function() { setFilter(f); }}>
                  {f === 'all' ? 'All' : f[0].toUpperCase() + f.slice(1)}
                </button>
              );
            })}

            <button className={view === 'grid' ? 'active' : ''} onClick={function() { setView('grid'); }}><Icon name="grid" size={12} /></button>
            <button className={view === 'list' ? 'active' : ''} onClick={function() { setView('list'); }}><Icon name="list" size={12} /></button>

          <button className="btn primary" onClick={function() { navigate('upload'); }}><Icon name="upload" />Upload</button>

        {assets.length === 0 ? (

No assets match this filter.

        ) : view === 'grid' ? (

            {assets.map(function(a) { return <AssetCard key={a.id} asset={a} onOpen={function() { onOpenAsset(a); }} />; })}

        ) : (

Name

Duration

Resolution

Codec

Size

Updated

            {assets.map(function(a) {
              return (

<AssetThumb asset={a} />

{a.name}

                      <StatusDot status={a.status} />
                      {a.status}

{a.duration}

{a.res}

{a.codec || '—'}

{a.size}

{a.updated}

                  <button className="icon-btn" onClick={function(e) { e.stopPropagation(); }}><Icon name="more" /></button>

              );
            })}

        )}

  );
}

function AssetCard({ asset, onOpen }) {
  const [hoverStream, setHoverStream] = React.useState(null);
  const [hovered, setHovered] = React.useState(false);
  const timerRef = React.useRef(null);
  const hlsRef = React.useRef(null);
  const videoRef = React.useRef(null);

  const handleMouseEnter = function() {
    timerRef.current = setTimeout(function() {
      setHovered(true);
      if (!hoverStream) {
        window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream')
          .then(function(r) { if (r && r.url) setHoverStream(r); })
          .catch(function() {});
      }
    }, 350);
  };

  const handleMouseLeave = function() {
    clearTimeout(timerRef.current);
    setHovered(false);
  };

  // HLS wiring
  React.useEffect(function() {
    if (!hovered || !hoverStream || hoverStream.type !== 'hls' || !videoRef.current) return;
    if (!window.Hls) return;
    hlsRef.current = new window.Hls({ maxBufferLength: 10 });
    hlsRef.current.loadSource(hoverStream.url);
    hlsRef.current.attachMedia(videoRef.current);
    return function() {
      if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
    };
  }, [hovered, hoverStream]);

  const showVideo = hovered && hoverStream;

  return (

        <AssetThumb asset={asset} />
        {showVideo && (
          <video
            key={hoverStream.url}
            ref={videoRef}
            src={hoverStream.type !== 'hls' ? hoverStream.url : undefined}
            autoPlay
            muted
            loop
            playsInline
            style={{
              position: 'absolute',
              inset: 0,
              width: '100%',
              height: '100%',
              objectFit: 'cover',
              borderRadius: 6,
            }}
          />
        )}

        {asset.status === 'live' && LIVE}
        {asset.status === 'processing' && Processing}
        {asset.status === 'error' && Error}

      {(asset.type === 'video' || !asset.type) && asset.duration !== '—' &&
{asset.duration}
}

{asset.name}

          {asset.res}
          ·
          {asset.size}

  );
}

function binIcon(name) {
  return { grid: 'library', live: 'record', film: 'film', proxy: 'proxy', audio: 'audio', package: 'package' }[name] || 'folder';
}

window.Library = Library;
window.AssetCard = AssetCard;


File: services/web-ui/public/screens-projects.jsx

// screens-projects.jsx

function NewProjectModal({ onClose, onCreated }) {
  const [name, setName] = React.useState('');
  const [saving, setSaving] = React.useState(false);
  const [err, setErr] = React.useState(null);

  const create = () => {
    if (!name.trim()) { setErr('Name is required'); return; }
    setSaving(true); setErr(null);
    window.ZAMPP_API.fetch('/projects', { method: 'POST', body: JSON.stringify({ name: name.trim() }) })
      .then(p => { onCreated(p); onClose(); })
      .catch(e => { setSaving(false); setErr(e.message || 'Failed to create project'); });
  };

  return (

 e.stopPropagation()}>

New project

          <button className="icon-btn" onClick={onClose}><Icon name="x" /></button>

            Project name
            <input className="field-input" value={name} onChange={e => setName(e.target.value)}
              placeholder="e.g. Sunday Night Game" autoFocus
              onKeyDown={e => e.key === 'Enter' && !saving && create()} />

          {err &&
{err}
}

          <button className="btn ghost" onClick={onClose}>Cancel</button>

          <button className="btn primary" onClick={create} disabled={saving}>
            {saving ? 'Creating…' : 'Create project'}
          </button>

  );
}

function Projects({ onOpenProject, navigate }) {
  const [projects, setProjects] = React.useState(window.ZAMPP_DATA.PROJECTS);
  const { ASSETS } = window.ZAMPP_DATA;
  const [search, setSearch] = React.useState('');
  const [view, setView] = React.useState('grid');
  const [showNew, setShowNew] = React.useState(false);

  const onCreated = (p) => {
    const updated = [p, ...window.ZAMPP_DATA.PROJECTS];
    window.ZAMPP_DATA.PROJECTS = updated;
    setProjects(updated);
  };

  let filtered = projects;
  if (search) filtered = filtered.filter(p => p.name.toLowerCase().includes(search.toLowerCase()));

  return (

Projects

        {filtered.length} projects

          <Icon name="search" className="search-icon" />
          <input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search projects…" />

          <button className={view === 'grid' ? 'active' : ''} onClick={() => setView('grid')}><Icon name="grid" size={12} /></button>
          <button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')}><Icon name="list" size={12} /></button>

        <button className="btn primary" onClick={() => setShowNew(true)}><Icon name="plus" />New project</button>

        {filtered.length === 0 ? (

            {search ? 'No matching projects.' : 'No projects yet.'}
            {!search && (

                <button className="btn primary" onClick={() => setShowNew(true)}><Icon name="plus" />New project</button>

            )}

        ) : view === 'grid' ? (

            {filtered.map(p => <ProjectCard key={p.id} project={p} assets={ASSETS} onOpen={() => onOpenProject(p)} />)}

        ) : (

Project

Assets

Storage

Updated

            {filtered.map(p => (

 onOpenProject(p)}>

{p.name}

{p.assets || 0}

—

{p.updated || '—'}

                <button className="icon-btn" onClick={e => e.stopPropagation()}><Icon name="more" /></button>

            ))}

        )}

      {showNew && <NewProjectModal onClose={() => setShowNew(false)} onCreated={onCreated} />}

  );
}

function ProjectCard({ project, assets, onOpen }) {
  const thumbAssets = assets.filter(a => a.project_id === project.id).slice(0, 4);
  return (

        {Array.from({ length: 4 }).map((_, i) => (

            {thumbAssets[i]
              ? <AssetThumb asset={thumbAssets[i]} />
              : <FauxFrame />}

        ))}

          {project.name}

          {project.assets || 0} assets
          ·
          updated {project.updated || '—'}

  );
}

window.Projects = Projects;


Agent

Find search bar styles

Prompt:
Fetch the Forgejo file at https://forge.wilddragon.net/zgaetano/dragonflight/raw/branch/main/services/web-ui/public/styles.css and report:

1. The CSS rules that define `.search`, `.search input`, `.search-icon`, `.kbd`, and `.topbar` — paste those rules verbatim, with line numbers if possible.
2. The CSS variables defined in `:root` (or any var(--...) tokens) — list them with values.
3. Any existing `.menu`, `.dropdown`, `.popover`, or `.context-menu` styles — paste verbatim.
4. The total line count of the file.

Use the mcp__claude_ai_mcp-server__forgejo_forgejo_get_file tool with owner="zgaetano", repo="dragonflight", path="services/web-ui/public/styles.css". Also check styles-rest.css the same way. Report what you find from BOTH files. Keep the response focused — no commentary, just the requested content.


## styles.css

### 1. Requested CSS rules (verbatim)

`.search`, `.search input`, `.search-icon`:
```css
.search {
  position: relative;
  height: 32px;
  width: 280px;
  display: flex; align-items: center;
  background: var(--bg-1);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  padding: 0 12px 0 32px;
  color: var(--text-2);
  font-size: 12.5px;
  transition: border 80ms, background 80ms;
}
.search:focus-within {
  border-color: var(--accent);
  background: var(--bg-2);
}
.search input { flex: 1; outline: 0; border: 0; background: transparent; min-width: 0; }
.search .search-icon {
  position: absolute;
  left: 10px;
  width: 14px; height: 14px;
  color: var(--text-3);
}
```

`.kbd`:
```css
.kbd {
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--text-3);
  background: var(--bg-2);
  border: 1px solid var(--border);
  padding: 1px 5px;
  border-radius: 4px;
  line-height: 1.2;
}
```

`.topbar`:
```css
.topbar {
  height: var(--topbar-h);
  border-bottom: 1px solid var(--border);
  display: flex;
  align-items: center;
  padding: 0 20px;
  gap: 16px;
  flex-shrink: 0;
  background: var(--bg-0);
}
```

### 2. `:root` CSS variables

Base palette:
- `--bg-0: #0B0D11`
- `--bg-1: #14171E`
- `--bg-2: #1B1F27`
- `--bg-3: #232833`
- `--bg-4: #2D3340`
- `--hover: rgba(255,255,255,0.04)`
- `--hover-strong: rgba(255,255,255,0.07)`
- `--border: rgba(255,255,255,0.06)`
- `--border-strong: rgba(255,255,255,0.10)`
- `--border-stronger: rgba(255,255,255,0.14)`

Text:
- `--text-1: #F2F3F6`
- `--text-2: #A8AEBC`
- `--text-3: #6B7280`
- `--text-4: #4B5260`

Accent:
- `--accent: #5B7CFA`
- `--accent-hover: #6E8BFF`
- `--accent-soft: rgba(91, 124, 250, 0.14)`
- `--accent-soft-2: rgba(91, 124, 250, 0.22)`
- `--accent-text: #B4C3FF`

Status:
- `--success: #2DD4A8`
- `--success-soft: rgba(45, 212, 168, 0.14)`
- `--warning: #F5A623`
- `--warning-soft: rgba(245, 166, 35, 0.14)`
- `--danger: #FF5B5B`
- `--danger-soft: rgba(255, 91, 91, 0.14)`
- `--live: #FF3B30`
- `--live-soft: rgba(255, 59, 48, 0.18)`
- `--purple: #B57CFA`
- `--purple-soft: rgba(181, 124, 250, 0.14)`

Radius:
- `--r-xs: 4px`
- `--r-sm: 6px`
- `--r-md: 8px`
- `--r-lg: 12px`
- `--r-xl: 16px`

Shadow:
- `--shadow-pop: 0 12px 32px rgba(0,0,0,0.45), 0 2px 6px rgba(0,0,0,0.25)`
- `--shadow-card: 0 1px 0 rgba(255,255,255,0.04) inset, 0 1px 2px rgba(0,0,0,0.3)`

Sizing:
- `--sidebar-w: 232px`
- `--row-h: 44px`
- `--topbar-h: 56px`
- `--gap: 16px`

Fonts:
- `--font-sans: "Geist", "Söhne", -apple-system, BlinkMacSystemFont, sans-serif`
- `--font-mono: "Geist Mono", "JetBrains Mono", ui-monospace, monospace`

Density override (`[data-density="compact"]`):
- `--row-h: 36px`
- `--topbar-h: 48px`
- `--gap: 12px`

### 3. `.menu`, `.dropdown`, `.popover`, `.context-menu`

None present in styles.css.

### 4. Total line count
styles.css: approximately 412 lines (content ends with the `[data-tip]:hover::after` block).

---

## styles-rest.css

### 1. Requested CSS rules
None of `.search`, `.search input`, `.search-icon`, `.kbd`, or `.topbar` are defined in this file.

### 2. `:root` variables
None defined here; the file consumes `var(--...)` tokens defined in styles.css.

### 3. `.menu`, `.dropdown`, `.popover`, `.context-menu`
No rules with those exact class names. The closest existing popover-style element is `.row-menu` (row popover menu, used in Users/admin tables):

```css
.row-menu {
  position: absolute;
  right: 0;
  top: calc(100% + 4px);
  z-index: 20;
  min-width: 180px;
  background: var(--bg-1);
  border: 1px solid var(--border-stronger);
  border-radius: 6px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
  padding: 4px;
  display: flex;
  flex-direction: column;
}
.row-menu button {
  display: flex;
  align-items: center;
  gap: 8px;
  background: transparent;
  border: 0;
  color: var(--text-1);
  font-size: 12.5px;
  padding: 7px 10px;
  border-radius: 4px;
  cursor: pointer;
  text-align: left;
}
.row-menu button:hover { background: var(--bg-3); }
.row-menu button.danger:hover { background: var(--danger-soft); color: var(--danger); }
```

### 4. Total line count
styles-rest.css: approximately 660 lines.

Note: The forgejo MCP tool returns raw file content without line numbers; line counts above are estimates from the returned content. No `.menu` / `.dropdown` / `.popover` / `.context-menu` classes exist in either file — only `.row-menu` serves a similar function.

agentId: a8fce6749f6612582 (use SendMessage with to: 'a8fce6749f6612582' to continue this agent)
<usage>total_tokens: 46099
tool_uses: 3
duration_ms: 37560</usage>

Prompt:
Fetch the Forgejo file at https://forge.wilddragon.net/zgaetano/dragonflight/raw/branch/main/services/web-ui/public/styles.css and report:

1. The CSS rules that define `.search`, `.search input`, `.search-icon`, `.kbd`, and `.topbar` — paste those rules verbatim, with line numbers if possible.
2. The CSS variables defined in `:root` (or any var(--...) tokens) — list them with values.
3. Any existing `.menu`, `.dropdown`, `.popover`, or `.context-menu` styles — paste verbatim.
4. The total line count of the file.

Use the mcp__claude_ai_mcp-server__forgejo_forgejo_get_file tool with owner="zgaetano", repo="dragonflight", path="services/web-ui/public/styles.css". Also check styles-rest.css the same way. Report what you find from BOTH files. Keep the response focused — no commentary, just the requested content.


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 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

Commit changes to forgejo


File: services/web-ui/public/styles-fixes.css

/* responsive + polish fixes */
.page-header h1 { white-space: nowrap; }
.page-header .subtitle {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
  flex-shrink: 1;
}
.page-header { gap: 12px; flex-wrap: wrap; }
.status-pip span { white-space: nowrap; }
.status-pip { white-space: nowrap; }
.rail-item { min-width: 0; }
.rail-item > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rail-item > span.rail-count, .rail-item > .rail-color-dot { overflow: visible; flex-shrink: 0; }

@media (max-width: 1100px) {
  .stat-row { grid-template-columns: repeat(2, 1fr); }
  .jobs-stats { grid-template-columns: repeat(2, 1fr); }
}

@media (max-width: 1280px) {
  .recorder-row {
    grid-template-columns: 180px 1fr;
    grid-template-rows: auto auto;
  }
  .recorder-stats { grid-column: 2 / 3; grid-row: 2; }
  .recorder-actions { grid-column: 1 / 3; grid-row: 3; justify-content: flex-end; padding-top: 4px; border-top: 1px solid var(--border); }
}

.capture-stat-label, .recorder-stat .stat-label, .stat-card .label, .stat-card .delta {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.topbar > * { flex-shrink: 0; }
.topbar .crumb { min-width: 0; overflow: hidden; }
.topbar .crumb > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar .spacer { flex: 1; min-width: 8px; }

@media (max-width: 1100px) {
  .topbar .search { display: none; }
}
@media (max-width: 900px) {
  .topbar .status-pip span:not(.dot) { display: none; }
}

.library-toolbar { flex-wrap: wrap; }
.library-toolbar .search { width: 200px; }

@media (max-width: 1280px) {
  .page-body > div[style*="grid-template-columns: 440px"] {
    grid-template-columns: 1fr !important;
  }
}

.audio-meter.v {
  border-radius: 99px;
  background: rgba(255,255,255,0.04);
  padding: 3px;
}

.recorder-preview { min-height: 56px; }

.activity-text .target { word-break: break-word; }

.asset-card .meta .sub { overflow: hidden; }

.stat-card .label, .stat-card .value, .stat-card .delta { position: relative; z-index: 1; }
.stat-card .spark { z-index: 0; }


I have enough context. Let me read the styles-rest.css before I append to it, so I don't lose anything.


File: services/web-ui/public/styles-rest.css

/* ========== Projects ========== */
.projects-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 14px;
}
.project-card {
  background: var(--bg-1);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  overflow: hidden;
  cursor: pointer;
  transition: transform 120ms, border 80ms;
}
.project-card:hover {
  transform: translateY(-2px);
  border-color: var(--border-stronger);
}
.project-thumb-grid {
  display: grid;
  grid-template-columns: 2fr 1fr;
  grid-template-rows: 1fr 1fr;
  gap: 2px;
  aspect-ratio: 16 / 9;
  background: var(--bg-2);
}
.project-thumb-grid .project-thumb-cell:first-child { grid-row: span 2; }
.project-thumb-cell { position: relative; overflow: hidden; background: var(--bg-2); }
.project-thumb-cell .thumb-svg { width: 100%; height: 100%; }
.project-card-body { padding: 12px 14px; }
.project-meta {
  display: flex; gap: 6px;
  font-size: 11.5px; color: var(--text-3);
  margin-top: 4px;
}
.project-bar {
  margin-top: 10px;
  display: flex;
  height: 4px;
  border-radius: 99px;
  overflow: hidden;
  background: var(--bg-3);
  gap: 1px;
}

/* ========== Upload ========== */
.field-label {
  font-size: 10.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-4);
  display: block;
  margin-bottom: 6px;
}
.select-faux, .field-input {
  height: 34px;
  background: var(--bg-1);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  padding: 0 10px;
  display: flex; align-items: center; gap: 8px;
  color: var(--text-1);
  font-size: 12.5px;
  width: 100%;
  cursor: pointer;
}
.field-input { font-size: 12.5px; outline: 0; }
.field-input:focus { border-color: var(--accent); background: var(--bg-2); }
.field-input.select { justify-content: space-between; }

.dropzone {
  background: var(--bg-1);
  border: 1.5px dashed var(--border-stronger);
  border-radius: var(--r-lg);
  padding: 40px 24px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  transition: background 120ms, border 120ms;
}
.dropzone:hover { background: var(--bg-2); border-color: var(--accent); }
.dropzone-formats { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; justify-content: center; }
.upload-row {
  display: flex; align-items: center; gap: 12px;
  padding: 12px 14px;
  border-bottom: 1px solid var(--border);
}
.upload-row:last-child { border-bottom: 0; }

/* ========== Recorders ========== */
.recorders-list { display: flex; flex-direction: column; gap: 10px; }
.recorder-row {
  background: var(--bg-1);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  padding: 12px;
  display: grid;
  grid-template-columns: 220px 1fr 380px auto;
  align-items: center;
  gap: 16px;
  transition: border 80ms;
}
.recorder-row:hover { border-color: var(--border-stronger); }
.recorder-row.recording { border-color: rgba(255,59,48,0.25); }
.recorder-row.error { border-color: rgba(255,91,91,0.25); }
.recorder-preview {
  height: 56px;
  border-radius: var(--r-sm);
  background: var(--bg-2);
  overflow: hidden;
  position: relative;
}
.recorder-empty {
  height: 100%;
  display: grid; place-items: center;
  color: var(--text-3);
}
.recorder-audio-prev {
  height: 100%;
  display: flex; align-items: center; gap: 10px;
  padding: 0 12px;
}
.recorder-audio-prev .waveform { flex: 1; height: 80%; }
.recorder-info { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.recorder-sub {
  font-size: 11.5px;
  color: var(--text-3);
  display: flex; gap: 6px;
}
.recorder-sub.mono { font-family: var(--font-mono); font-size: 11px; }
.recorder-stats {
  display: grid;
  grid-template-columns: 90px 90px 1fr;
  gap: 12px;
}
.recorder-stat .stat-label {
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-weight: 600;
  color: var(--text-4);
  margin-bottom: 3px;
}
.recorder-stat .stat-val { font-size: 12.5px; }
.recorder-actions { display: flex; align-items: center; gap: 4px; }
.rec-dot {
  width: 8px; height: 8px;
  border-radius: 50%;
  background: currentColor;
}

/* ========== Capture / DeckLink ========== */
.decklink-card {
  background: var(--bg-1);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  overflow: hidden;
}
.decklink-head {
  padding: 14px 16px;
  display: flex; justify-content: space-between; align-items: center;
  border-bottom: 1px solid var(--border);
}
.decklink-body {
  padding: 24px;
  display: grid;
  grid-template-columns: 60px 1fr;
  gap: 20px;
  align-items: stretch;
  background:
    radial-gradient(ellipse at top, rgba(91,124,250,0.05), transparent 60%),
    var(--bg-0);
}
.decklink-card-face {
  background: linear-gradient(180deg, #1c1f28, #0d0e13);
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  display: flex; flex-direction: column;
  justify-content: space-between;
  padding: 12px 6px;
  position: relative;
}
.decklink-label {
  writing-mode: vertical-rl;
  transform: rotate(180deg);
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: 0.12em;
  color: var(--text-3);
  text-align: center;
  margin: 0 auto;
}
.decklink-leds {
  display: flex; gap: 6px;
  justify-content: center;
}
.decklink-led {
  width: 6px; height: 6px;
  border-radius: 50%;
  background: rgba(255,255,255,0.1);
}
.decklink-led.on {
  background: var(--success);
  box-shadow: 0 0 6px var(--success);
}
.bnc-ports { display: flex; flex-direction: column; gap: 10px; }
.bnc-port {
  display: grid;
  grid-template-columns: 32px 1fr auto;
  align-items: center;
  gap: 12px;
  padding: 10px;
  background: var(--bg-2);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  text-align: left;
  cursor: pointer;
  position: relative;
  transition: background 80ms, border 80ms;
}
.bnc-port:hover { border-color: var(--border-stronger); }
.bnc-port.active { background: var(--bg-3); }
.bnc-port.active.live::before {
  content: "";
  position: absolute;
  left: 0; top: 0; bottom: 0;
  width: 2px;
  background: var(--success);
}
.bnc-port.recording::before { background: var(--live); }
.bnc-port.active { box-shadow: inset 0 0 0 1px var(--accent); border-color: var(--accent); }
.bnc-connector {
  width: 32px; height: 32px;
  border-radius: 50%;
  background: radial-gradient(circle, #1a1d24, #0a0c11);
  display: grid; place-items: center;
  border: 1.5px solid var(--border-stronger);
  position: relative;
}
.bnc-pin {
  width: 6px; height: 6px;
  background: linear-gradient(135deg, #6b7280, #2a2f3a);
  border-radius: 50%;
}
.bnc-ring {
  position: absolute;
  inset: 4px;
  border: 1px dashed rgba(255,255,255,0.08);
  border-radius: 50%;
}
.bnc-port.live .bnc-pin { background: linear-gradient(135deg, #FFC857, #C8862D); box-shadow: 0 0 8px rgba(255,200,87,0.4); }
.bnc-port.recording .bnc-pin { background: linear-gradient(135deg, #FF5C5C, #C8362D); box-shadow: 0 0 8px rgba(255,92,92,0.5); }
.bnc-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.bnc-num { font-size: 12.5px; font-weight: 600; }
.bnc-label { font-size: 11px; color: var(--text-2); }
.bnc-sig { font-size: 10.5px; color: var(--text-3); }
.bnc-port.live .bnc-sig { color: var(--success); }
.bnc-rec {
  width: 10px; height: 10px;
  border-radius: 50%;
  background: var(--live);
  animation: pulse 1.2s ease-in-out infinite;
}
.bnc-signal-bar {
  grid-column: 2 / 4;
  height: 3px;
  background: var(--bg-3);
  border-radius: 99px;
  overflow: hidden;
  margin-top: 4px;
}
.bnc-signal-fill {
  height: 100%;
  background: linear-gradient(90deg, var(--success), var(--warning), var(--danger));
  background-size: 200% 100%;
  background-position: 0% 0;
  transition: width 200ms;
}
.decklink-foot {
  padding: 10px 16px;
  border-top: 1px solid var(--border);
  display: flex; gap: 20px;
  font-size: 11.5px;
  color: var(--text-3);
}
.decklink-foot strong { color: var(--text-1); font-weight: 600; }

.capture-detail {
  background: var(--bg-1);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  padding: 16px;
}
.capture-detail.empty {
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  padding: 80px 24px;
  color: var(--text-3);
}
.capture-preview {
  position: relative;
  aspect-ratio: 16 / 9;
  border-radius: var(--r-md);
  overflow: hidden;
  background: #000;
}
.capture-overlay-meters {
  position: absolute;
  right: 8px; top: 8px;
  display: flex; gap: 4px;
}
.capture-tc {
  position: absolute;
  bottom: 8px; right: 8px;
  background: rgba(0,0,0,0.7);
  padding: 3px 8px;
  border-radius: 3px;
  font-family: var(--font-mono);
  font-size: 11px;
  color: white;
}
.capture-stats {
  margin-top: 16px;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
}
.capture-stat {
  background: var(--bg-2);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  padding: 8px 10px;
}
.capture-stat-label {
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-weight: 600;
  color: var(--text-4);
}
.capture-stat-value { font-size: 13px; margin-top: 2px; }

/* ========== Monitors ========== */
.monitors-grid {
  display: grid;
  gap: 10px;
  background: var(--bg-2);
  border-radius: var(--r-lg);
  padding: 10px;
}
.monitor-tile {
  position: relative;
  aspect-ratio: 16 / 9;
  background: #000;
  border-radius: var(--r-sm);
  overflow: hidden;
  border: 1px solid var(--border);
  display: flex; flex-direction: column;
  cursor: pointer;
}
.monitor-tile.audio { background: linear-gradient(135deg, hsl(180 30% 12%), hsl(200 25% 6%)); }
.monitor-tile-label {
  position: absolute;
  bottom: 0; left: 0; right: 0;
  padding: 8px 10px;
  background: linear-gradient(180deg, transparent, rgba(0,0,0,0.8));
  display: flex; align-items: center; gap: 6px;
}
.monitor-tile-label .name {
  color: white;
  font-size: 11.5px;
  font-weight: 500;
}
.monitor-tile-label .time {
  margin-left: auto;
  color: white;
  font-family: var(--font-mono);
  font-size: 10.5px;
}

/* ========== Jobs ========== */
.jobs-stats {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 12px;
}
.job-row {
  display: grid;
  grid-template-columns: 20px 110px 1fr 90px 200px 70px 80px 90px;
  align-items: center;
  gap: 12px;
  padding: 10px 16px;
  border-bottom: 1px solid var(--border);
  font-size: 12.5px;
}
.job-row:last-child { border-bottom: 0; }
.job-row.head {
  font-size: 10.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-4);
}
.job-progress-wrap {
  display: flex; align-items: center; gap: 8px;
}
.job-progress-bar {
  flex: 1;
  height: 5px;
  background: var(--bg-3);
  border-radius: 99px;
  overflow: hidden;
  position: relative;
}
.job-progress-fill {
  height: 100%;
  background: linear-gradient(90deg, var(--accent), #7C9EFF);
  background-size: 200% 100%;
  animation: shimmer 2s linear infinite;
  transition: width 300ms;
}

/* ========== Editor ========== */
.editor-shell {
  display: flex; flex-direction: column;
  height: 100%;
  background: var(--bg-0);
}
.editor-topbar {
  height: 44px;
  border-bottom: 1px solid var(--border);
  padding: 0 14px;
  display: flex; align-items: center; gap: 10px;
}
.editor-body {
  flex: 1;
  display: grid;
  grid-template-columns: 240px 1fr 240px;
  min-height: 0;
}
.editor-bins, .editor-insp { background: var(--bg-1); border-right: 1px solid var(--border); overflow-y: auto; }
.editor-insp { border-right: 0; border-left: 1px solid var(--border); }
.editor-bin-item {
  display: flex; align-items: center; gap: 8px;
  padding: 6px;
  border-radius: var(--r-sm);
  cursor: pointer;
}
.editor-bin-item:hover { background: var(--hover); }
.editor-bin-thumb { width: 48px; aspect-ratio: 16/9; border-radius: 3px; overflow: hidden; background: var(--bg-2); flex-shrink: 0; }
.editor-viewer { background: #000; display: flex; flex-direction: column; }
.editor-canvas {
  flex: 1;
  position: relative;
  background: #050608;
  display: grid; place-items: center;
  overflow: hidden;
  max-height: 50vh;
}
.editor-canvas .thumb-svg { width: 100%; height: 100%; }
.editor-transport {
  height: 44px;
  display: flex; align-items: center; gap: 8px;
  padding: 0 14px;
  background: var(--bg-1);
  border-top: 1px solid var(--border);
}
.editor-timeline {
  background: var(--bg-0);
  border-top: 1px solid var(--border);
  height: 240px;
  display: flex; flex-direction: column;
  position: relative;
  overflow: hidden;
}
.editor-timeline-head {
  height: 32px;
  display: flex; align-items: center;
  padding: 0 12px;
  gap: 8px;
  border-bottom: 1px solid var(--border);
}
.timeline-ruler {
  height: 22px;
  display: flex;
  padding-left: 40px;
  border-bottom: 1px solid var(--border);
  background: var(--bg-1);
}
.ruler-tick {
  flex: 1;
  border-left: 1px solid var(--border);
  font-size: 9.5px;
  color: var(--text-4);
  padding: 4px 4px 0;
}
.timeline-track {
  display: grid;
  grid-template-columns: 40px 1fr;
  align-items: stretch;
  height: 42px;
  border-bottom: 1px solid var(--border);
}
.timeline-track-label {
  background: var(--bg-1);
  display: grid; place-items: center;
  font-size: 10.5px;
  font-family: var(--font-mono);
  font-weight: 600;
  color: var(--text-3);
  border-right: 1px solid var(--border);
}
.timeline-track-lane {
  position: relative;
  background:
    repeating-linear-gradient(90deg, transparent 0 49px, rgba(255,255,255,0.02) 49px 50px);
}
.clip {
  position: absolute;
  top: 4px; bottom: 4px;
  border-radius: 3px;
  overflow: hidden;
  display: flex; align-items: center;
  cursor: pointer;
  border: 1px solid rgba(0,0,0,0.3);
  box-shadow: inset 0 1px 0 rgba(255,255,255,0.15);
}
.clip-label {
  position: absolute;
  left: 6px; top: 3px;
  font-size: 10px;
  font-weight: 600;
  color: white;
  z-index: 2;
  text-shadow: 0 1px 1px rgba(0,0,0,0.4);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.clip-film { display: flex; flex: 1; height: 100%; }
.clip-film-cell { flex: 1; overflow: hidden; }
.clip-film-cell .thumb-svg { width: 100%; height: 100%; }
.clip.audio { background: var(--success) !important; }
.clip-wave { width: 100%; height: 100%; padding: 4px; opacity: 0.6; }
.clip-wave .waveform { width: 100%; height: 100%; }
.timeline-playhead {
  position: absolute;
  top: 32px; bottom: 0;
  width: 1.5px;
  background: var(--live);
  pointer-events: none;
  z-index: 5;
}
.timeline-playhead::before {
  content: "";
  position: absolute;
  top: -4px; left: 50%;
  width: 10px; height: 10px;
  background: var(--live);
  transform: translateX(-50%) rotate(45deg);
}

/* ========== Admin tables ========== */
/* ── Row popover menu (Users, etc.) ────────────────────────────────── */
.row-menu {
  position: absolute;
  right: 0;
  top: calc(100% + 4px);
  z-index: 20;
  min-width: 180px;
  background: var(--bg-1);
  border: 1px solid var(--border-stronger);
  border-radius: 6px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
  padding: 4px;
  display: flex;
  flex-direction: column;
}
.row-menu button {
  display: flex;
  align-items: center;
  gap: 8px;
  background: transparent;
  border: 0;
  color: var(--text-1);
  font-size: 12.5px;
  padding: 7px 10px;
  border-radius: 4px;
  cursor: pointer;
  text-align: left;
}
.row-menu button:hover { background: var(--bg-3); }
.row-menu button.danger:hover { background: var(--danger-soft); color: var(--danger); }

.user-row, .token-row, .container-row, .schedule-row {
  display: grid;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  font-size: 12.5px;
}
.user-row { grid-template-columns: 1.5fr 100px 1.5fr 120px 40px; }
.token-row { grid-template-columns: 1.4fr 1.4fr 110px 110px 100px 40px; }
.container-row { grid-template-columns: 1.4fr 1.4fr 140px 140px 100px 1.4fr 110px; }
.schedule-row { grid-template-columns: 1.6fr 1.2fr 1.2fr 90px 110px 110px 150px; }
.user-row.head, .token-row.head, .container-row.head, .schedule-row.head {
  font-size: 10.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-4);
}
.user-row:last-child, .token-row:last-child, .container-row:last-child, .schedule-row:last-child { border-bottom: 0; }
.token-row.revoked { opacity: 0.5; }

/* ========== Cluster ========== */
.cluster-canvas {
  background: var(--bg-1);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  overflow: hidden;
}

/* ========== Settings ========== */
.settings-nav {
  display: flex; flex-direction: column; gap: 2px;
  position: sticky; top: 0;
}
.settings-nav-item {
  display: flex; align-items: center; gap: 8px;
  padding: 0 10px;
  height: 32px;
  border-radius: var(--r-sm);
  color: var(--text-2);
  font-size: 12.5px;
  cursor: pointer;
}
.settings-nav-item:hover { background: var(--hover); color: var(--text-1); }
.settings-nav-item.active { background: var(--accent-soft); color: var(--accent-text); }
.settings-card {
  background: var(--bg-1);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  overflow: hidden;
}
.settings-card-head {
  padding: 16px;
  display: flex; align-items: flex-start; gap: 12px;
  border-bottom: 1px solid var(--border);
}
.settings-card-icon {
  width: 36px; height: 36px;
  border-radius: var(--r-sm);
  background: var(--accent-soft);
  color: var(--accent);
  display: grid; place-items: center;
  flex-shrink: 0;
}
.settings-card-body { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.field { display: flex; flex-direction: column; gap: 4px; }
.field-input-wrap { display: flex; gap: 6px; align-items: center; }
.checkbox-row {
  display: flex; align-items: center; gap: 8px;
  font-size: 12.5px; color: var(--text-1);
  cursor: pointer;
}
.checkbox-row input { accent-color: var(--accent); }

/* ========== Tokens parody ========== */
.token-hero {
  display: grid;
  grid-template-columns: 2fr 1fr;
  gap: 14px;
  margin-bottom: 16px;
}
.token-burn-card, .token-actual-card {
  background: var(--bg-1);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  padding: 18px 20px;
  position: relative;
  overflow: hidden;
}
.token-burn-card {
  background:
    radial-gradient(ellipse at top right, rgba(255,91,91,0.12), transparent 60%),
    var(--bg-1);
  border-color: rgba(255,91,91,0.18);
}
.token-actual-card {
  background:
    radial-gradient(ellipse at bottom left, rgba(45,212,168,0.10), transparent 60%),
    var(--bg-1);
  border-color: rgba(45,212,168,0.18);
}
.token-card-label {
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-weight: 700;
  color: var(--text-4);
  margin-bottom: 8px;
}
.token-counter {
  display: flex; align-items: baseline; gap: 12px;
}
.token-flame { font-size: 32px; line-height: 1; }
.token-big {
  font-size: 48px;
  font-weight: 700;
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
  background: linear-gradient(180deg, #FF9D6B, #FF5B5B);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  line-height: 1.05;
}
.token-rate {
  font-size: 12px;
  display: flex; align-items: baseline;
  font-family: var(--font-mono);
}
.token-actual-amount {
  display: flex; align-items: baseline; gap: 0;
  color: var(--success);
  margin-bottom: 8px;
}

.token-comparison {
  background: var(--bg-1);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  padding: 0 0 12px;
  margin-bottom: 16px;
  overflow: hidden;
}
.token-compare-chart { padding: 0 16px; }
.token-compare-legend {
  display: flex; gap: 24px;
  padding: 8px 0 0;
  font-size: 12px;
  color: var(--text-2);
}
.token-compare-legend .dot {
  display: inline-block;
  width: 10px; height: 10px;
  border-radius: 50%;
  margin-right: 6px;
  vertical-align: middle;
}

.token-grid {
  display: grid;
  grid-template-columns: 1fr 1.4fr;
  gap: 14px;
}
.token-event {
  display: flex; align-items: center; gap: 12px;
  padding: 10px 14px;
  border-bottom: 1px solid var(--border);
}
.token-event:last-child { border-bottom: 0; }
.token-event.fresh { animation: tokenFresh 800ms ease; }
@keyframes tokenFresh {
  0% { background: rgba(255,91,91,0.15); transform: translateX(-4px); }
  100% { background: transparent; transform: none; }
}
.token-tiers {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 10px;
}
.token-tier {
  background: var(--bg-1);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  padding: 16px 14px;
  display: flex; flex-direction: column;
  gap: 6px;
  position: relative;
}
.token-tier.popular {
  border-color: var(--accent);
  background: linear-gradient(180deg, var(--accent-soft) 0%, var(--bg-1) 40%);
  box-shadow: 0 0 24px rgba(91,124,250,0.10);
}
.token-tier-badge {
  position: absolute;
  top: -8px; right: 12px;
  background: var(--accent);
  color: white;
  font-size: 9.5px;
  font-weight: 700;
  letter-spacing: 0.06em;
  padding: 3px 8px;
  border-radius: 99px;
}
.token-tier-name { font-size: 13px; font-weight: 700; letter-spacing: -0.01em; }
.token-tier-desc { font-size: 11px; color: var(--text-3); min-height: 32px; line-height: 1.4; }
.token-tier-price { margin-top: 6px; display: flex; align-items: baseline; }
.token-tier-tokens { font-size: 11px; color: var(--text-3); padding-top: 4px; border-top: 1px solid var(--border); }

.token-footnote {
  margin-top: 20px;
  padding: 14px 16px;
  background: var(--warning-soft);
  border: 1px solid rgba(245,166,35,0.2);
  border-radius: var(--r-md);
  display: flex; gap: 12px;
  font-size: 12.5px;
  color: var(--text-2);
  line-height: 1.55;
}
.token-footnote svg { color: var(--warning); flex-shrink: 0; margin-top: 2px; }
.token-footnote strong { color: var(--warning); }


Now I have what I need. Let me write all the changes and batch-commit them.


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


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}

  );
}

function ContextMenu({ menu, onClose }) {
  const ref = React.useRef(null);
  const [pos, setPos] = React.useState({ left: 0, top: 0, ready: false });

  React.useLayoutEffect(() => {
    if (!menu || !ref.current) return;
    const rect = ref.current.getBoundingClientRect();
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    let left = menu.x;
    let top = menu.y;
    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 });
  }, [menu]);

  React.useEffect(() => {
    if (!menu) return;
    const onEsc = (e) => { if (e.key === 'Escape') onClose(); };
    const onScroll = () => onClose();
    window.addEventListener('keydown', onEsc);
    window.addEventListener('scroll', onScroll, true);
    window.addEventListener('resize', onClose);
    return () => {
      window.removeEventListener('keydown', onEsc);
      window.removeEventListener('scroll', onScroll, true);
      window.removeEventListener('resize', onClose);
    };
  }, [menu, onClose]);

  if (!menu) return null;

  const handleItem = (item) => {
    if (item.disabled) return;
    onClose();
    if (item.onClick) item.onClick();
  };

  return (
    <>

 { e.preventDefault(); onClose(); }}
      />

 e.preventDefault()}
      >
        {menu.items.map((item, i) => {
          if (item.divider) return
;
          if (item.header) return
{item.header}
;
          return (
            <button
              key={i}
              className={`context-menu-item ${item.danger ? 'danger' : ''}`}
              onClick={() => handleItem(item)}
              disabled={item.disabled}
            >
              {item.icon && <Icon name={item.icon} size={13} className="context-menu-icon" />}
              {item.label}
              {item.shortcut && {item.shortcut}}
            </button>
          );
        })}

    </>
  );
}

// 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.ContextMenu = ContextMenu;
window.GlobalSearch = GlobalSearch;


File created successfully at: /tmp/shell.jsx (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}

  );
}

function ContextMenu({ menu, onClose }) {
  const ref = React.useRef(null);
  const [pos, setPos] = React.useState({ left: 0, top: 0, ready: false });

  React.useLayoutEffect(() => {
    if (!menu || !ref.current) return;
    const rect = ref.current.getBoundingClientRect();
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    let left = menu.x;
    let top = menu.y;
    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 });
  }, [menu]);

  React.useEffect(() => {
    if (!menu) return;
    const onEsc = (e) => { if (e.key === 'Escape') onClose(); };
    const onScroll = () => onClose();
    window.addEventListener('keydown', onEsc);
    window.addEventListener('scroll', onScroll, true);
    window.addEventListener('resize', onClose);
    return () => {
      window.removeEventListener('keydown', onEsc);
      window.removeEventListener('scroll', onScroll, true);
      window.removeEventListener('resize', onClose);
    };
  }, [menu, onClose]);

  if (!menu) return null;

  const handleItem = (item) => {
    if (item.disabled) return;
    onClose();
    if (item.onClick) item.onClick();
  };

  return (
    <>

 { e.preventDefault(); onClose(); }}
      />

 e.preventDefault()}
      >
        {menu.items.map((item, i) => {
          if (item.divider) return
;
          if (item.header) return
{item.header}
;
          return (
            <button
              key={i}
              className={`context-menu-item ${item.danger ? 'danger' : ''}`}
              onClick={() => handleItem(item)}
              disabled={item.disabled}
            >
              {item.icon && <Icon name={item.icon} size={13} className="context-menu-icon" />}
              {item.label}
              {item.shortcut && {item.shortcut}}
            </button>
          );
        })}

    </>
  );
}

// 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.ContextMenu = ContextMenu;
window.GlobalSearch = GlobalSearch;


// 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 />);