Search the forgejo repo zgaetano/dragonflight (use mcp__claude_ai_mcp-server__forgejo_forgejo_get_file and forgejo_list_directory) for any code that implements a right-click / context-menu UI feature. Specifically search the files under services/web-ui/public/ for:

1. The string "onContextMenu" (React handler)
2. The string "oncontextmenu"
3. The string "contextmenu" (event name)
4. The string "context-menu" (CSS class)
5. The string "right-click" or "rightclick"

For each match, report:
- Which file
- A few lines of surrounding context (what's being right-clicked, what menu is shown)

Files to check (all under services/web-ui/public/):
- shell.jsx (current main branch)
- app.jsx
- screens-library.jsx
- screens-projects.jsx
- screens-ingest.jsx
- screens-jobs.jsx
- screens-editor.jsx
- screens-admin.jsx
- screens-asset.jsx
- screens-home.jsx
- tweaks-panel.jsx
- modal-new-recorder.jsx
- visuals.jsx
- icons.jsx
- data.jsx
- styles.css
- styles-rest.css
- styles-fixes.css
- styles-screens.css
- styles-asset.css
- styles-modal.css

Use forgejo_get_file with owner="zgaetano" and repo="dragonflight" for each. Be efficient — you may grep through the content as you fetch. Report ALL findings, even if "nothing found". Keep report under 200 lines.


I'll load the forgejo tools and search through the dragonflight repo files.


ToolSearch

Question: select:mcp__claude_ai_mcp-server__forgejo_forgejo_get_file,mcp__claude_ai_mcp-server__forgejo_forgejo_list_directory


I'll search through each file efficiently, fetching contents and checking for the target patterns.


📁 css
📁 fonts
📁 img
📁 js
📄 _primitives-smoke.html (12459B)
📄 api-tokens.html (15415B)
📄 app.jsx (5417B)
📄 capture.html (21359B)
📄 cluster.html (15141B)
📄 containers.html (12265B)
📄 data.jsx (5728B)
📄 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 (36720B)
📄 screens-jobs.jsx (7132B)
📄 screens-library.jsx (15310B)
📄 screens-projects.jsx (6626B)
📄 settings.html (33212B)
📄 shell.jsx (12663B)
📄 styles-asset.css (9140B)
📄 styles-fixes.css (2130B)
📄 styles-modal.css (4529B)
📄 styles-rest.css (24173B)
📄 styles-screens.css (11352B)
📄 styles.css (12440B)
📄 tokens.html (37818B)
📄 tweaks-panel.jsx (18370B)
📄 upload.html (20736B)
📄 users.html (28557B)
📄 visuals.jsx (5519B)


Good, all files exist. Let me fetch them in parallel batches.


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

const NAV_FLAT = (() => {
  const out = [];
  const visit = (arr) => arr.forEach(n => {
    if (n.group && n.children) { visit(n.children); return; }
    out.push({ id: n.id, label: n.label, icon: n.icon });
  });
  visit(NAV_TREE); visit(ADMIN_TREE);
  return out;
})();

function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup }) {
  const isGroup = item.group && item.children;
  const isOpen = isGroup && openGroups.has(item.id);
  const isActive = active === item.id || (isGroup && item.children.some(c => c.id === active));

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

      {isGroup && isOpen && (

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

          ))}

      )}
    </>
  );
}

function Sidebar({ active, onNavigate }) {
  const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"]));
  const toggleGroup = (id) => {
    setOpenGroups(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  React.useEffect(() => {
    const ingestChildren = ["upload", "recorders", "schedule", "capture", "monitors"];
    if (ingestChildren.includes(active)) {
      setOpenGroups(prev => prev.has("ingest") ? prev : new Set([...prev, "ingest"]));
    }
  }, [active]);

  return (

D

Dragonflight

v1.2.0

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

Admin

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

ZG

Zach Gaetano

admin · broadcast

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

  );
}

function assetSearchIcon(a) {
  const t = (a.type || a.media_type || '').toLowerCase();
  if (t.includes('audio')) return 'audio';
  if (t.includes('image')) return 'image';
  return 'film';
}

function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) {
  const [q, setQ] = React.useState('');
  const [open, setOpen] = React.useState(false);
  const [sel, setSel] = React.useState(0);
  const inputRef = React.useRef(null);
  const listRef = React.useRef(null);
  const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);

  React.useEffect(() => {
    const handler = (e) => {
      const key = e.key && e.key.toLowerCase();
      if ((e.metaKey || e.ctrlKey) && key === 'k') {
        e.preventDefault();
        if (inputRef.current) {
          inputRef.current.focus();
          inputRef.current.select();
          setOpen(true);
        }
      }
    };
    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, []);

  const results = React.useMemo(() => {
    const term = q.trim().toLowerCase();
    if (!term) return [];
    const D = window.ZAMPP_DATA || {};
    const out = [];

    (D.ASSETS || []).forEach(a => {
      const hay = ((a.name || '') + ' ' + (a.project || '') + ' ' + (a.filename || '')).toLowerCase();
      if (hay.includes(term)) {
        const sub = [a.project, a.res, a.duration].filter(x => x && x !== '—').join(' · ');
        out.push({ kind: 'asset', icon: assetSearchIcon(a), label: a.name, sub, item: a });
      }
    });
    (D.PROJECTS || []).forEach(p => {
      if ((p.name || '').toLowerCase().includes(term)) {
        out.push({ kind: 'project', icon: 'folder', label: p.name, sub: (p.assets || 0) + ' assets', item: p, color: p.color });
      }
    });
    (D.RECORDERS || []).forEach(r => {
      const hay = ((r.name || '') + ' ' + (r.url || '') + ' ' + (r.source || '')).toLowerCase();
      if (hay.includes(term)) {
        out.push({ kind: 'recorder', icon: 'record', label: r.name || r.url || 'Recorder', sub: [r.status, r.source].filter(Boolean).join(' · '), item: r });
      }
    });
    (D.JOBS || []).forEach(j => {
      const hay = ((j.asset || '') + ' ' + (j.kind || '') + ' ' + (j.status || '')).toLowerCase();
      if (hay.includes(term)) {
        out.push({ kind: 'job', icon: 'jobs', label: j.asset || j.kind || 'Job', sub: [j.kind, j.status].filter(Boolean).join(' · '), item: j });
      }
    });
    (D.USERS || []).forEach(u => {
      const hay = ((u.name || '') + ' ' + (u.email || '') + ' ' + (u.username || '')).toLowerCase();
      if (hay.includes(term)) {
        out.push({ kind: 'user', icon: 'users', label: u.name, sub: u.email || u.role, item: u });
      }
    });
    NAV_FLAT.forEach(n => {
      if (n.label.toLowerCase().includes(term)) {
        out.push({ kind: 'nav', icon: n.icon, label: n.label, sub: 'Go to ' + n.label, target: n.id });
      }
    });

    return out.slice(0, 40);
  }, [q, open]);

  React.useEffect(() => { setSel(0); }, [q]);

  React.useEffect(() => {
    if (!listRef.current) return;
    const el = listRef.current.querySelector('.search-result.active');
    if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
  }, [sel]);

  const handleSelect = (r) => {
    if (!r) return;
    setOpen(false);
    setQ('');
    if (inputRef.current) inputRef.current.blur();
    if (r.kind === 'asset' && onOpenAsset) onOpenAsset(r.item);
    else if (r.kind === 'project' && onOpenProject) onOpenProject(r.item);
    else if (r.kind === 'recorder') onNavigate && onNavigate('recorders');
    else if (r.kind === 'job') onNavigate && onNavigate('jobs');
    else if (r.kind === 'user') onNavigate && onNavigate('users');
    else if (r.kind === 'nav') onNavigate && onNavigate(r.target);
  };

  const onKeyDown = (e) => {
    if (e.key === 'Escape') {
      setOpen(false); setQ('');
      if (inputRef.current) inputRef.current.blur();
      return;
    }
    if (!open || results.length === 0) return;
    if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(s + 1, results.length - 1)); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(0, s - 1)); }
    else if (e.key === 'Enter') { e.preventDefault(); handleSelect(results[sel]); }
  };

  const showDropdown = open && q.trim().length > 0;

  return (

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

      {showDropdown && (

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

No results for “{q}”

          ) : results.map((r, i) => (
            <div
              key={r.kind + '-' + i + '-' + (r.item ? (r.item.id || r.item.name) : (r.target || r.label))}
              className={`search-result ${i === sel ? 'active' : ''}`}
              onClick={() => handleSelect(r)}
              onMouseEnter={() => setSel(i)}
            >

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

{r.label}

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

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

          ))}

      )}

  );
}

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

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

          </React.Fragment>
        ))}

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

        cluster healthy

      <button className="icon-btn" data-tip="Notifications">
        <Icon name="bell" />
      </button>
      {right}

  );
}

// General-purpose read-only form field used by recorder modal and other forms.
// Renders as a labeled select (when select=true) or text input.
function Field({ label, value, select, children }) {
  return (

      {label}
      {children || (select
        ? (
          <select className="field-input" defaultValue={value} style={{ appearance: 'auto' }}>
            <option value={value}>{value}</option>
          </select>
        ) : (
          <input className="field-input" defaultValue={value} readOnly />
        )
      )}

  );
}

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


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

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

  if (!dataReady) {
    return (
      <>

Loading Dragonflight…

      </>
    );
  }

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

  return (

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

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

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

  );
}

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

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


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

// screens-library.jsx

function Library({ navigate, onOpenAsset, openProject }) {
  const { BINS, PROJECTS } = window.ZAMPP_DATA;
  const [view, setView] = React.useState('grid');
  const [filter, setFilter] = React.useState('all');
  const [search, setSearch] = React.useState('');
  // Local state lets us re-render after delete / move-to-bin without forcing
  // a full app reload — keeps ZAMPP_DATA in sync as the cache of record.
  const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []);
  const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y }

  const refreshAssets = React.useCallback(() => {
    window.ZAMPP_API.fetch('/assets?limit=500')
      .then(r => {
        const list = Array.isArray(r) ? r : (r.assets || []);
        const proj = {};
        (PROJECTS || []).forEach(p => { proj[p.id] = p.name; });
        const normalized = list.map(a => window.normalizeAsset ? window.normalizeAsset(a, proj) : a);
        window.ZAMPP_DATA.ASSETS = normalized;
        setAllAssets(normalized);
      })
      .catch(() => {});
  }, [PROJECTS]);

  // Dismiss the context menu on any outside click (capture phase so clicking
  // a menu item still fires before the menu unmounts).
  React.useEffect(() => {
    if (!ctxMenu) return;
    const close = () => setCtxMenu(null);
    window.addEventListener('click', close);
    window.addEventListener('contextmenu', close);
    window.addEventListener('scroll', close, true);
    return () => {
      window.removeEventListener('click', close);
      window.removeEventListener('contextmenu', close);
      window.removeEventListener('scroll', close, true);
    };
  }, [ctxMenu]);

  const openCtx = (asset, e) => {
    e.preventDefault();
    e.stopPropagation();
    setCtxMenu({ asset, x: e.clientX, y: e.clientY });
  };

  let assets = openProject
    ? allAssets.filter(function(a) { return a.project_id === openProject.id; })
    : allAssets;
  const ALL_ASSETS = allAssets;
  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); }}
                       onContextMenu={function(e) { openCtx(a, e); }} />;
            })}

        ) : (

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(); openCtx(a, e); }}><Icon name="more" /></button>

              );
            })}

        )}

      {ctxMenu && (
        <AssetContextMenu
          asset={ctxMenu.asset}
          x={ctxMenu.x}
          y={ctxMenu.y}
          bins={BINS}
          onClose={function() { setCtxMenu(null); }}
          onChanged={refreshAssets}
          onOpen={function() { onOpenAsset(ctxMenu.asset); }}
        />
      )}

  );
}

function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen }) {
  const ref = React.useRef(null);
  // Pin the menu inside the viewport even if the user right-clicked near
  // the bottom-right edge of the grid.
  const [pos, setPos] = React.useState({ left: x, top: y });
  React.useLayoutEffect(() => {
    if (!ref.current) return;
    const r = ref.current.getBoundingClientRect();
    const margin = 8;
    let nx = x, ny = y;
    if (x + r.width  + margin > window.innerWidth)  nx = window.innerWidth  - r.width  - margin;
    if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin;
    setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) });
  }, [x, y]);

  const rename = function() {
    onClose();
    const next = prompt('Rename asset', asset.display_name || asset.name || '');
    if (next == null) return;
    const trimmed = next.trim();
    if (!trimmed || trimmed === (asset.display_name || asset.name)) return;
    window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ display_name: trimmed }) })
      .then(onChanged)
      .catch(function(e) { alert('Rename failed: ' + e.message); });
  };

  const moveToBin = function(binId) {
    onClose();
    window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) })
      .then(onChanged)
      .catch(function(e) { alert('Move failed: ' + e.message); });
  };

  const copyId = function() {
    onClose();
    if (navigator.clipboard) navigator.clipboard.writeText(asset.id).catch(function() {});
  };

  const remove = function() {
    onClose();
    if (!confirm('Delete "' + (asset.display_name || asset.name) + '" permanently?\nThis removes the database row and the S3 objects.\nThis cannot be undone.')) return;
    window.ZAMPP_API.fetch('/assets/' + asset.id + '?hard=true', { method: 'DELETE' })
      .then(onChanged)
      .catch(function(e) { alert('Delete failed: ' + e.message); });
  };

  return (

{asset.display_name || asset.name}

      <button onClick={function() { onClose(); onOpen(); }}><Icon name="play" size={11} />Open</button>
      <button onClick={rename}><Icon name="edit" size={11} />Rename…</button>

      {(bins && bins.length > 0) ? (
        <>

Move to bin

          {bins.slice(0, 10).map(function(b) {
            const isCurrent = asset.bin_id === b.id;
            return (
              <button key={b.id} onClick={function() { moveToBin(b.id); }} disabled={isCurrent}>
                <Icon name="folder" size={11} />{b.name}{isCurrent && current}
              </button>
            );
          })}
          {asset.bin_id && (
            <button onClick={function() { moveToBin(null); }}>
              <Icon name="x" size={11} />Remove from bin
            </button>
          )}
        </>
      ) : (

No bins — create one in a project

      )}

      <button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
      <button className="danger" onClick={remove}><Icon name="trash" size={11} />Delete permanently</button>

  );
}

function AssetCard({ asset, onOpen, onContextMenu }) {
  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;


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

// screens-ingest.jsx — Upload, Recorders, Capture, Monitors

/* ===== Upload helpers ===== */
const _SIMPLE_MAX = 50 * 1024 * 1024;  // 50 MB → simple upload
const _PART_SIZE  = 10 * 1024 * 1024;  // 10 MB chunks for multipart

function _xhrPost(url, formData, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.withCredentials = true;
    xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded, e.total); };
    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); }
      } else {
        let msg = xhr.status + ' ' + xhr.statusText;
        try { const j = JSON.parse(xhr.responseText); msg = j.error || j.message || msg; } catch {}
        reject(new Error(msg));
      }
    };
    xhr.onerror = () => reject(new Error('Network error'));
    xhr.open('POST', url);
    xhr.send(formData);
  });
}

async function _uploadFile(file, projectId, onProgress) {
  const mime = file.type || 'application/octet-stream';

  if (file.size <= _SIMPLE_MAX) {
    const fd = new FormData();
    fd.append('file', file, file.name);
    fd.append('filename', file.name);
    fd.append('projectId', projectId);
    fd.append('contentType', mime);
    return _xhrPost('/api/v1/upload/simple', fd,
      (loaded, total) => onProgress(Math.round((loaded / total) * 100)));
  }

  // — Multipart —
  const init = await window.ZAMPP_API.fetch('/upload/init', {
    method: 'POST',
    body: JSON.stringify({ filename: file.name, fileSize: file.size, contentType: mime, projectId }),
  });
  const { assetId, uploadId, key } = init;
  const totalParts = Math.ceil(file.size / _PART_SIZE);
  const parts = [];

  for (let i = 0; i < totalParts; i++) {
    const chunk = file.slice(i * _PART_SIZE, Math.min((i + 1) * _PART_SIZE, file.size));
    const fd = new FormData();
    fd.append('file', chunk, file.name);
    fd.append('uploadId', uploadId);
    fd.append('key', key);
    fd.append('partNumber', String(i + 1));
    const partRes = await _xhrPost('/api/v1/upload/part', fd,
      (loaded, total) => onProgress(Math.round(((i + loaded / total) / totalParts) * 100)));
    parts.push({ PartNumber: i + 1, ETag: partRes.etag || partRes.ETag });
  }

  await window.ZAMPP_API.fetch('/upload/complete', {
    method: 'POST',
    body: JSON.stringify({ uploadId, key, assetId, parts }),
  });
  return { id: assetId };
}

/* ===== Upload ===== */
function Upload({ navigate }) {
  const { PROJECTS } = window.ZAMPP_DATA;
  const [files, setFiles] = React.useState([]);
  const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');

  const updateFile = React.useCallback((id, patch) => {
    setFiles(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f));
  }, []);

  const startUpload = React.useCallback((entry, pid) => {
    _uploadFile(entry.file, pid, (pct) => updateFile(entry.id, { progress: pct }))
      .then(() => updateFile(entry.id, { status: 'done', progress: 100 }))
      .catch(e => updateFile(entry.id, { status: 'error', progress: 0, error: e.message }));
  }, [updateFile]);

  const handleDrop = React.useCallback((e) => {
    e.preventDefault();
    const dropped = Array.from(e.dataTransfer ? e.dataTransfer.files : e.target.files);
    const pid = projectId || PROJECTS[0]?.id || '';
    const newEntries = dropped.map((f, i) => ({
      id: Date.now() + i,
      name: f.name,
      size: window.ZAMPP_API.fmtSize(f.size),
      file: f,
      progress: 0,
      status: 'uploading',
      error: null,
    }));
    setFiles(prev => [...prev, ...newEntries]);
    newEntries.forEach(entry => startUpload(entry, pid));
  }, [projectId, startUpload]);

  return (

Upload

        Drop video, audio, or stills — we proxy and index automatically.

            Project
            <select className="field-input" value={projectId} onChange={e => setProjectId(e.target.value)}
              style={{ appearance: 'auto' }}>
              {PROJECTS.length === 0
                ? <option value="">No projects</option>
                : PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
            </select>

 e.preventDefault()}
          onClick={() => {
            const inp = document.createElement('input');
            inp.type = 'file'; inp.multiple = true;
            inp.onchange = handleDrop;
            inp.click();
          }}>
          <Icon name="upload" size={32} style={{ color: 'var(--text-3)' }} />

Drop files here or click to browse

Video, audio, and image files

            {['MOV', 'MP4', 'MXF', 'ProRes', 'DNxHR', 'WAV', 'AIFF'].map(f =>
              {f})}

        {files.length > 0 && (

              Queue {files.length}

              <button className="btn ghost sm" onClick={() => setFiles(f => f.filter(x => x.status === 'uploading'))}>Clear done</button>

              {files.map(f => (

                  <Icon name={f.name.match(/\.(wav|aif|mp3|aiff)$/i) ? 'audio' : 'video'} size={16}
                    style={{ color: 'var(--text-3)' }} />

                      {f.name}
                      {f.size}

                    {f.status === 'error' && (

{f.error}

                    )}

                    {f.status === 'done' ? '✓ done'
                     : f.status === 'error' ? '✗ failed'
                     : f.progress + '%'}

              ))}

        )}

  );
}

/* ===== Live preview (HLS) ====================================
   Shared by RecorderRow + MonitorTile. The capture container writes
   HLS segments to /live/{assetId}/index.m3u8 (see capture-manager.js
   and nginx.conf); we attach hls.js to a <video> when a recorder is
   actively recording and has a live asset.
   ============================================================ */
function HlsPreview({ assetId, muted = true, controls = false, className }) {
  const videoRef = React.useRef(null);
  const [err, setErr] = React.useState(null);

  React.useEffect(() => {
    if (!assetId || !videoRef.current) return;
    const url = '/live/' + assetId + '/index.m3u8';
    const v = videoRef.current;

    // Safari can play HLS natively; everything else needs hls.js.
    if (v.canPlayType('application/vnd.apple.mpegurl')) {
      v.src = url;
      const onErr = () => setErr('playback failed');
      v.addEventListener('error', onErr);
      return () => v.removeEventListener('error', onErr);
    }
    if (!window.Hls) { setErr('hls.js missing'); return; }

    const hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true });
    hls.loadSource(url);
    hls.attachMedia(v);
    hls.on(window.Hls.Events.ERROR, (_e, data) => {
      if (data.fatal) setErr(data.details || 'hls error');
    });
    return () => { try { hls.destroy(); } catch (_) {} };
  }, [assetId]);

  return (

      <video
        ref={videoRef}
        autoPlay
        playsInline
        muted={muted}
        controls={controls}
        style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
      />
      {err && (

          {err}

      )}

  );
}

/* ===== Recorders ===== */
function _normRecorder(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, '00') + ':' +
              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 Recorders({ navigate, onNew }) {
  const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA.RECORDERS);

  const refresh = React.useCallback(() => {
    window.ZAMPP_API.fetch('/recorders')
      .then(raw => {
        const norm = (raw || []).map(_normRecorder);
        window.ZAMPP_DATA.RECORDERS = norm;
        setRecorders(norm);
      })
      .catch(() => {});
  }, []);

  React.useEffect(() => {
    refresh();
    const id = setInterval(refresh, 10000);
    return () => clearInterval(id);
  }, []);

  const liveCount = recorders.filter(r => r.status === 'recording').length;
  const errCount  = recorders.filter(r => r.status === 'error').length;

  return (

Recorders

        Live ingest from SRT, RTMP, and SDI sources

        {(liveCount > 0 || errCount > 0) && (

            {liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}

        )}
        <button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
        <button className="btn primary" onClick={onNew}><Icon name="plus" />New recorder</button>

        {recorders.length === 0 ? (

            No recorders configured.

<button className="btn primary" onClick={onNew}><Icon name="plus" />Add recorder</button>

        ) : (

            {recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={refresh} />)}

        )}

  );
}

function RecorderRow({ recorder: initialRecorder, onRefresh }) {
  const [recorder, setRecorder] = React.useState(initialRecorder);
  const [pending, setPending] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const [liveStatus, setLiveStatus] = React.useState(null);
  const [clipName, setClipName] = React.useState('');
  const isRec = recorder.status === 'recording';

  React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);

  // Poll the status endpoint every 3s while recording for live feedback.
  React.useEffect(() => {
    if (!isRec) { setLiveStatus(null); return; }
    const poll = () => {
      window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/status')
        .then(s => setLiveStatus(s))
        .catch(() => {});
    };
    poll();
    const id = setInterval(poll, 3000);
    return () => clearInterval(id);
  }, [isRec, recorder.id]);

  const displayElapsed = React.useMemo(() => {
    if (liveStatus && liveStatus.duration != null) {
      const d = Math.max(0, liveStatus.duration);
      return String(Math.floor(d / 3600)).padStart(2, '0') + ':' +
             String(Math.floor((d % 3600) / 60)).padStart(2, '0') + ':' +
             String(d % 60).padStart(2, '0');
    }
    return recorder.elapsed;
  }, [liveStatus, recorder.elapsed]);

  const displaySignal = liveStatus
    ? (liveStatus.signal || '—')
    : (isRec ? 'connecting…' : '—');

  const signalColor = displaySignal === 'receiving' ? 'var(--success)'
    : displaySignal === 'stopped' ? 'var(--danger)'
    : 'var(--text-3)';

  const toggle = () => {
    if (pending) return;
    const action = isRec ? 'stop' : 'start';
    setPending(true);
    setErr(null);
    setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
    // Ship the operator-typed clip name on start; stop has no body.
    const body = (action === 'start' && clipName.trim())
      ? JSON.stringify({ clipName: clipName.trim() })
      : undefined;
    window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST', body })
      .then(() => {
        setPending(false);
        // Clear the input on a successful stop so the next take starts fresh.
        if (action === 'stop') setClipName('');
        onRefresh();
      })
      .catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
  };

  const handleDelete = () => {
    if (!window.confirm('Delete recorder "' + recorder.name + '"?\nThis will stop any active recording and cannot be undone.')) return;
    window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'DELETE' })
      .then(() => onRefresh())
      .catch(e => setErr(e.message || 'Delete failed'));
  };

  return (

        {isRec && recorder.live_asset_id
          ? <HlsPreview assetId={recorder.live_asset_id} />
          : isRec
            ? <LiveStrip seed={recorder.id.length * 3} count={6} />
            :
<Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} />
}

          {recorder.name}

            <StatusDot status={recorder.status} /> {recorder.status.toUpperCase()}

          {recorder.source}

{recorder.url}

          {recorder.codec}·
          {recorder.res}

        {err &&
{err}
}
        {liveStatus?.lastError && isRec && (

{liveStatus.lastError}

        )}

Elapsed

{displayElapsed}

Signal

{displaySignal}

        {liveStatus?.currentFps != null && (

FPS

{Number(liveStatus.currentFps).toFixed(1)}

        )}

        {!isRec && (
          <input
            className="field-input"
            value={clipName}
            onChange={e => setClipName(e.target.value)}
            placeholder="Clip name (optional)"
            disabled={pending}
            maxLength={80}
            onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
            style={{ width: 180, padding: '5px 8px', fontSize: 12 }}
            title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
          />
        )}
        {isRec
          ? <button className="btn danger sm" onClick={toggle} disabled={pending}>
              {pending ? '…' : <>Stop</>}
            </button>
          : <button className="btn subtle sm" onClick={toggle} disabled={pending}>
              {pending ? '…' : <>Record</>}
            </button>}
        <button className="icon-btn" onClick={handleDelete} title="Delete recorder" style={{ color: 'var(--text-3)' }}>
          <Icon name="x" />
        </button>

  );
}

function badgeForStatus(s) {
  return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
}

/* ===== Capture ===== */
function Capture({ navigate }) {
  const [devices, setDevices] = React.useState([]);
  const [activeIdx, setActiveIdx] = React.useState(0);

  const loadDevices = () => {
    window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
      .then(devs => setDevices(Array.isArray(devs) ? devs : []))
      .catch(() => setDevices([]));
  };

  React.useEffect(() => { loadDevices(); }, []);

  if (devices.length === 0) {
    return (

Capture

          DeckLink SDI ingest

          <button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>

            No DeckLink devices found in cluster.

    );
  }

  const active = devices[activeIdx] || devices[0];

  return (

Capture

        DeckLink SDI ingest — {devices.length} device{devices.length > 1 ? 's' : ''} in cluster

        <button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>

          {devices.map((d, i) => (
            <button key={i} className={'btn ' + (activeIdx === i ? 'primary' : 'ghost') + ' sm'} onClick={() => setActiveIdx(i)}>
              {d.model || d.device || 'DeckLink'} — {d.hostname}
            </button>
          ))}

            <StatusDot status={active.online ? 'online' : 'offline'} />

{active.model || active.device || 'DeckLink'}

{active.hostname} · {active.ip_address}

Connect a source and click Refresh to see port status.

  );
}

/* ===== Monitors ===== */
function Monitors({ navigate }) {
  const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA.RECORDERS);
  const [grid, setGrid] = React.useState(4);

  React.useEffect(() => {
    const refresh = () => {
      window.ZAMPP_API.fetch('/recorders')
        .then(raw => {
          const norm = (raw || []).map(_normRecorder);
          window.ZAMPP_DATA.RECORDERS = norm;
          setRecorders(norm);
        })
        .catch(() => {});
    };
    refresh();
    const id = setInterval(refresh, 5000);
    return () => clearInterval(id);
  }, []);

  const videoFeeds = recorders.filter(r => !r.audio);
  const audioFeeds = recorders.filter(r => r.audio).map(r => ({ ...r, kind: 'audio' }));
  const allFeeds = [...videoFeeds.map(r => ({ ...r, kind: 'video' })), ...audioFeeds];
  const feeds = allFeeds.slice(0, grid * grid);

  return (

Monitors

        Multi-cam live monitoring

          {[2, 3, 4].map(n => (
            <button key={n} className={grid === n ? 'active' : ''} onClick={() => setGrid(n)}>{n}×{n}</button>
          ))}

        {feeds.length === 0 ? (

No active feeds. Start a recorder to see live video here.

        ) : (

            {feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}

        )}

  );
}

function MonitorTile({ feed, seed }) {
  const [levels, setLevels] = React.useState([0.65, 0.78]);
  const isLive = feed.status === 'recording';

  React.useEffect(() => {
    if (!isLive) return;
    const id = setInterval(() => {
      setLevels([0.3 + Math.random() * 0.55, 0.3 + Math.random() * 0.55]);
    }, 180);
    return () => clearInterval(id);
  }, [isLive]);

  if (feed.kind === 'audio') {
    return (

          <Waveform seed={seed * 7} color="var(--accent)" />

          <AudioMeter level={levels[0]} vertical />
          <AudioMeter level={levels[1]} vertical />

          LIVE
          {feed.name}

    );
  }

  return (

      {isLive && feed.live_asset_id
        ? <HlsPreview assetId={feed.live_asset_id} />
        : <FauxFrame />}
      {isLive &&
}

        {isLive                    && REC}
        {feed.status === 'stopped' && IDLE}
        {feed.status === 'idle'    && IDLE}
        {feed.status === 'error'   && ERR}

      {isLive && (

          <AudioMeter level={levels[0]} vertical />
          <AudioMeter level={levels[1]} vertical />

      )}

        {feed.name}
        {feed.elapsed && feed.elapsed !== '—' && {feed.elapsed}}

  );
}

/* ===== Schedule ===== */

const _STATUS_BADGE = {
  pending:   { cls: 'neutral',  label: 'pending' },
  running:   { cls: 'success',  label: 'recording' },
  completed: { cls: 'accent',   label: 'completed' },
  cancelled: { cls: 'warning',  label: 'cancelled' },
  failed:    { cls: 'danger',   label: 'failed' },
};

function _fmtWhen(iso) {
  if (!iso) return '—';
  const d = new Date(iso);
  // Local-time, short, human; e.g. "May 22 · 7:30 PM"
  return d.toLocaleString(undefined, {
    month: 'short', day: 'numeric',
    hour: 'numeric', minute: '2-digit',
  });
}

function _durationMin(startISO, endISO) {
  return Math.round((new Date(endISO) - new Date(startISO)) / 60000);
}

function Schedule({ navigate }) {
  const [schedules, setSchedules]   = React.useState(null);
  const [recorders, setRecorders]   = React.useState([]);
  const [showNew, setShowNew]       = React.useState(false);
  const [filter, setFilter]         = React.useState('upcoming');

  const load = React.useCallback(() => {
    window.ZAMPP_API.fetch('/schedules?status=' + filter)
      .then(d => setSchedules(d.schedules || []))
      .catch(() => setSchedules([]));
  }, [filter]);

  React.useEffect(() => { load(); }, [load]);
  React.useEffect(() => { window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => setRecorders([])); }, []);

  // Auto-refresh every 10s so the list reflects the tick loop's transitions
  React.useEffect(() => {
    const t = setInterval(load, 10_000);
    return () => clearInterval(t);
  }, [load]);

  const cancel = (s) => {
    if (!confirm(`Cancel scheduled recording "${s.name}"?`)) return;
    window.ZAMPP_API.fetch('/schedules/' + s.id + '/cancel', { method: 'POST' })
      .then(load)
      .catch(e => alert('Cancel failed: ' + e.message));
  };
  const remove = (s) => {
    if (!confirm(`Delete schedule "${s.name}"?`)) return;
    window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' })
      .then(load)
      .catch(e => alert('Delete failed: ' + e.message));
  };

  return (

Schedule

        Plan recorder windows · {schedules?.length || 0} {filter}

          <button className={filter === 'upcoming' ? 'active' : ''} onClick={() => setFilter('upcoming')}>Upcoming</button>
          <button className={filter === 'past' ? 'active' : ''}     onClick={() => setFilter('past')}>Past</button>
          <button className={filter === 'all' ? 'active' : ''}      onClick={() => setFilter('all')}>All</button>

        <button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
        <button className="btn primary" onClick={() => setShowNew(true)} disabled={recorders.length === 0}>
          <Icon name="plus" />New schedule
        </button>

        {schedules === null && (

Loading…

        )}
        {schedules !== null && schedules.length === 0 && (

📅

No {filter} recordings

            {filter === 'upcoming' && recorders.length === 0 && (

Create a recorder first, then schedule it here.

            )}
            {filter === 'upcoming' && recorders.length > 0 && (

Click New schedule to plan a future recording.

            )}

        )}
        {schedules !== null && schedules.length > 0 && (

Name

Recorder

Starts

Duration

Recurrence

Status

            {schedules.map(s => {
              const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending;
              return (

                    {s.name}
                    {s.error_message && (

{s.error_message}

                    )}

{s.recorder_name || s.recorder_id.slice(0, 8)}

{_fmtWhen(s.start_at)}

{_durationMin(s.start_at, s.end_at)} min

{s.recurrence === 'none' ? 'one-shot' : s.recurrence}

<span className={`badge ${badge.cls}`}>{badge.label}

                    {(s.status === 'pending' || s.status === 'running') && (
                      <button className="btn ghost sm" onClick={() => cancel(s)}>Cancel</button>
                    )}
                    {(s.status === 'completed' || s.status === 'failed' || s.status === 'cancelled' || s.status === 'pending') && (
                      <button className="btn ghost sm" onClick={() => remove(s)} title="Delete schedule row">Delete</button>
                    )}

              );
            })}

        )}

      {showNew && <NewScheduleModal recorders={recorders} onClose={() => setShowNew(false)} onCreated={() => { setShowNew(false); load(); }} />}

  );
}

function NewScheduleModal({ recorders, onClose, onCreated }) {
  // Default: start in 5 minutes, run for 30 min — gives the operator something
  // sensible the moment the modal opens.
  const now = new Date();
  now.setSeconds(0, 0);
  const startDefault = new Date(now.getTime() + 5 * 60 * 1000);
  const endDefault   = new Date(now.getTime() + 35 * 60 * 1000);
  const toLocalInput = (d) => {
    const tz = d.getTimezoneOffset() * 60_000;
    return new Date(d.getTime() - tz).toISOString().slice(0, 16);
  };

  const [form, setForm] = React.useState({
    name: '',
    recorder_id: recorders[0]?.id || '',
    start_at: toLocalInput(startDefault),
    end_at:   toLocalInput(endDefault),
    recurrence: 'none',
  });
  const [saving, setSaving] = React.useState(false);
  const [err, setErr] = React.useState(null);

  const set = (k, v) => setForm(p => ({ ...p, [k]: v }));

  const submit = () => {
    setErr(null);
    if (!form.name.trim())   return setErr('Name is required');
    if (!form.recorder_id)   return setErr('Pick a recorder');
    if (!form.start_at)      return setErr('Start time is required');
    if (!form.end_at)        return setErr('End time is required');
    if (new Date(form.end_at) <= new Date(form.start_at)) return setErr('End must be after start');

    // Datetime-local inputs are in the browser's local zone; ship as ISO so
    // Postgres stores them as TIMESTAMPTZ properly.
    const body = {
      name:        form.name.trim(),
      recorder_id: form.recorder_id,
      start_at:    new Date(form.start_at).toISOString(),
      end_at:      new Date(form.end_at).toISOString(),
      recurrence:  form.recurrence,
    };

    setSaving(true);
    window.ZAMPP_API.fetch('/schedules', { method: 'POST', body: JSON.stringify(body) })
      .then(() => onCreated())
      .catch(e => { setSaving(false); setErr(e.message || 'Failed to schedule'); });
  };

  const onKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) submit(); };

  return (

 e.stopPropagation()}>

New scheduled recording

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

            Name
            <input className="field-input" autoFocus value={form.name}
                   onChange={e => set('name', e.target.value)}
                   onKeyDown={onKey} placeholder="Morning service stream" />

            Recorder
            <select className="field-input" value={form.recorder_id}
                    onChange={e => set('recorder_id', e.target.value)}
                    style={{ appearance: 'auto' }}>
              {recorders.length === 0 && <option value="">— No recorders defined —</option>}
              {recorders.map(r => (
                <option key={r.id} value={r.id}>
                  {r.name} · {r.source_type?.toUpperCase() || '?'}
                </option>
              ))}
            </select>

              Start
              <input className="field-input mono" type="datetime-local"
                     value={form.start_at}
                     onChange={e => set('start_at', e.target.value)} />

              End
              <input className="field-input mono" type="datetime-local"
                     value={form.end_at}
                     onChange={e => set('end_at', e.target.value)} />

            Recurrence
            <select className="field-input" value={form.recurrence}
                    onChange={e => set('recurrence', e.target.value)}
                    style={{ appearance: 'auto' }}>
              <option value="none">One-shot (no repeat)</option>
              <option value="daily">Daily</option>
              <option value="weekly">Weekly</option>
            </select>

              Recurring schedules queue the next occurrence as soon as the current one completes.

          {err &&
{err}
}

          <button className="btn ghost sm" onClick={onClose}>Cancel</button>
          <button className="btn primary sm" onClick={submit} disabled={saving}>
            {saving ? 'Scheduling…' : 'Schedule recording'}
          </button>

  );
}

Object.assign(window, { Upload, Recorders, Capture, Monitors, Schedule });


Found a major hit in screens-library.jsx. Let me fetch the remaining files in parallel.


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

// screens-jobs.jsx

function Jobs({ navigate }) {
  const [tab, setTab] = React.useState('all');
  const [jobs, setJobs] = React.useState(window.ZAMPP_DATA.JOBS);
  const [lastFetch, setLastFetch] = React.useState(Date.now());

  const 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,
    };
  };

  const refresh = React.useCallback(() => {
    window.ZAMPP_API.fetch('/jobs')
      .then(raw => {
        const norm = (raw || []).map(normalizeJob);
        window.ZAMPP_DATA.JOBS = norm;
        setJobs(norm);
        setLastFetch(Date.now());
      })
      .catch(() => {});
  }, []);

  React.useEffect(() => {
    const i = setInterval(refresh, 5000);
    return () => clearInterval(i);
  }, [refresh]);

  const handleRetry = React.useCallback((job) => {
    window.ZAMPP_API.fetch('/jobs/' + job.id + '/retry', { method: 'POST' })
      .then(() => refresh())
      .catch(e => alert('Retry failed: ' + e.message));
  }, [refresh]);

  const handleDelete = React.useCallback((job) => {
    if (!window.confirm('Remove this job from the queue?')) return;
    window.ZAMPP_API.fetch('/jobs/' + job.id, { method: 'DELETE' })
      .then(() => setJobs(prev => prev.filter(j => j.id !== job.id)))
      .catch(e => alert('Delete failed: ' + e.message));
  }, []);

  const counts = {
    all:     jobs.length,
    running: jobs.filter(j => j.status === 'running').length,
    queued:  jobs.filter(j => j.status === 'queued').length,
    done:    jobs.filter(j => j.status === 'done').length,
    failed:  jobs.filter(j => j.status === 'failed').length,
  };
  const filtered = tab === 'all' ? jobs : jobs.filter(j => j.status === tab);

  return (

Jobs

        Proxy generation, transcoding, and processing queue

        <button className="btn ghost sm" onClick={refresh}>
          <Icon name="refresh" />Refresh
        </button>

Running

{counts.running}

{counts.queued} queued

Completed

{counts.done}

Total done

Failed

{counts.failed}

 0 ? 'var(--warning)' : '' }}>
              {counts.failed > 0 ? 'Needs attention' : 'All clear'}

Total jobs

{counts.all}

Updated {Math.round((Date.now()-lastFetch)/1000)}s ago

          {[
            { id: 'all',     label: 'All · ' + counts.all },
            { id: 'running', label: 'Running · ' + counts.running },
            { id: 'queued',  label: 'Queued · ' + counts.queued },
            { id: 'done',    label: 'Done · ' + counts.done },
            { id: 'failed',  label: 'Failed · ' + counts.failed },
          ].map(t => (
            <button key={t.id} className={tab === t.id ? 'active' : ''} onClick={() => setTab(t.id)}>{t.label}</button>
          ))}

Job

Asset

Node

Progress

ETA

Priority

          {filtered.length === 0
            ?
No jobs in this category.

            : filtered.map(j => <JobRow key={j.id} job={j} onRetry={handleRetry} onDelete={handleDelete} />)}

  );
}

function JobRow({ job, onRetry, onDelete }) {
  const iconMap = { Proxy: 'proxy', Transcode: 'film', Thumbnail: 'image', Conform: 'layers' };
  return (

<StatusDot status={job.status} />

        <Icon name={iconMap[job.kind] || 'jobs'} size={13} style={{ color: 'var(--text-3)' }} />
        {job.kind}

{job.asset}

{job.node}

        {job.status === 'running' && (

            {Math.round(job.progress)}%

        )}
        {job.status === 'done' && <Icon name="check" size={12} /> Complete}
        {job.status === 'queued' && Waiting…}
        {job.status === 'failed' && <Icon name="alert" size={12} />{job.error || 'Failed'}}

{job.eta}

{job.priority}

        {job.status === 'failed' && (
          <button className="btn ghost sm" onClick={() => onRetry(job)}><Icon name="refresh" />Retry</button>
        )}
        {(job.status === 'queued' || job.status === 'done') && (
          <button className="icon-btn" title="Remove job" onClick={() => onDelete(job)}><Icon name="x" /></button>
        )}

  );
}

window.Jobs = Jobs;


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

// screens-editor.jsx — Editor (timeline)

function _fmtTimecode(ms) {
  const s = Math.floor(ms / 1000);
  const f = Math.floor((ms % 1000) / (1000 / 30));
  return String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
         String(Math.floor((s % 3600) / 60)).padStart(2, '0') + ':' +
         String(s % 60).padStart(2, '0') + ':' +
         String(f).padStart(2, '0');
}

function Editor() {
  const { ASSETS } = window.ZAMPP_DATA;
  const [playing, setPlaying] = React.useState(false);
  const [currentMs, setCurrentMs] = React.useState(0);

  React.useEffect(() => {
    if (!playing) return;
    const i = setInterval(() => setCurrentMs(t => t + 100), 100);
    return () => clearInterval(i);
  }, [playing]);

  return (

        New sequence

        <button className="btn ghost sm"><Icon name="download" />Export</button>
        <button className="btn primary sm">Publish</button>

            Project bin

            <button className="icon-btn"><Icon name="search" size={12} /></button>

            {ASSETS.length === 0 ? (

No assets in library.

            ) : ASSETS.slice(0, 12).map(a => (

<AssetThumb asset={a} />

{a.name}

{a.duration}

            ))}

            <FauxFrame />
            {!playing && (
              <button className="player-play-overlay" onClick={() => setPlaying(true)}>
                <Icon name="play" size={28} />
              </button>
            )}

{_fmtTimecode(currentMs)}

            <button className="icon-btn" onClick={() => setCurrentMs(0)}><Icon name="arrowLeft" size={14} /></button>
            <button className="icon-btn" onClick={() => setPlaying(p => !p)}>
              <Icon name={playing ? 'pause' : 'play'} size={14} />
            </button>
            <button className="icon-btn"><Icon name="arrowRight" size={14} /></button>

            <button className="btn ghost sm">Mark in</button>
            <button className="btn ghost sm">Mark out</button>
            <button className="btn subtle sm">Add to timeline</button>

Inspector

            <InspGroup title="Transform">
              <InspRow label="Position" value="0, 0" />
              <InspRow label="Scale" value="100%" />
              <InspRow label="Rotation" value="0°" />
            </InspGroup>
            <InspGroup title="Color">
              <InspRow label="Exposure" value="0.0" />
              <InspRow label="Contrast" value="0.0" />
              <InspRow label="Saturation" value="0.0" />
            </InspGroup>
            <InspGroup title="Audio">
              <InspRow label="Level" value="0.0 dB" />
              <InspRow label="Pan" value="C" />
            </InspGroup>

      <EditorTimeline currentMs={currentMs} total={60} clips={[]} />

      {/* IN DEVELOPMENT overlay */}

In Development

Non-linear Editor

            Timeline editing, multi-track audio mixing,

            GPU-accelerated export, and color grading

            are coming in a future release.

  );
}

function InspGroup({ title, children }) {
  return (

{title}

{children}

  );
}
function InspRow({ label, value }) {
  return (

      {label}
      {value}

  );
}

function EditorTimeline({ currentMs, total = 60, clips = [] }) {
  const playheadPct = total > 0 ? ((currentMs / 1000) / total) * 100 : 0;
  return (

        Timeline

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

            {i % 2 === 0 && {`00:${String(i * 5).padStart(2, '0')}`}}

        ))}

      {clips.length === 0 && (

Drop assets from the bin to build a sequence.

      )}
      <div className="timeline-playhead" style={{ left: `calc(40px + (100% - 40px) * ${playheadPct / 100})` }} />

  );
}

window.Editor = Editor;


Error: result (77,565 characters) exceeds maximum allowed tokens. Output has been saved to /home/node/.claude/projects/-home-node/512de26d-e7df-41bb-aacd-91c5f85e82b5/tool-results/mcp-claude_ai_mcp-server-forgejo_forgejo_get_file-1779508726994.txt.
Format: JSON with schema: {result: string}
- For targeted queries (find a value, filter by field): use jq on the file directly.
- For analysis or summarization that requires reading the full content: first probe the structure (e.g., jq 'type, length, keys?' /home/node/.claude/projects/-home-node/512de26d-e7df-41bb-aacd-91c5f85e82b5/tool-results/mcp-claude_ai_mcp-server-forgejo_forgejo_get_file-1779508726994.txt), then extract slices with jq or python — Read's line-based offset/limit will not chunk this file.
- If the Agent tool is available, do this inside a subagent so the full output stays out of your main context. Give it the instruction above verbatim, and be explicit about what it must return — e.g. "/home/node/.claude/projects/-home-node/512de26d-e7df-41bb-aacd-91c5f85e82b5/tool-results/mcp-claude_ai_mcp-server-forgejo_forgejo_get_file-1779508726994.txt is JSON with schema: {result: string}; probe the structure with jq (type/length/keys), then extract and read the content in full with jq or python, then summarize and quote any key findings verbatim." A vague "summarize this" may lose detail.


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

// screens-asset.jsx — asset detail (Frame.io-style player, filmstrip, comments)

const { COMMENTS: SEED_COMMENTS } = window.ZAMPP_DATA;

// Simple gradient palette — replaces the missing thumbGrad function
const _FRAME_GRADIENTS = [
  'linear-gradient(135deg,#1a1f2e 0%,#2a3045 100%)',
  'linear-gradient(135deg,#1e2030 0%,#2d2040 100%)',
  'linear-gradient(135deg,#0d1520 0%,#1a2535 100%)',
  'linear-gradient(135deg,#1f1a2e 0%,#2a2040 100%)',
  'linear-gradient(135deg,#0f1e18 0%,#1a3028 100%)',
  'linear-gradient(135deg,#1e1510 0%,#302018 100%)',
  'linear-gradient(135deg,#1a1020 0%,#281830 100%)',
  'linear-gradient(135deg,#101828 0%,#182438 100%)',
  'linear-gradient(135deg,#1e2820 0%,#283830 100%)',
  'linear-gradient(135deg,#201820 0%,#302030 100%)',
  'linear-gradient(135deg,#181e28 0%,#202838 100%)',
];

function AssetDetail({ asset, onClose }) {
  const [playing, setPlaying] = React.useState(false);
  const [currentMs, setCurrentMs] = React.useState(0);
  const [tab, setTab] = React.useState("comments");
  const [showResolved, setShowResolved] = React.useState(false);
  const [comments, setComments] = React.useState(SEED_COMMENTS || []);
  const [newComment, setNewComment] = React.useState("");

  // Stream / video state
  const [streamUrl, setStreamUrl] = React.useState(null);
  const [streamType, setStreamType] = React.useState(null);
  const [streamLoading, setStreamLoading] = React.useState(false);
  const [videoDuration, setVideoDuration] = React.useState(0);
  const [retrying, setRetrying] = React.useState(false);
  const videoRef = React.useRef(null);

  const assetId = asset && asset.id;
  const totalMs = videoDuration > 0 ? videoDuration : parseDuration(asset.duration);

  // Fetch stream URL when asset changes
  React.useEffect(() => {
    if (!assetId) return;
    setStreamUrl(null);
    setStreamType(null);
    setVideoDuration(0);
    setCurrentMs(0);
    setPlaying(false);
    setStreamLoading(true);
    window.ZAMPP_API.fetch('/assets/' + assetId + '/stream')
      .then(function(r) {
        if (r && r.url) {
          setStreamUrl(r.url);
          setStreamType(r.type || 'mp4');
        }
      })
      .catch(function() {})
      .finally(function() { setStreamLoading(false); });
  }, [assetId]);

  // Wire hls.js for live HLS streams
  React.useEffect(() => {
    if (!streamUrl || streamType !== 'hls' || !videoRef.current) return;
    if (!window.Hls) return;
    const hls = new window.Hls();
    hls.loadSource(streamUrl);
    hls.attachMedia(videoRef.current);
    return function() { hls.destroy(); };
  }, [streamUrl, streamType]);

  // Fake playback timer — only used when no real video stream
  React.useEffect(() => {
    if (!playing || totalMs <= 0 || streamUrl) return;
    const i = setInterval(function() {
      setCurrentMs(function(t) {
        const next = t + 100;
        if (next >= totalMs) { setPlaying(false); return totalMs; }
        return next;
      });
    }, 100);
    return function() { clearInterval(i); };
  }, [playing, totalMs, streamUrl]);

  const togglePlay = function() {
    if (videoRef.current) {
      if (videoRef.current.paused) { videoRef.current.play(); }
      else { videoRef.current.pause(); }
    } else {
      setPlaying(function(p) { return !p; });
    }
  };

  const seek = function(ms) {
    const clamped = Math.max(0, Math.min(totalMs || 0, ms));
    setCurrentMs(clamped);
    if (videoRef.current) videoRef.current.currentTime = clamped / 1000;
  };

  const retryProcessing = function() {
    if (retrying) return;
    setRetrying(true);
    window.ZAMPP_API.fetch('/assets/' + assetId + '/retry', { method: 'POST' })
      .then(function() { window.alert('Re-queued for processing. Refresh in a moment.'); })
      .catch(function(e) { window.alert('Retry failed: ' + (e.message || 'unknown error')); })
      .finally(function() { setRetrying(false); });
  };

  const addComment = function() {
    if (!newComment.trim()) return;
    const t = msToTimecode(currentMs);
    setComments(function(c) {
      return [...c, {
        id: 'n' + Date.now(),
        who: "Zach Gaetano",
        avatar: "ZG",
        time: t,
        real: "just now",
        text: newComment,
        resolved: false,
        frame: Math.floor(currentMs / 1000 * 30),
      }];
    });
    setNewComment("");
  };

  const visibleComments = comments.filter(function(c) { return showResolved || !c.resolved; });

  const statusMessage =
    asset.status === 'processing' ? 'Processing…' :
    asset.status === 'live'       ? 'Live recording in progress' :
    asset.status === 'error'      ? 'Processing failed' :
                                    'Preview not yet available';

  return (

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

            {asset.name}
            <StatusDot status={asset.status} />

            {asset.project}·updated {asset.updated}

        <button className="btn ghost sm"><Icon name="download" />Download</button>
        <button className="btn subtle sm"><Icon name="check" />Approve</button>
        <button className="icon-btn"><Icon name="more" /></button>

              {streamUrl ? (
                <video
                  key={streamUrl}
                  ref={videoRef}
                  src={streamType !== 'hls' ? streamUrl : undefined}
                  style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block', background: '#000' }}
                  onTimeUpdate={function() { if (videoRef.current) setCurrentMs(videoRef.current.currentTime * 1000); }}
                  onLoadedMetadata={function() { if (videoRef.current) setVideoDuration(videoRef.current.duration * 1000); }}
                  onPlay={function() { setPlaying(true); }}
                  onPause={function() { setPlaying(false); }}
                  onEnded={function() { setPlaying(false); }}
                />
              ) : (
                <React.Fragment>
                  <FauxFrame seed={asset.seed || 1} />

                      {streamLoading ? (

Loading…

                      ) : (
                        <React.Fragment>

{statusMessage}

                          <StatusDot status={asset.status} />
                          {asset.status === 'error' && (
                            <button
                              className="btn ghost sm"
                              style={{ marginTop: 12, display: 'block', margin: '12px auto 0' }}
                              onClick={retryProcessing}
                              disabled={retrying}
                            >
                              {retrying ? 'Retrying…' : 'Retry processing'}
                            </button>
                          )}
                        </React.Fragment>
                      )}

                  {!streamLoading && !playing && totalMs > 0 && (
                    <button className="player-play-overlay" onClick={togglePlay}>
                      <Icon name="play" size={28} />
                    </button>
                  )}
                </React.Fragment>
              )}

                {visibleComments
                  .filter(function(c) { return Math.abs(parseDuration(c.time) - currentMs) < 200; })
                  .map(function(c) {
                    return (

{c.avatar}

{c.text}

                    );
                  })}

              {asset.status === "live" && (

                  LIVE · REC

              )}
              {totalMs > 0 && (

                  {msToTimecode(currentMs)}
                   / {asset.duration}

              )}

            {totalMs > 0 && (

                <button className="icon-btn" onClick={togglePlay}>
                  <Icon name={playing ? "pause" : "play"} size={14} />
                </button>
                {msToTimecode(currentMs)}
                <PlaybackBar current={currentMs} total={totalMs} onSeek={seek} comments={visibleComments} />
                {asset.duration}

                <button className="icon-btn"><Icon name="audio" size={14} /></button>
                <button className="icon-btn"><Icon name="layout" size={14} /></button>

            )}

            {totalMs > 0 && (
              <FilmStrip seed={asset.seed || 1} current={currentMs} total={totalMs} onSeek={seek} comments={visibleComments} />
            )}

            <button className={tab === "comments" ? "active" : ""} onClick={function() { setTab("comments"); }}>
              Comments {comments.length}
            </button>
            <button className={tab === "versions" ? "active" : ""} onClick={function() { setTab("versions"); }}>
              Versions
            </button>
            <button className={tab === "metadata" ? "active" : ""} onClick={function() { setTab("metadata"); }}>
              Metadata
            </button>
            <button className={tab === "audio" ? "active" : ""} onClick={function() { setTab("audio"); }}>
              Audio
            </button>

            {tab === "comments" && (

                <input type="checkbox" checked={showResolved} onChange={function(e) { setShowResolved(e.target.checked); }} /> Show resolved

            )}

            {tab === "comments" && (
              <CommentsList
                comments={visibleComments}
                onSeek={function(c) { seek(parseDuration(c.time)); }}
                onResolve={function(id) {
                  setComments(function(cs) { return cs.map(function(c) { return c.id === id ? Object.assign({}, c, { resolved: !c.resolved }) : c; }); });
                }}
              />
            )}
            {tab === "versions" && <VersionsTab />}
            {tab === "metadata" && <MetadataTab asset={asset} />}
            {tab === "audio" && <AudioTab asset={asset} />}

          <CommentComposer
            asset={asset}
            currentMs={currentMs}
            value={newComment}
            onChange={setNewComment}
            onSubmit={addComment}
          />

  );
}

function PlaybackBar({ current, total, onSeek, comments }) {
  const ref = React.useRef(null);
  const handle = function(e) {
    const r = ref.current.getBoundingClientRect();
    const p = (e.clientX - r.left) / r.width;
    onSeek(Math.max(0, Math.min(1, p)) * total);
  };
  const pct = total > 0 ? (current / total) * 100 : 0;
  return (

      {comments.map(function(c) {
        const ct = parseDuration(c.time);
        if (!ct || total <= 0) return null;
        const x = (ct / total) * 100;
        return (

            {c.avatar}

        );
      })}

  );
}

function FilmStrip({ seed, current, total, onSeek, comments }) {
  const ref = React.useRef(null);
  const frames = 28;
  const handle = function(e) {
    if (!ref.current || total <= 0) return;
    const r = ref.current.getBoundingClientRect();
    onSeek(((e.clientX - r.left) / r.width) * total);
  };
  const pct = total > 0 ? (current / total) * 100 : 0;
  return (

        {Array.from({ length: frames }).map(function(_, i) {
          return (

              <FauxFrame seed={(seed + i) % 6} />

          );
        })}

        {comments.map(function(c) {
          const ct = parseDuration(c.time);
          if (!ct || total <= 0) return null;
          const x = (ct / total) * 100;
          return (

              {c.avatar}

          );
        })}

        {[0, 0.25, 0.5, 0.75, 1].map(function(p) {
          return {msToTimecode(p * total)};
        })}

  );
}

function CommentsList({ comments, onSeek, onResolve }) {
  if (comments.length === 0) {
    return
No comments yet.
;
  }
  return (

      {comments.map(function(c) {
        return (

{c.avatar}

                {c.who}
                <button className="comment-time" onClick={function() { onSeek(c); }}><Icon name="clock" size={10} />{c.time}</button>
                {c.real}

                <button className="comment-action" onClick={function() { onResolve(c.id); }}>
                  <Icon name={c.resolved ? "refresh" : "check"} size={12} />
                </button>

{c.text}

              {c.resolved &&
✓ Resolved
}

        );
      })}

  );
}

function CommentComposer({ asset, currentMs, value, onChange, onSubmit }) {
  return (

        Reviewers

          {["ZG"].map(function(a, i) {
            return
{a}
;
          })}
          <button className="add-reviewer"><Icon name="plus" size={11} /></button>

        @ {msToTimecode(currentMs)}

      <textarea
        className="composer-input"
        placeholder="Leave a comment at this timecode…"
        value={value}
        onChange={function(e) { onChange(e.target.value); }}
        onKeyDown={function(e) { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) onSubmit(); }}
      />

        ⌘↵ to send
        <button className="btn primary sm" onClick={onSubmit} disabled={!value.trim()}>Comment</button>

  );
}

function VersionsTab() {
  return (

      Version history not yet available.

  );
}

function MetadataTab({ asset }) {
  const rows = [
    { k: "Filename",   v: asset.name },
    { k: "Duration",   v: asset.duration || '—' },
    { k: "Resolution", v: asset.res || '—' },
    { k: "Codec",      v: asset.codec || '—' },
    { k: "File size",  v: asset.size || '—' },
    { k: "Status",     v: asset.status || '—' },
    { k: "Updated",    v: asset.updated || '—' },
    { k: "Project",    v: asset.project || '—' },
  ];
  return (

      {rows.map(function(r) {
        return (

{r.k}

{r.v}

        );
      })}

  );
}

function AudioTab({ asset }) {
  return (

        <Waveform seed={3} color="var(--accent)" />
        <Waveform seed={7} color="var(--purple)" />

  );
}

function parseDuration(d) {
  if (!d || d === '—' || typeof d !== 'string') return 0;
  const parts = d.split(':');
  if (parts.length < 2) return 0;
  const nums = parts.map(Number);
  if (nums.some(isNaN)) return 0;
  if (nums.length === 3) return ((nums[0] * 60 + nums[1]) * 60 + nums[2]) * 1000;
  return (nums[0] * 60 + nums[1]) * 1000;
}

function msToTimecode(ms) {
  const total = Math.floor((ms || 0) / 1000);
  const h = Math.floor(total / 3600);
  const m = Math.floor((total % 3600) / 60);
  const s = total % 60;
  return String(h).padStart(2, "0") + ':' + String(m).padStart(2, "0") + ':' + String(s).padStart(2, "0");
}

function avatarColor(initials) {
  let h = 0;
  for (const c of (initials || '?')) h = (h * 31 + c.charCodeAt(0)) & 0xff;
  return 'linear-gradient(135deg, hsl(' + (h % 360) + ' 60% 50%), hsl(' + ((h + 60) % 360) + ' 60% 45%))';
}

Object.assign(window, { AssetDetail, msToTimecode, parseDuration, avatarColor });


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

// screens-home.jsx

function Home({ navigate }) {
  const { RECORDERS, JOBS, ASSETS, NODES } = window.ZAMPP_DATA;

  // Live (current-state) data from the boot-time data load
  const liveRecorders = RECORDERS.filter(r => r.status === 'recording').slice(0, 4);
  const runningJobs   = JOBS.filter(j => j.status === 'running' || j.status === 'queued');
  const recentAssets  = [...ASSETS].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)).slice(0, 6);

  // Real historic sparklines from /metrics/home — buckets the last 24h.
  const [metrics, setMetrics] = React.useState(null);
  React.useEffect(() => {
    let cancelled = false;
    const load = () => {
      window.ZAMPP_API.fetch('/metrics/home?hours=24')
        .then(d => { if (!cancelled) setMetrics(d); })
        .catch(() => {});
    };
    load();
    const t = setInterval(load, 30_000);
    return () => { cancelled = true; clearInterval(t); };
  }, []);

  const cards = metrics?.cards || {};
  const vals = (s) => Array.isArray(s) ? s.map(p => p.v) : [];

  // Card values come from /metrics so that "Library" reflects what's in the
  // DB right now, not whatever ZAMPP_DATA happens to be cached as on first load.
  const assetsTotal = cards.assets?.total ?? ASSETS.length;
  const liveCount   = cards.recorders?.live ?? liveRecorders.length;
  const totalRecs   = cards.recorders?.total ?? RECORDERS.length;
  const runningCount = cards.jobs?.running ?? runningJobs.length;
  const doneCount   = cards.jobs?.done_total ?? JOBS.filter(j => j.status === 'done').length;
  const failedCount = cards.jobs?.failed_total ?? JOBS.filter(j => j.status === 'failed').length;
  const nodesOnline = cards.cluster?.online ?? NODES.filter(n => n.status === 'online' || n.online === true).length;
  const nodesTotal  = cards.cluster?.total ?? NODES.length;

  // Sum the most recent hour of each bucketed series for the delta line so
  // the "+N this hour" hint always reflects the latest bucket.
  const lastBucket = (series) => (Array.isArray(series) && series.length ? series[series.length - 1].v : 0);
  const sumWindow = (series) => (Array.isArray(series) ? series.reduce((a, p) => a + p.v, 0) : 0);

  return (

Dragonflight

          {liveCount > 0 ? liveCount + ' live · ' : ''}
          {runningCount > 0 ? runningCount + ' job' + (runningCount > 1 ? 's running' : ' running') + ' · ' : ''}
          {assetsTotal.toLocaleString()} assets

 navigate('library')} style={{ cursor: 'pointer' }}>

<Icon name="library" size={12} /> Library

{assetsTotal.toLocaleString()}

            {sumWindow(cards.assets?.series) > 0
              ? '+' + sumWindow(cards.assets?.series) + ' added in last 24h'
              : 'Total assets'}

          <Sparkline data={vals(cards.assets?.series)} color="#5B7CFA" />

 navigate('recorders')} style={{ cursor: 'pointer' }}>

<Icon name="record" size={12} /> Live feeds

{liveCount}

{totalRecs} recorder{totalRecs === 1 ? '' : 's'} configured

          <Sparkline data={vals(cards.recorders?.series)} color="#2DD4A8" />

 navigate('jobs')} style={{ cursor: 'pointer' }}>

<Icon name="jobs" size={12} /> Jobs

{runningCount} / {doneCount} done

 0 ? 'var(--warning)' : '' }}>
            {failedCount > 0 ? failedCount + ' failed' : 'All clear'}
            {sumWindow(cards.jobs?.series_done) > 0 && (
              <> · {sumWindow(cards.jobs?.series_done)} completed in last 24h</>
            )}

          <Sparkline data={vals(cards.jobs?.series_done)} color="#B57CFA" />

 navigate('cluster')} style={{ cursor: 'pointer' }}>

<Icon name="hdd" size={12} /> Cluster nodes

{nodesOnline} / {nodesTotal} online

Heartbeat within 2 min

          <Sparkline data={vals(cards.cluster?.series)} color="#F5A623" />

          {liveRecorders.length > 0 && (
            <>
              <SectionHead title="Live now" onMore={() => navigate('recorders')} moreLabel="All recorders" />

                {liveRecorders.map(r => (

 navigate('recorders')}>

REC

                    {r.live_asset_id
                      ? <HlsPreview assetId={r.live_asset_id} />
                      : <FauxFrame />}

                      {r.name}
                      {r.elapsed}

                ))}

            </>
          )}

          <SectionHead title="Recently added" onMore={() => navigate('library')} moreLabel="All assets" />

            {recentAssets.length === 0 ? (

No assets yet.

            ) : recentAssets.map(a => (

                  <Icon name={a.type === 'audio' ? 'audio' : 'video'} size={12} />

                  {a.name}
                  {a.project && <> in {a.project}</>}

{a.updated}

            ))}

          <SectionHead title="Job queue" onMore={() => navigate('jobs')} moreLabel="View all" />

            {JOBS.length === 0
              ?
No jobs.

              : JOBS.slice(0, 5).map(j => <MiniJobRow key={j.id} job={j} />)}

          <SectionHead title="Cluster" onMore={() => navigate('cluster')} moreLabel="View all" />

            {NODES.length === 0
              ?
No nodes found.

              : NODES.slice(0, 4).map(n => {
                  const nodeId = n.id || n.hostname || n.name || 'node';
                  const isOnline = n.status === 'online' || n.online === true;
                  const cpuPct = n.cpu_percent ?? n.cpu ?? n.cpu_usage ?? null;
                  const memRaw = n.memory_used_gb ?? n.mem ?? (n.mem_used_mb != null ? n.mem_used_mb / 1024 : null);
                  const memGb = memRaw != null ? Number(memRaw) : null;
                  return (

                      <StatusDot status={isOnline ? 'online' : 'offline'} />
                      {nodeId}

                      {cpuPct != null && CPU {Math.round(cpuPct)}%}
                      {memGb != null && {memGb < 1 ? Math.round(memGb * 1024) + 'MB' : memGb.toFixed(1) + 'GB'} RAM}

                  );
                })}

  );
}

function SectionHead({ title, onMore, moreLabel = 'View all' }) {
  return (

{title}

      {onMore && (
        <button className="btn ghost sm" style={{ marginLeft: 'auto', whiteSpace: 'nowrap' }} onClick={onMore}>
          {moreLabel}<Icon name="arrowRight" size={11} />
        </button>
      )}

  );
}

function MiniJobRow({ job }) {
  return (

      <StatusDot status={job.status} />

          {job.kind}
          ·
          {job.asset}

        {job.status === 'running' && (

        )}
        {job.status === 'failed' &&
{job.error}
}

        {job.status === 'running' ? job.eta : job.status}

  );
}

window.Home = Home;