1000	        <button className="btn primary" onClick={addNode}><Icon name="plus" />Add node</button>
1001

1002

1003

1004

1005
<Icon name="cluster" size={12} />Nodes

1006
{NODES.length}

1007

1008

1009
<Icon name="cpu" size={12} />Avg CPU

1010
{Math.round(NODES.reduce((a, n) => a + (n.cpu || 0), 0) / NODES.length)} %

1011

1012

1013
<Icon name="gpu" size={12} />GPUs

1014
{NODES.reduce((a, n) => a + n.gpus.length, 0)}

1015

1016

1017
<Icon name="hdd" size={12} />Avg Memory

1018
{Math.round(NODES.reduce((a, n) => a + (n.mem || 0), 0) / NODES.length)} GB

1019

1020

1021
1022

1023

1024

1025	              Topology
1026	              {NODES.length} node{NODES.length === 1 ? '' : 's'}
1027

1028	            <svg viewBox={`0 0 ${W} ${H}`} style={{ display: "block", width: "100%", height: "auto" }}>
1029	              <defs>
1030	                <radialGradient id="nodeGlow">
1031	                  <stop offset="0%" stopColor="rgba(91,124,250,0.3)" />
1032	                  <stop offset="100%" stopColor="rgba(91,124,250,0)" />
1033	                </radialGradient>
1034	                <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
1035	                  <path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(255,255,255,0.025)" strokeWidth="1" />
1036	                </pattern>
1037	              </defs>
1038	              <rect width={W} height={H} fill="url(#grid)" />
1039	              {edges.map((e, i) => {
1040	                const x1 = e.from.x * W, y1 = e.from.y * H;
1041	                const x2 = e.to.x * W, y2 = e.to.y * H;
1042	                return (
1043	                  <g key={i}>
1044	                    <line x1={x1} y1={y1} x2={x2} y2={y2}
1045	                      stroke={e.alive ? "var(--accent)" : "var(--text-4)"}
1046	                      strokeWidth="1"
1047	                      strokeDasharray={e.alive ? "0" : "4 3"}
1048	                      opacity={e.alive ? 0.5 : 0.25}
1049	                    />
1050	                    {e.alive && (
1051	                      <circle r="3" fill="var(--accent)">
1052	                        <animateMotion dur={`${2 + i * 0.4}s`} repeatCount="indefinite"
1053	                          path={`M ${x1} ${y1} L ${x2} ${y2}`} />
1054	                      </circle>
1055	                    )}
1056	                  </g>
1057	                );
1058	              })}
1059	              {NODES.map(n => {
1060	                const cx = n.x * W, cy = n.y * H;
1061	                const isSelected = sel && sel.id === n.id;
1062	                const color = n.status === "online" ? "var(--success)" : "var(--text-4)";
1063	                return (
1064	                  <g key={n.id} transform={`translate(${cx}, ${cy})`}
1065	                    style={{ cursor: "pointer" }}
1066	                    onMouseEnter={() => setHovered(n.id)}
1067	                    onMouseLeave={() => setHovered(null)}
1068	                    onClick={() => setSelected(n)}>
1069	                    {n.status === "online" && (
1070	                      <circle r="44" fill="url(#nodeGlow)">
1071	                        <animate attributeName="r" values="34;48;34" dur="3s" repeatCount="indefinite" />
1072	                      </circle>
1073	                    )}
1074	                    <circle r={isSelected ? 26 : 22} fill="var(--bg-2)" stroke={isSelected ? "var(--accent)" : "var(--border-stronger)"} strokeWidth={isSelected ? 2 : 1} />
1075	                    <circle r="6" cx="-13" cy="-13" fill={color} />
1076	                    {n.role === "primary" && <path d="M -4 -2 L 0 2 L 4 -2 L 0 -6 Z" fill="var(--accent)" stroke="none" />}
1077	                    {n.role !== "primary" && <text textAnchor="middle" y="3" fill="var(--text-2)" fontSize="10" fontFamily="var(--font-mono)">{n.role[0].toUpperCase()}</text>}
1078	                    <text textAnchor="middle" y="40" fill={isSelected ? "var(--text-1)" : "var(--text-2)"} fontSize="11" fontWeight={isSelected ? 600 : 500}>{n.id}</text>
1079	                    <text textAnchor="middle" y="54" fill="var(--text-3)" fontSize="10" fontFamily="var(--font-mono)">{n.ip}</text>
1080	                  </g>
1081	                );
1082	              })}
1083	            </svg>
1084

1085
1086	          {sel && (
1087

1088

1089	                <StatusDot status={sel.status} />
1090	                {sel.id}
1091	                <span className={`badge ${sel.role === "primary" ? "accent" : "neutral"}`}>{sel.role}
1092

1093

1094	                <DetailRow k="Status" v={{sel.status}} />
1095	                <DetailRow k="IP" v={sel.ip} mono />
1096	                <DetailRow k="Version" v={sel.version} mono />
1097	                <DetailRow k="Uptime" v={sel.uptime} mono />
1098	                <DetailRow k="CPU" v={
1099

1100

1101	                      <div style={{ width: `${sel.cpu}%`, height: "100%", background: "var(--accent)" }} />
1102

1103	                    {sel.cpu}%
1104

1105	                } />
1106	                {sel.memTotal > 0 && (
1107	                  <DetailRow k="Memory" v={
1108

1109

1110	                        <div style={{ width: `${(sel.mem / sel.memTotal) * 100}%`, height: "100%", background: "var(--purple)" }} />
1111

1112	                      {sel.mem} / {sel.memTotal} GB
1113

1114	                  } />
1115	                )}
1116	                {sel.gpus.length > 0 && (
1117

1118
GPUs ({sel.gpus.length})

1119	                    {sel.gpus.map((g, i) => (
1120

1121	                        <Icon name="gpu" size={11} style={{ color: "var(--text-3)" }} />
1122	                        {g}
1123

1124	                    ))}
1125

1126	                )}
1127	                {sel.devices && sel.devices.length > 0 && (
1128

1129
Capture devices

1130	                    {sel.devices.map((d, i) => (
1131

1132	                        <Icon name="video" size={11} style={{ color: "var(--text-3)", marginRight: 6 }} />{d}
1133

1134	                    ))}
1135

1136	                )}
1137

1138	                  <button className="btn ghost sm" onClick={() => nodeLogsHint(sel)}>Logs</button>
1139	                  <button className="btn ghost sm" onClick={() => drainNode(sel)}>Drain</button>
1140	                  {sel.role !== "primary" && <button className="btn danger sm" onClick={() => removeNode(sel)}>Remove</button>}
1141

1142

1143

1144	          )}
1145

1146

1147	      {adviceModal && (
1148
 setAdviceModal(null)}>
1149
 e.stopPropagation()}>
1150

1151
{adviceModal.title}

1152	              <button className="icon-btn" onClick={() => setAdviceModal(null)}><Icon name="x" /></button>
1153

1154

1155	              {(adviceModal.lines || []).map((l, i) => (
1156
{l}

1157	              ))}
1158	              {(adviceModal.commands || []).map((c, i) => (
1159	                {c}
1160	              ))}
1161

1162

1163	              {adviceModal.commands && adviceModal.commands.length > 0 && (
1164	                <button className="btn ghost sm" onClick={() => {
1165	                  if (navigator.clipboard) navigator.clipboard.writeText(adviceModal.commands.join('\n')).catch(() => {});
1166	                }}>Copy commands</button>
1167	              )}
1168	              <button className="btn primary sm" onClick={() => setAdviceModal(null)}>Got it</button>
1169

1170

1171

1172	      )}
1173

1174	  );
1175	}
1176
1177	function DetailRow({ k, v, mono }) {
1178	  return (
1179

1180	      {k}
1181	      {v}
1182

1183	  );
1184	}
1185
1186	function Settings() {
1187	  const [section, setSection] = React.useState('storage');
1188
1189	  const SECTIONS = [
1190	    { id: 'storage',  label: 'S3 / Object storage', icon: 'hdd' },
1191	    { id: 'proxy',    label: 'Proxy encoding',      icon: 'gpu' },
1192	    { id: 'growing',  label: 'Growing files (SMB)', icon: 'hdd' },
1193	    { id: 'sdk',      label: 'Capture SDKs',        icon: 'video' },
1194	    { id: 'sdi',      label: 'SDI capture',         icon: 'video' },
1195	  ];
1196
1197	  return (
1198

1199

1200
Settings

1201	        System configuration · changes apply without restart
1202

1203

1204

1205
1206	            {SECTIONS.map(s => (
1207	              <a key={s.id}
1208	                 className={`settings-nav-item ${section === s.id ? 'active' : ''}`}
1209	                 onClick={() => setSection(s.id)}
1210	                 style={{ cursor: 'pointer' }}>
1211	                <Icon name={s.icon} size={14} />{s.label}
1212
1213	            ))}
1214
1215

1216	            {section === 'storage' && <S3SettingsCard />}
1217	            {section === 'proxy'   && <GpuSettingsCard />}
1218	            {section === 'growing' && <GrowingSettingsCard />}
1219	            {section === 'sdk'     && <SdkSettingsCard />}
1220	            {section === 'sdi'     && <SdiSettingsCard />}
1221

1222

1223

1224

1225	  );
1226	}
1227
1228	function S3SettingsCard() {
1229	  const [s3, setS3] = React.useState({ s3_endpoint: '', s3_bucket: '', s3_access_key: '', s3_secret_key: '', s3_region: 'us-east-1' });
1230	  const [loading, setLoading] = React.useState(true);
1231	  const [saving,  setSaving]  = React.useState(false);
1232	  const [testing, setTesting] = React.useState(false);
1233	  const [msg,     setMsg]     = React.useState(null);
1234	  const [secretExists, setSecretExists] = React.useState(false);
1235
1236	  React.useEffect(() => {
1237	    window.ZAMPP_API.fetch('/settings/s3')
1238	      .then(data => {
1239	        setS3({ s3_endpoint: data.s3_endpoint || '', s3_bucket: data.s3_bucket || '', s3_access_key: data.s3_access_key || '', s3_secret_key: '', s3_region: data.s3_region || 'us-east-1' });
1240	        setSecretExists(!!data.s3_secret_key_exists);
1241	        setLoading(false);
1242	      })
1243	      .catch(() => setLoading(false));
1244	  }, []);
1245
1246	  const save = () => {
1247	    setSaving(true); setMsg(null);
1248	    window.ZAMPP_API.fetch('/settings/s3', { method: 'PUT', body: JSON.stringify(s3) })
1249	      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved and applied.' }); if (s3.s3_secret_key) setSecretExists(true); })
1250	      .catch(e  => { setSaving(false); setMsg({ ok: false, text: e.message }); });
1251	  };
1252	  const test = () => {
1253	    setTesting(true); setMsg(null);
1254	    window.ZAMPP_API.fetch('/settings/s3/test', { method: 'POST', body: JSON.stringify(s3) })
1255	      .then(r => { setTesting(false); setMsg({ ok: r.ok !== false, text: r.message || 'Connection OK' }); })
1256	      .catch(e => { setTesting(false); setMsg({ ok: false, text: e.message }); });
1257	  };
1258
1259	  return (
1260	    <SettingsCard icon="hdd" title="S3 / Object Storage" sub="S3-compatible bucket for media asset storage"
1261	      tag={secretExists ? connected : not configured}>
1262	      {loading ?
Loading…
 : (<>
1263	        <SField label="Endpoint URL">
1264	          <input className="field-input mono" value={s3.s3_endpoint} onChange={e => setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" />
1265	        </SField>
1266

1267	          <SField label="Region"><input className="field-input mono" value={s3.s3_region} onChange={e => setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" /></SField>
1268	          <SField label="Bucket"><input className="field-input mono" value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" /></SField>
1269

1270	        <SField label="Access key ID"><input className="field-input mono" value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" /></SField>
1271	        <SField label="Secret access key"><input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} /></SField>
1272	        <SettingsMsg msg={msg} />
1273

1274	          <button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button>
1275	          <button className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>
1276

1277	      </>)}
1278	    </SettingsCard>
1279	  );
1280	}
1281
1282	function GpuSettingsCard() {
1283	  const [cfg, setCfg] = React.useState(null);
1284	  const [saving, setSaving] = React.useState(false);
1285	  const [msg, setMsg] = React.useState(null);
1286
1287	  React.useEffect(() => {
1288	    window.ZAMPP_API.fetch('/settings/transcoding').then(setCfg).catch(() => setCfg({}));
1289	  }, []);
1290
1291	  const save = () => {
1292	    setSaving(true); setMsg(null);
1293	    window.ZAMPP_API.fetch('/settings/transcoding', { method: 'PUT', body: JSON.stringify(cfg) })
1294	      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved — new settings apply to the next proxy job.' }); })
1295	      .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
1296	  };
1297
1298	  if (!cfg) return <SettingsCard icon="gpu" title="Proxy encoding" sub="Global proxy encoder applied to every ingested file">
Loading…
</SettingsCard>;
1299


1300	  const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
1301	  const gpuEnabled = cfg.gpu_transcode_enabled === 'true' || cfg.gpu_transcode_enabled === true;
1302
1303	  return (
1304	    <SettingsCard icon="gpu" title="Proxy encoding" sub="Global proxy encoder applied to every ingested file"
1305	      tag={gpuEnabled ? GPU mode : CPU mode}>
1306

1307	        These settings drive the proxy worker for every ingested asset (SDI, SRT, RTMP, upload). GPU mode uses NVENC / VAAPI when hardware is available; CPU mode uses libx264.
1308

1309
1310	      <SField label="Hardware acceleration">
1311
1312	          <input type="checkbox" checked={gpuEnabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} />
1313	          Use GPU encoders (NVENC / VAAPI) when available — falls back to CPU on missing hardware
1314
1315	      </SField>
1316
1317

1318	        <SField label={gpuEnabled ? 'GPU codec' : 'CPU codec'}>
1319	          <select className="field-input" value={cfg.gpu_codec || 'h264_nvenc'} onChange={e => set('gpu_codec', e.target.value)} style={{ appearance: 'auto' }}>
1320	            {gpuEnabled ? (<>
1321	              <option value="h264_nvenc">h264_nvenc (NVIDIA)</option>
1322	              <option value="hevc_nvenc">hevc_nvenc (NVIDIA HEVC)</option>
1323	              <option value="h264_vaapi">h264_vaapi (Intel/AMD)</option>
1324	              <option value="hevc_vaapi">hevc_vaapi (Intel/AMD HEVC)</option>
1325	            </>) : (<>
1326	              <option value="libx264">libx264 (H.264, recommended)</option>
1327	              <option value="libx265">libx265 (HEVC, slower)</option>
1328	            </>)}
1329	          </select>
1330	        </SField>
1331	        <SField label="Preset">
1332	          <select className="field-input" value={cfg.gpu_preset || (gpuEnabled ? 'p4' : 'fast')} onChange={e => set('gpu_preset', e.target.value)} style={{ appearance: 'auto' }}>
1333	            {gpuEnabled
1334	              ? ['p1','p2','p3','p4','p5','p6','p7'].map(p => <option key={p} value={p}>{p}</option>)
1335	              : ['ultrafast','superfast','veryfast','faster','fast','medium','slow','slower'].map(p => <option key={p} value={p}>{p}</option>)}
1336	          </select>
1337	        </SField>
1338

1339
1340

1341	        <SField label="Target bitrate (Mbps)">
1342	          <input className="field-input mono" type="number" value={cfg.gpu_bitrate_mbps || ''} onChange={e => set('gpu_bitrate_mbps', e.target.value)} placeholder="10" />
1343	        </SField>
1344	        <SField label="Rate control">
1345	          <select className="field-input" value={cfg.gpu_rc_mode || 'cbr'} onChange={e => set('gpu_rc_mode', e.target.value)} style={{ appearance: 'auto' }}>
1346	            <option value="cbr">CBR — constant bitrate</option>
1347	            <option value="vbr">VBR — variable bitrate</option>
1348	            <option value="cqp">CQP / CRF — constant quality</option>
1349	          </select>
1350	        </SField>
1351

1352
1353

1354	        <SField label="Audio codec">
1355	          <select className="field-input" value={cfg.gpu_audio_codec || 'aac'} onChange={e => set('gpu_audio_codec', e.target.value)} style={{ appearance: 'auto' }}>
1356	            <option value="aac">aac</option>
1357	            <option value="opus">opus</option>
1358	            <option value="mp3">mp3</option>
1359	          </select>
1360	        </SField>
1361	        <SField label="Audio bitrate (kbps)">
1362	          <input className="field-input mono" type="number" value={cfg.gpu_audio_bitrate_kbps || ''} onChange={e => set('gpu_audio_bitrate_kbps', e.target.value)} placeholder="192" />
1363	        </SField>
1364

1365
1366	      <SettingsMsg msg={msg} />
1367

1368	        <button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
1369

1370	    </SettingsCard>
1371	  );
1372	}
1373
1374	function GrowingSettingsCard() {
1375	  const [cfg, setCfg] = React.useState(null);
1376	  const [saving, setSaving] = React.useState(false);
1377	  const [msg, setMsg] = React.useState(null);
1378
1379	  React.useEffect(() => {
1380	    window.ZAMPP_API.fetch('/settings/growing').then(setCfg).catch(() => setCfg({
1381	      growing_enabled: 'false', growing_path: '/growing', growing_smb_url: '', growing_promote_after_seconds: '8',
1382	    }));
1383	  }, []);
1384
1385	  const save = () => {
1386	    setSaving(true); setMsg(null);
1387	    window.ZAMPP_API.fetch('/settings/growing', { method: 'PUT', body: JSON.stringify(cfg) })
1388	      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); })
1389	      .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
1390	  };
1391
1392	  if (!cfg) return <SettingsCard icon="hdd" title="Growing files (SMB)" sub="Loading…">
…
</SettingsCard>;
1393	  const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
1394	  const enabled = cfg.growing_enabled === 'true' || cfg.growing_enabled === true;
1395
1396	  return (
1397	    <SettingsCard icon="hdd" title="Growing files (SMB)" sub="High-speed local landing zone; promote to S3 on stop"
1398	      tag={enabled ? enabled : disabled}>
1399	      <SField label="Enable growing-file capture">
1400
1401	          <input type="checkbox" checked={enabled} onChange={e => set('growing_enabled', String(e.target.checked))} />
1402	          Capture writes to the local SMB share first; Premier can edit while it's still growing.
1403
1404	      </SField>
1405	      <SField label="Container mount path">
1406	        <input className="field-input mono" value={cfg.growing_path || ''} onChange={e => set('growing_path', e.target.value)} placeholder="/growing" />
1407	      </SField>
1408	      <SField label="SMB share URL (for editors)">
1409	        <input className="field-input mono" value={cfg.growing_smb_url || ''} onChange={e => set('growing_smb_url', e.target.value)} placeholder="smb://10.0.0.25/mam-growing" />
1410	      </SField>
1411	      <SField label="Promote-to-S3 idle threshold (seconds)">
1412	        <input className="field-input mono" type="number" value={cfg.growing_promote_after_seconds || ''} onChange={e => set('growing_promote_after_seconds', e.target.value)} placeholder="8" />
1413	      </SField>
1414	      <SettingsMsg msg={msg} />
1415

1416	        <button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
1417

1418	    </SettingsCard>
1419	  );
1420	}
1421
1422	function SdiSettingsCard() {
1423	  return (
1424	    <SettingsCard icon="video" title="SDI capture" sub="DeckLink device routing and defaults"
1425	      tag={per-recorder}>
1426

1427	        SDI settings are configured per-recorder. Use{' '}
1428	        Ingest → Recorders → New recorder{' '}
1429	        to pick the DeckLink port, codec, and audio routing.
1430

1431

1432	         { e.preventDefault(); window.dispatchEvent(new CustomEvent('dragonflight-navigate', { detail: 'capture' })); }}>
1433	          <Icon name="video" />Open Capture dashboard
1434
1435

1436	    </SettingsCard>
1437	  );
1438	}
1439
1440	// ────────────────────────────────────────────────────────────────────────────
1441	// Capture SDK deployment — Blackmagic / AJA / Deltacast
1442	// ────────────────────────────────────────────────────────────────────────────
1443	const SDK_VENDORS = [
1444	  {
1445	    id: 'blackmagic',
1446	    name: 'Blackmagic DeckLink',
1447	    sub: 'DeckLink SDK 16.x — required for SDI capture via DeckLink cards',
1448	    expect: 'DeckLinkAPI.h, DeckLinkAPIDispatch.cpp, LinuxCOM.h, libDeckLinkAPI.so',
1449	    docs: 'https://www.blackmagicdesign.com/developer/product/capture',
1450	    buildHint: 'docker compose build --no-cache capture',
1451	    status: 'wired',
1452	  },
1453	  {
1454	    id: 'aja',
1455	    name: 'AJA NTV2',
1456	    sub: 'NTV2 SDK — for Kona / Io / U-Tap / T-Tap cards',
1457	    expect: 'libajantv2.so, ntv2card.h, ntv2enums.h',
1458	    docs: 'https://sdksupport.aja.com/',
1459	    buildHint: 'FFmpeg patch + Dockerfile update pending — files will be staged for the next capture image build',
1460	    status: 'staging-only',
1461	  },
1462	  {
1463	    id: 'deltacast',
1464	    name: 'Deltacast VideoMaster',
1465	    sub: 'VideoMasterHD SDK — for FLEX / DELTA-h4k2 / etc.',
1466	    expect: 'VideoMasterHD_Core.h, libVideoMasterHD_Core.so',
1467	    docs: 'https://www.deltacast.tv/products/sdk',
1468	    buildHint: 'FFmpeg patch + Dockerfile update pending — files will be staged for the next capture image build',
1469	    status: 'staging-only',
1470	  },
1471	];
1472
1473	function SdkSettingsCard() {
1474	  const [statuses, setStatuses] = React.useState(null);
1475	  const [msg, setMsg] = React.useState(null);
1476
1477	  const load = React.useCallback(() => {
1478	    window.ZAMPP_API.fetch('/sdk').then(setStatuses).catch(() => setStatuses({}));
1479	  }, []);
1480
1481	  React.useEffect(() => { load(); }, [load]);
1482
1483	  return (
1484	    <SettingsCard icon="video" title="Capture SDKs" sub="Vendor SDKs are licensed — upload them here so the capture container can build with hardware support"
1485	      tag={{SDK_VENDORS.length} vendors}>
1486

1487	        Each SDK archive should be a .zip or .tar.gz containing the vendor's Linux SDK contents. After uploading, rebuild the capture container on the host with a DeckLink/AJA/Deltacast card. The SDK files are staged under /sdk/<vendor>/ inside mam-api.
1488

1489	      <SettingsMsg msg={msg} />
1490

1491	        {SDK_VENDORS.map(v => (
1492	          <SdkVendorRow
1493	            key={v.id}
1494	            vendor={v}
1495	            status={(statuses && statuses[v.id]) || null}
1496	            onDone={(text, ok = true) => { setMsg({ ok, text }); load(); }}
1497	          />
1498	        ))}
1499

1500	    </SettingsCard>
1501	  );
1502	}
1503
1504	function SdkVendorRow({ vendor, status, onDone }) {
1505	  const fileRef = React.useRef(null);
1506	  const [uploading, setUploading] = React.useState(false);
1507	  const [progress, setProgress] = React.useState(0);
1508
1509	  const deployed = status && status.file_count > 0;
1510	  const lastUpload = status?.uploaded_at
1511	    ? new Date(status.uploaded_at).toLocaleString()
1512	    : null;
1513
1514	  const handleFile = async (file) => {
1515	    if (!file) return;
1516	    setUploading(true); setProgress(0);
1517	    const fd = new FormData();
1518	    fd.append('archive', file);
1519
1520	    // Use XHR so we can report progress to the user — fetch's stream API is fiddly.
1521	    await new Promise((resolve) => {
1522	      const xhr = new XMLHttpRequest();
1523	      xhr.open('POST', '/api/v1/sdk/' + vendor.id);
1524	      xhr.withCredentials = true;
1525	      xhr.upload.onprogress = (e) => {
1526	        if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));
1527	      };
1528	      xhr.onload = () => {
1529	        setUploading(false); setProgress(0);
1530	        if (xhr.status >= 200 && xhr.status < 300) {
1531	          onDone(vendor.name + ': SDK staged.', true);
1532	        } else {
1533	          let txt = xhr.responseText;
1534	          try { txt = JSON.parse(xhr.responseText).error || txt; } catch {}
1535	          onDone(vendor.name + ': upload failed — ' + txt, false);
1536	        }
1537	        resolve();
1538	      };
1539	      xhr.onerror = () => {
1540	        setUploading(false); setProgress(0);
1541	        onDone(vendor.name + ': network error', false);
1542	        resolve();
1543	      };
1544	      xhr.send(fd);
1545	    });
1546	  };
1547
1548	  const clear = () => {
1549	    if (!confirm('Remove staged ' + vendor.name + ' SDK files?')) return;
1550	    window.ZAMPP_API.fetch('/sdk/' + vendor.id, { method: 'DELETE' })
1551	      .then(() => onDone(vendor.name + ': cleared.', true))
1552	      .catch(e => onDone(vendor.name + ': ' + e.message, false));
1553	  };
1554
1555	  return (
1556

1557

1558	        {vendor.name}
1559	        {deployed
1560	          ? deployed · {status.file_count} files
1561	          : not deployed}
1562	        {vendor.status === 'staging-only' && build pipeline pending}
1563

1564	        {deployed && <button className="btn ghost sm" onClick={clear}>Remove</button>}
1565	        <button className="btn primary sm" onClick={() => fileRef.current?.click()} disabled={uploading}>
1566	          {uploading ? `Uploading ${progress}%` : (deployed ? 'Replace' : 'Upload SDK')}
1567	        </button>
1568	        <input ref={fileRef} type="file" accept=".zip,.tar.gz,.tgz,.tar"
1569	               style={{ display: 'none' }}
1570	               onChange={e => handleFile(e.target.files?.[0])} />
1571

1572

1573	        {vendor.sub}

1574	        expects: {vendor.expect}
1575	        {lastUpload && <>
uploaded: {lastUpload}</>}
1576	        {deployed && <>
on host: rebuild with → {vendor.buildHint}</>}
1577

1578

1579	  );
1580	}
1581
1582	function AmppSettingsCard() {
1583	  const [cfg, setCfg] = React.useState(null);
1584	  const [saving, setSaving] = React.useState(false);
1585	  const [testing, setTesting] = React.useState(false);
1586	  const [msg, setMsg] = React.useState(null);
1587	  const [tokenExists, setTokenExists] = React.useState(false);
1588
1589	  React.useEffect(() => {
1590	    window.ZAMPP_API.fetch('/settings/ampp').then(d => {
1591	      setCfg({ ampp_base_url: d.ampp_base_url || '', ampp_token: '' });
1592	      setTokenExists(!!d.ampp_token_exists);
1593	    }).catch(() => setCfg({ ampp_base_url: '', ampp_token: '' }));
1594	  }, []);
1595
1596	  const save = () => {
1597	    setSaving(true); setMsg(null);
1598	    window.ZAMPP_API.fetch('/settings/ampp', { method: 'PUT', body: JSON.stringify(cfg) })
1599	      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); if (cfg.ampp_token) setTokenExists(true); })


1600	      .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
1601	  };
1602	  const test = () => {
1603	    setTesting(true); setMsg(null);
1604	    window.ZAMPP_API.fetch('/settings/ampp/test', { method: 'POST', body: JSON.stringify(cfg) })
1605	      .then(r => { setTesting(false); setMsg({ ok: true, text: r.message || 'Connection OK' }); })
1606	      .catch(e => { setTesting(false); setMsg({ ok: false, text: e.message }); });
1607	  };
1608
1609	  if (!cfg) return <SettingsCard icon="link" title="AMPP integration" sub="Loading…">
…
</SettingsCard>;
1610
1611	  return (
1612	    <SettingsCard icon="link" title="AMPP integration" sub="Migrate assets and metadata from Grass Valley AMPP"
1613	      tag={tokenExists ? connected : not configured}>
1614	      <SField label="AMPP base URL">
1615	        <input className="field-input mono" value={cfg.ampp_base_url} onChange={e => setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" />
1616	      </SField>
1617	      <SField label="API token">
1618	        <input className="field-input mono" type="password" value={cfg.ampp_token} onChange={e => setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved — type to replace)' : 'AMPP API token'} />
1619	      </SField>
1620	      <SettingsMsg msg={msg} />
1621

1622	        <button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
1623	        <button className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>
1624

1625	    </SettingsCard>
1626	  );
1627	}
1628
1629	function SettingsMsg({ msg }) {
1630	  if (!msg) return null;
1631	  return (
1632

1636	      {msg.text}
1637

1638	  );
1639	}
1640
1641	function SField({ label, children }) {
1642	  return (
1643

1644	      {label}
1645	      {children}
1646

1647	  );
1648	}
1649
1650	function SettingsCard({ icon, title, sub, tag, children }) {
1651	  return (
1652

1653

1654
<Icon name={icon} size={16} />

1655

1656
{title}

1657
{sub}

1658

1659	        {tag}
1660

1661
{children}

1662

1663	  );
1664	}
1665
1666	Object.assign(window, { Users, Tokens, Containers, Cluster, Settings });
1667


I now have all the content of screens-admin.jsx. Now I'll read the full file as a string for the push, and simultaneously push screens-library.jsx and screens-projects.jsx (which I have complete), and list MeshCentral devices.


Bash

Check total line count of screens-admin.jsx


# MeshCentral Devices (26 found)

## AMPP

### bert
- **Status**: online
- **Node ID**: `node//PxaegfDULqnnDbWcbcoEGbmszHc4tcU6NimdpPSW8DLVv4dp7Ea1QOtwLMPvbo9C`
- **IP**: 10.0.0.25
- **OS**: Ubuntu 24.04.4 LTS

### ernie
- **Status**: online
- **Node ID**: `node//MGsCO9f2A8HbZU4Npikbln68I7FA0hQ@cHWaomAX0Evc2VhR4ZJA2kY@Q45CxZYH`
- **IP**: 10.0.0.25
- **OS**: Ubuntu 24.04.4 LTS

## CVS

### CVS - Engineering
- **Status**: online
- **Node ID**: `node//Q1puwG0oTN$w5AxMMricUJ7WxqXLgi@74D1um1vcGHObHtb7JYLK@VGf0Ecq7EIh`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 11 Pro - 25H2/26200

### CVS - Teams 1 NEW
- **Status**: online
- **Node ID**: `node//5BSMANOEtOGHM6v$1FQ90SfKWedj9doSBI66hFIlbZM48fLaQzyLYKGfbQG2kVCU`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 11 Pro - 25H2/26200

### CVS A1 PCR2
- **Status**: online
- **Node ID**: `node//3npuTQYtqdpkNMkzqi3P2deB6tNfJlK6z17lD0txvVYCtxIqKVOWijM2R7xEoYFe`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 11 Pro - 25H2/26200

### CVS Tria
- **Status**: online
- **Node ID**: `node//VRYZWkfrIxK0v5yr2ca7re9QudHFOeywlwitoxBS0xFJpVSV$i1iau2XPCyrmEII`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 10 IoT Enterprise LTSC - 21H2/19044

### Teams 2 CVS
- **Status**: online
- **Node ID**: `node//AWp8HLVoVxFXa4rD5VPDtUcXxaW2CKbJMEAXrwnVJZnSZSTbitaQvIq1uhlcq9n5`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 11 Pro - 25H2/26200

## Commanders

### COMMANDSPROD2
- **Status**: online
- **Node ID**: `node//Sso2tmwNVCfdZ7DhfBewpR5sM3Ms0iENeTMuFgqVWQvKhey2Hdz1GS3UDlHwiVhz`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 11 Pro - 23H2/22631

### COMMPROD1
- **Status**: online
- **Node ID**: `node//sdq@wqcyEAmf$aQ9kQ3PfXH$dxi1ICV$UQcQO@Jt3XJWIIv1tSqnHOntAxlh3ezN`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 11 Pro - 23H2/22631

### CommandersWall
- **Status**: online
- **Node ID**: `node//sDylu2PiZVwju4ndY3$VB35ApbasM9w6dVCRSGUNCgYx6z6tK7jeu4Yam6qTXoZL`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 10 Pro - 22H2/19045

### FINGERWORKS
- **Status**: online
- **Node ID**: `node//Xrq@vloZ8bFqLMhzh5TqtbjVrZy1jM30okB65eHNuPysCJrJjdgoh9@UsUN0$QWn`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 11 Pro - 25H2/26200

### Lighting Board
- **Status**: online
- **Node ID**: `node//skbvJJFpbU2NnNZGsJFQFlnoZwAWMd6bm1or2RQgfOgGbaEDVJMxN@qg@ZUx@WK@`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 10 Pro for Workstations - 22H2/19045

### Unity
- **Status**: online
- **Node ID**: `node//iTkbyQd8mZg4dsh5EniDF7eMLejlfIhlxXVXeF5FtHucy$LII4NNeRRVR9z$0ZJ1`
- **IP**: 10.0.0.25
- **OS**: macOS 11.4

### liveu-mmh
- **Status**: online
- **Node ID**: `node//9lHBjLzWysWbmbUgpYsZlRgzH7YrFWgIsxCJjtfX9kFDYscDo1Ssz9EvyocfPP$x`
- **IP**: 10.0.0.25
- **OS**: Ubuntu 22.04.5 LTS

### liveu-videoreturnserver
- **Status**: online
- **Node ID**: `node//CUbJTnqVRMPJAWtRQNdQzkhgyVXYihMYBySGfQIBnydX5GRvZ@03gTQtl9N4oYzU`
- **IP**: 10.0.0.25
- **OS**: Ubuntu 22.04.4 LTS

### vMix
- **Status**: online
- **Node ID**: `node//jZWAyROu1aSQM$69qvdxuAIz@B3EP2Qm$Q1la8MoyC9sB30s7lyGafzUDUsJcA83`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 11 Pro - 24H2/26100

## Deployment

### FredNatsReplay
- **Status**: online
- **Node ID**: `node//9ak5BblTwPxa2c@1L93sgnD2LtASvODrlp9VdsHa1NJVTnNlBnHfQYl@v@C5zIMK`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 11 Pro - 25H2/26200

### Intuit XR
- **Status**: online
- **Node ID**: `node//uog6aYqDtbJP1E2ehUbvBPXPmV3@drpfD@QWHrc7JYmuMYeSknWzuZRhPTW@EBdm`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 11 Pro - 24H2/26100

## Personal

### Node 1
- **Status**: online
- **Node ID**: `node//TaK@$$4IewBkx3fzvuzXETPKsIqs01W00LbzDKohRqEXrq@rzWz0zL4vZVEXpqqH`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 10 Pro for Workstations - 22H2/19045

### ZGaetano-MBP.local
- **Status**: online
- **Node ID**: `node//xbl9hXeRFJ$CMvlrF1VdPZ3mRf6c9aHrAkzbB1bZx4WSQ8QZp6rnlA39aCT1SLxG`
- **IP**: 10.0.0.25
- **OS**: macOS 26.5

## UBS - BMG

### TD - Thin Client
- **Status**: online
- **Node ID**: `node//qvZ5G4oCQ5tP0BTuZUl0an9VRnRImIeTnFQe4O9$UwEN9HQCOrUlzHecElSoNPoE`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 11 Pro - 25H2/26200

### Thinclient 4
- **Status**: online
- **Node ID**: `node//QXmlkR0LVfBI5mQs1x4g6t2oxkM3LBDMxBPKZM44Xr2vgejqF0OELvtRQDoOjOnW`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 11 Pro - 25H2/26200

### Thinclient 5
- **Status**: online
- **Node ID**: `node//HxkHsICw0VDVGDFNJ3MWQf9ybkxGmTlocTUCGdWOtpvDQXOGGgdVqPo6prwbE@o5`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 11 Pro - 25H2/26200

### UBS - EIC
- **Status**: online
- **Node ID**: `node//lx7y6k5@$ISYsvCKri733O080X1m2PSqs39Sy$SOWOtTqGvAYEnsMoxq$R$93JfV`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 11 Pro - 24H2/26100

### UBS - Podcast
- **Status**: online
- **Node ID**: `node//n90LR02jT9t6@Ds0N5Lr2nNSIIOzAZv86K9EgqEJNZychL8mKl0aOWbkDd$$KZb@`
- **IP**: 10.0.0.25
- **OS**: Microsoft Windows 11 Pro - 25H2/26200

### UBS-BMG-ENGINEER
- **Status**: online
- **Node ID**: `node//TNvUJHv7H0l0RtcLbdJrpbckikcxoOc3PrTI1ZyFIqxHVGgBITJdnt5rnxukUbqX`
- **IP**: 10.0.0.25
- **OS**: Ubuntu 24.04.3 LTS


Permission request timed out


No TrueNAS node is visible in MeshCentral. The devices all show IP 10.0.0.25 (likely a MeshCentral proxy display quirk). I can see "bert" and "ernie" are Ubuntu 24.04 VMs in the AMPP group — the TrueNAS host at 10.0.0.25 isn't listed as a direct device. I'll need to use a Linux device that has access to the TrueNAS filesystem, or check if there's a different device. Looking at the memory file, the TrueNAS is at 10.0.0.25 itself.

Let me now push the two complete files (library and projects) while reading the full admin file content for its push:


0	// screens-library.jsx
1
2	function Library({ navigate, onOpenAsset, openProject }) {
3	  const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
4	  const [bins, setBins] = React.useState(window.ZAMPP_DATA?.BINS || []);
5	  const BINS = bins; // legacy local name; keep so the rest of the function reads unchanged
6
7	  // Re-fetch bins on mount + whenever the open project changes; surfaces
8	  // every-project bins when the global view is on, project-scoped otherwise.
9	  React.useEffect(() => {
10	    const qs = openProject ? '?project_id=' + openProject.id : '';
11	    window.ZAMPP_API.fetch('/bins' + qs)
12	      .then(list => {
13	        const normalized = (list || []).map(b => ({
14	          ...b,
15	          count: b.asset_count != null ? b.asset_count : (b.count || 0),
16	          icon:  b.type || 'grid',
17	        }));
18	        if (!openProject) window.ZAMPP_DATA.BINS = normalized;
19	        setBins(normalized);
20	      })
21	      .catch(() => {});
22	  }, [openProject]);
23
24	  const createBin = () => {
25	    if (!openProject) { window.alert('Open a project first (Projects → click a project), then create a bin inside it.'); return; }
26	    setNewBinName(''); setCreatingBin(true);
27	  };
28
29	  const submitBin = (name) => {
30	    if (!name || !name.trim()) { setCreatingBin(false); return; }
31	    setCreatingBin(false);
32	    window.ZAMPP_API.fetch('/bins', {
33	      method: 'POST',
34	      body: JSON.stringify({ project_id: openProject.id, name: name.trim() }),
35	    })
36	      .then(() => {
37	        window.ZAMPP_API.fetch('/bins?project_id=' + openProject.id)
38	          .then(list => setBins((list || []).map(b => ({ ...b, count: b.asset_count || 0, icon: b.type || 'grid' }))));
39	      })
40	      .catch(e => window.alert('Could not create bin: ' + e.message));
41	  };
42	  const [view, setView] = React.useState('grid');
43	  const [filter, setFilter] = React.useState('all');
44	  const [search, setSearch] = React.useState(window._dfPendingSearch || '');
45	  React.useEffect(() => { delete window._dfPendingSearch; }, []);
46	  // Local state lets us re-render after delete / move-to-bin without forcing
47	  // a full app reload — keeps ZAMPP_DATA in sync as the cache of record.
48	  const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []);
49	  const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y }
50	  const [renamingAsset, setRenamingAsset] = React.useState(null);
51	  const [creatingBin, setCreatingBin] = React.useState(false);
52	  const [newBinName, setNewBinName] = React.useState('');
53
54	  const refreshAssets = React.useCallback(() => {
55	    window.ZAMPP_API.fetch('/assets?limit=500')
56	      .then(r => {
57	        const list = Array.isArray(r) ? r : (r.assets || []);
58	        const proj = {};
59	        (PROJECTS || []).forEach(p => { proj[p.id] = p.name; });
60	        const normalized = list.map(a => window.normalizeAsset ? window.normalizeAsset(a, proj) : a);
61	        window.ZAMPP_DATA.ASSETS = normalized;
62	        setAllAssets(normalized);
63	      })
64	      .catch(() => {});
65	  }, [PROJECTS]);
66
67	  // Dismiss the context menu on any outside click (capture phase so clicking
68	  // a menu item still fires before the menu unmounts).
69	  React.useEffect(() => {
70	    if (!ctxMenu) return;
71	    const close = () => setCtxMenu(null);
72	    window.addEventListener('click', close);
73	    window.addEventListener('contextmenu', close);
74	    window.addEventListener('scroll', close, true);
75	    return () => {
76	      window.removeEventListener('click', close);
77	      window.removeEventListener('contextmenu', close);
78	      window.removeEventListener('scroll', close, true);
79	    };
80	  }, [ctxMenu]);
81
82	  const openCtx = (asset, e) => {
83	    e.preventDefault();
84	    e.stopPropagation();
85	    setCtxMenu({ asset, x: e.clientX, y: e.clientY });
86	  };
87
88	  const [selectedBinId, setSelectedBinId] = React.useState(null);
89	  // Clear bin filter on project change so a stale id doesn't hide everything.
90	  React.useEffect(() => { setSelectedBinId(null); }, [openProject?.id]);
91	  let assets = openProject
92	    ? allAssets.filter(function(a) { return a.project_id === openProject.id; })
93	    : allAssets;
94	  const ALL_ASSETS = allAssets;
95	  if (filter !== 'all') assets = assets.filter(function(a) { return a.status === filter; });
96	  if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); });
97	  if (selectedBinId) assets = assets.filter(function(a) { return a.bin_id === selectedBinId; });
98
99	  const activeBin = selectedBinId ? BINS.find(b => b.id === selectedBinId) : null;
100	  const displayTitle = activeBin
101	    ? (openProject ? openProject.name + ' · ' : '') + activeBin.name
102	    : (openProject ? openProject.name : 'All Assets');
103	  const errorCount = ALL_ASSETS.filter(function(a) { return a.status === 'error'; }).length;
104	  const recentCount = ALL_ASSETS.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }).length;
105
106	  return (
107

108
109

110
Projects

111

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

117	            {PROJECTS.slice(0, 8).map(function(p) {
118	              return (
119	                <div key={p.id} className={`rail-item ${openProject && openProject.id === p.id ? 'active' : ''}`} style={{ cursor: 'pointer' }}
120	                  onClick={function() { navigate('projects'); }}>
121
122	                  {p.name}
123	                  {p.assets}
124

125	              );
126	            })}
127

128

129

130

131
Bins

132	            <button className="icon-btn" onClick={createBin}
133	              title={openProject ? 'Create bin in this project' : 'Open a project to create a bin'}
134	              style={{ opacity: openProject ? 1 : 0.5 }}>
135	              <Icon name="plus" size={11} />
136	            </button>
137

138

139	            {creatingBin && (
140

141	                <input
142	                  className="field-input"
143	                  autoFocus
144	                  value={newBinName}
145	                  onChange={function(e) { setNewBinName(e.target.value); }}
146	                  onKeyDown={function(e) {
147	                    if (e.key === 'Enter') submitBin(newBinName);
148	                    if (e.key === 'Escape') { setCreatingBin(false); }
149	                  }}
150	                  onBlur={function() { submitBin(newBinName); }}
151	                  placeholder="Bin name"
152	                  style={{ fontSize: 12, height: 26, padding: '0 6px', flex: 1 }}
153	                />
154

155	            )}
156	            {!creatingBin && BINS.length === 0 ? (
157

158	                {openProject ? 'No bins yet — click + to create one.' : 'Open a project to manage bins.'}
159

160	            ) : BINS.map(function(b) {
161	              const isActive = selectedBinId === b.id;
162	              return (
163

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

172	              );
173	            })}
174

175

176

177
Smart filters

178

179	            {errorCount > 0 &&
<Icon name="alert" size={13} className="rail-icon" />Errors{errorCount}
}
180
<Icon name="clock" size={13} className="rail-icon" />Last 24h{recentCount}

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

182

183

184
185
186

187

188
{displayTitle}

189	          · {assets.length} assets
190

191

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

195

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

204

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

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

210
211	        {assets.length === 0 ? (
212
No assets match this filter.

213	        ) : view === 'grid' ? (
214

215	            {assets.map(function(a) {
216	              return <AssetCard key={a.id} asset={a}
217	                       onOpen={function() { onOpenAsset(a); }}
218	                       onContextMenu={function(e) { openCtx(a, e); }} />;
219	            })}
220

221	        ) : (
222

223

224

Name

Duration

Resolution

Codec

Size

Updated

225

226	            {assets.map(function(a) {
227	              return (
228

229
<AssetThumb asset={a} />

230

231
{a.name}

232

233	                      <StatusDot status={a.status} />
234	                      {a.status}
235

236

237
{a.duration}

238
{a.res}

239
{a.codec || '—'}

240
{a.size}

241
{a.updated}

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

244	              );
245	            })}
246

247	        )}
248

249	      {ctxMenu && (
250	        <AssetContextMenu
251	          asset={ctxMenu.asset}
252	          x={ctxMenu.x}
253	          y={ctxMenu.y}
254	          bins={BINS}
255	          onClose={function() { setCtxMenu(null); }}
256	          onChanged={refreshAssets}
257	          onOpen={function() { onOpenAsset(ctxMenu.asset); }}
258	          onRename={function(a) { setCtxMenu(null); setRenamingAsset(a); }}
259	        />
260	      )}
261	      {renamingAsset && (
262	        <RenameAssetModal
263	          asset={renamingAsset}
264	          onClose={function() { setRenamingAsset(null); }}
265	          onSaved={function() { setRenamingAsset(null); refreshAssets(); }}
266	        />
267	      )}
268

269	  );
270	}
271
272	function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRename }) {
273	  const ref = React.useRef(null);
274	  // Pin the menu inside the viewport even if the user right-clicked near
275	  // the bottom-right edge of the grid.
276	  const [pos, setPos] = React.useState({ left: x, top: y });
277	  React.useLayoutEffect(() => {
278	    if (!ref.current) return;
279	    const r = ref.current.getBoundingClientRect();
280	    const margin = 8;
281	    let nx = x, ny = y;
282	    if (x + r.width  + margin > window.innerWidth)  nx = window.innerWidth  - r.width  - margin;
283	    if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin;
284	    setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) });
285	  }, [x, y]);
286
287	  const rename = function() { if (onRename) onRename(asset); else onClose(); };
288
289	  const moveToBin = function(binId) {
290	    onClose();
291	    window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) })
292	      .then(onChanged)
293	      .catch(function(e) { alert('Move failed: ' + e.message); });
294	  };
295
296	  const copyId = function() {
297	    onClose();
298	    if (navigator.clipboard) navigator.clipboard.writeText(asset.id).catch(function() {});
299	  };
300
301	  const remove = function() {
302	    onClose();
303	    if (!confirm('Delete "' + (asset.display_name || asset.name) + '" permanently?\nThis removes the database row and the S3 objects.\nThis cannot be undone.')) return;
304	    window.ZAMPP_API.fetch('/assets/' + asset.id + '?hard=true', { method: 'DELETE' })
305	      .then(onChanged)
306	      .catch(function(e) { alert('Delete failed: ' + e.message); });
307	  };
308
309	  return (
310

313
{asset.display_name || asset.name}

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

317	      {(bins && bins.length > 0) ? (
318	        <>
319
Move to bin

320	          {bins
321	            .filter(function(b) { return !asset.project_id || b.project_id === asset.project_id; })
322	            .slice(0, 10)
323	            .map(function(b) {
324	              const isCurrent = asset.bin_id === b.id;
325	              return (
326	                <button key={b.id} onClick={function() { moveToBin(b.id); }} disabled={isCurrent}
327	                  title={b.project_name ? 'in ' + b.project_name : ''}>
328	                  <Icon name="folder" size={11} />{b.name}{isCurrent && current}
329	                </button>
330	              );
331	            })}
332	          {asset.bin_id && (
333	            <button onClick={function() { moveToBin(null); }}>
334	              <Icon name="x" size={11} />Remove from bin
335	            </button>
336	          )}
337	        </>
338	      ) : (
339
No bins — create one inside a project

340	      )}
341

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

345	  );
346	}
347
348	function AssetCard({ asset, onOpen, onContextMenu }) {
349	  const [hoverStream, setHoverStream] = React.useState(null);
350	  const [hovered, setHovered] = React.useState(false);
351	  const timerRef = React.useRef(null);
352	  const hlsRef = React.useRef(null);
353	  const videoRef = React.useRef(null);
354
355	  const handleMouseEnter = function() {
356	    timerRef.current = setTimeout(function() {
357	      setHovered(true);
358	      if (!hoverStream) {
359	        window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream')
360	          .then(function(r) { if (r && r.url) setHoverStream(r); })
361	          .catch(function() {});
362	      }
363	    }, 350);
364	  };
365
366	  const handleMouseLeave = function() {
367	    clearTimeout(timerRef.current);
368	    setHovered(false);
369	  };
370
371	  // HLS wiring
372	  React.useEffect(function() {
373	    if (!hovered || !hoverStream || hoverStream.type !== 'hls' || !videoRef.current) return;
374	    if (!window.Hls) return;
375	    hlsRef.current = new window.Hls({ maxBufferLength: 10 });
376	    hlsRef.current.loadSource(hoverStream.url);
377	    hlsRef.current.attachMedia(videoRef.current);
378	    return function() {
379	      if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
380	    };
381	  }, [hovered, hoverStream]);
382
383	  const showVideo = hovered && hoverStream;
384
385	  return (
386

387

388	        <AssetThumb asset={asset} />
389	        {showVideo && (
390	          <video
391	            key={hoverStream.url}
392	            ref={videoRef}
393	            src={hoverStream.type !== 'hls' ? hoverStream.url : undefined}
394	            autoPlay
395	            muted
396	            loop
397	            playsInline
398	            style={{
399	              position: 'absolute',
400	              inset: 0,
401	              width: '100%',
402	              height: '100%',
403	              objectFit: 'cover',
404	              borderRadius: 6,
405	            }}
406	          />
407	        )}
408

409

410	        {asset.status === 'live' && LIVE}
411	        {asset.status === 'processing' && Processing}
412	        {asset.status === 'error' && Error}
413

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

416
{asset.name}

417

418	          {asset.res}
419	          ·
420	          {asset.size}
421

422

423

424	  );
425	}
426
427	function binIcon(name) {
428	  return { grid: 'library', live: 'record', film: 'film', proxy: 'proxy', audio: 'audio', package: 'package' }[name] || 'folder';
429	}
430
431	function RenameAssetModal({ asset, onClose, onSaved }) {
432	  const [name, setName] = React.useState(asset.display_name || asset.name || '');
433	  const [saving, setSaving] = React.useState(false);
434	  const [err, setErr] = React.useState(null);
435	  const original = asset.display_name || asset.name || '';
436	  const submit = function() {
437	    const trimmed = name.trim();
438	    if (!trimmed || trimmed === original) { onClose(); return; }
439	    setSaving(true); setErr(null);
440	    window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ display_name: trimmed }) })
441	      .then(onSaved)
442	      .catch(function(e) { setSaving(false); setErr(e.message); });
443	  };
444	  return (
445

446

447

448
Rename asset

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

451

452

453	            Display name
454	            <input className="field-input" autoFocus value={name}
455	                   onChange={function(e) { setName(e.target.value); }}
456	                   onKeyDown={function(e) { if (e.key === 'Enter') submit(); if (e.key === 'Escape') onClose(); }} />
457

458	          {err &&
{err}
}
459

460

461	          <button className="btn ghost sm" onClick={onClose}>Cancel</button>
462	          <button className="btn primary sm" onClick={submit} disabled={saving || !name.trim()}>{saving ? 'Saving…' : 'Rename'}</button>
463

464

465

466	  );
467	}
468
469	window.Library = Library;
470	window.AssetCard = AssetCard;
471


0	// screens-projects.jsx
1
2	function NewProjectModal({ onClose, onCreated }) {
3	  const [name, setName] = React.useState('');
4	  const [saving, setSaving] = React.useState(false);
5	  const [err, setErr] = React.useState(null);
6
7	  const create = () => {
8	    if (!name.trim()) { setErr('Name is required'); return; }
9	    setSaving(true); setErr(null);
10	    window.ZAMPP_API.fetch('/projects', { method: 'POST', body: JSON.stringify({ name: name.trim() }) })
11	      .then(p => { onCreated(p); onClose(); })
12	      .catch(e => { setSaving(false); setErr(e.message || 'Failed to create project'); });
13	  };
14
15	  return (
16

17
 e.stopPropagation()}>
18

19
New project

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

22

23

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

29	          {err &&
{err}
}
30

31

32	          <button className="btn ghost" onClick={onClose}>Cancel</button>
33
34	          <button className="btn primary" onClick={create} disabled={saving}>
35	            {saving ? 'Creating…' : 'Create project'}
36	          </button>
37

38

39

40	  );
41	}
42
43	function Projects({ onOpenProject, navigate }) {
44	  const [projects, setProjects] = React.useState(window.ZAMPP_DATA?.PROJECTS || []);
45	  const ASSETS = window.ZAMPP_DATA?.ASSETS || [];
46	  const [search, setSearch] = React.useState('');
47	  const [view, setView] = React.useState('grid');
48	  const [showNew, setShowNew] = React.useState(false);
49	  const [menuFor, setMenuFor] = React.useState(null);
50	  const [renamingProject, setRenamingProject] = React.useState(null);
51
52	  const refresh = React.useCallback(() => {
53	    window.ZAMPP_API.fetch('/projects')
54	      .then(list => {
55	        const updated = (list || []).map((p, i) => ({
56	          ...p,
57	          color: (window.ZAMPP_DATA.PROJECTS.find(x => x.id === p.id) || {}).color
58	                 || window.PROJECT_COLORS?.[i % (window.PROJECT_COLORS?.length || 1)]
59	                 || 'var(--accent)',
60	          assets: (ASSETS || []).filter(a => a.project_id === p.id).length,
61	          updated: window.ZAMPP_API.fmtRelative(p.updated_at),
62	        }));
63	        window.ZAMPP_DATA.PROJECTS = updated;
64	        setProjects(updated);
65	      })
66	      .catch(() => {});
67	  }, [ASSETS]);
68
69	  const onCreated = (p) => { refresh(); };
70
71	  const renameProject = (p) => { setMenuFor(null); setRenamingProject(p); };
72
73	  const deleteProject = (p) => {
74	    setMenuFor(null);
75	    if (!confirm('Delete project "' + p.name + '"?\nThis fails if there are still assets attached.')) return;
76	    window.ZAMPP_API.fetch('/projects/' + p.id, { method: 'DELETE' })
77	      .then(refresh)
78	      .catch(e => alert('Delete failed: ' + e.message));
79	  };
80
81	  React.useEffect(() => {
82	    if (!menuFor) return;
83	    const close = () => setMenuFor(null);
84	    window.addEventListener('click', close);
85	    return () => window.removeEventListener('click', close);
86	  }, [menuFor]);
87
88	  let filtered = projects;
89	  if (search) filtered = filtered.filter(p => p.name.toLowerCase().includes(search.toLowerCase()));
90
91	  return (
92

93

94
Projects

95	        {filtered.length} projects
96

97

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

101

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

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

107

108	        {filtered.length === 0 ? (
109

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

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

115	            )}
116

117	        ) : view === 'grid' ? (
118

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

121	        ) : (
122

123

124
Project

Assets

Storage

Updated

125

126	            {filtered.map(p => (
127
 onOpenProject(p)}>
128

129

130
{p.name}

131

132
{p.assets || 0}

133
—

134
{p.updated || '—'}

135
 e.stopPropagation()}>
136	                  <button className="icon-btn" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === p.id ? null : p.id); }}><Icon name="more" /></button>
137	                  {menuFor === p.id && (
138
 e.stopPropagation()}>
139	                      <button onClick={() => { setMenuFor(null); onOpenProject(p); }}><Icon name="library" size={11} />Open</button>
140	                      <button onClick={() => renameProject(p)}><Icon name="edit" size={11} />Rename…</button>
141	                      <button className="danger" onClick={() => deleteProject(p)}><Icon name="trash" size={11} />Delete</button>
142

143	                  )}
144

145

146	            ))}
147

148	        )}
149

150	      {showNew && <NewProjectModal onClose={() => setShowNew(false)} onCreated={onCreated} />}
151	      {renamingProject && (
152	        <RenameProjectModal
153	          project={renamingProject}
154	          onClose={() => setRenamingProject(null)}
155	          onSaved={() => { setRenamingProject(null); refresh(); }}
156	        />
157	      )}
158

159	  );
160	}
161
162	function RenameProjectModal({ project, onClose, onSaved }) {
163	  const [name, setName] = React.useState(project.name || '');
164	  const [saving, setSaving] = React.useState(false);
165	  const [err, setErr] = React.useState(null);
166	  const submit = () => {
167	    const trimmed = name.trim();
168	    if (!trimmed || trimmed === project.name) { onClose(); return; }
169	    setSaving(true); setErr(null);
170	    window.ZAMPP_API.fetch('/projects/' + project.id, { method: 'PATCH', body: JSON.stringify({ name: trimmed }) })
171	      .then(onSaved)
172	      .catch(e => { setSaving(false); setErr(e.message); });
173	  };
174	  return (
175

176
 e.stopPropagation()}>
177

178
Rename project

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

181

182

183	            Project name
184	            <input className="field-input" autoFocus value={name}
185	                   onChange={e => setName(e.target.value)}
186	                   onKeyDown={e => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') onClose(); }} />
187

188	          {err &&
{err}
}
189

190

191	          <button className="btn ghost sm" onClick={onClose}>Cancel</button>
192	          <button className="btn primary sm" onClick={submit} disabled={saving || !name.trim()}>{saving ? 'Saving…' : 'Rename'}</button>
193

194

195

196	  );
197	}
198
199	function ProjectCard({ project, assets, onOpen }) {
200	  const ofProject = assets.filter(a => a.project_id === project.id);
201	  const thumbAssets = ofProject.slice(0, 4);
202
203	  // Real status distribution — ready vs processing/live vs error.
204	  const total = ofProject.length || 1;
205	  const ready      = ofProject.filter(a => a.status === 'ready').length;
206	  const inFlight   = ofProject.filter(a => a.status === 'processing' || a.status === 'live').length;
207	  const errored    = ofProject.filter(a => a.status === 'error').length;
208	  const readyPct    = (ready / total) * 100;
209	  const inFlightPct = (inFlight / total) * 100;
210	  const errPct      = (errored / total) * 100;
211
212	  return (
213

214

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

217	            {thumbAssets[i]
218	              ? <AssetThumb asset={thumbAssets[i]} />
219	              : <FauxFrame />}
220

221	        ))}
222

223

224

225
226	          {project.name}
227

228

229	          {ofProject.length} asset{ofProject.length === 1 ? '' : 's'}
230	          ·
231	          updated {project.updated || '—'}
232

233	        {ofProject.length > 0 ? (
234	          <div className="project-bar" title={`ready ${ready} · in-flight ${inFlight} · errored ${errored}`}>
235	            {ready    > 0 &&
}
236	            {inFlight > 0 &&
}
237	            {errored  > 0 &&
}
238

239	        ) : (
240

241	        )}
242

243

244	  );
245	}
246
247	window.Projects = Projects;
248


Good, I have all three files fully read. Now I'll push screens-library.jsx and screens-projects.jsx simultaneously, and also assemble the full screens-admin.jsx content (which I've read in segments, lines 0-1667).


// screens-library.jsx

function Library({ navigate, onOpenAsset, openProject }) {
  const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
  const [bins, setBins] = React.useState(window.ZAMPP_DATA?.BINS || []);
  const BINS = bins; // legacy local name; keep so the rest of the function reads unchanged

  // Re-fetch bins on mount + whenever the open project changes; surfaces
  // every-project bins when the global view is on, project-scoped otherwise.
  React.useEffect(() => {
    const qs = openProject ? '?project_id=' + openProject.id : '';
    window.ZAMPP_API.fetch('/bins' + qs)
      .then(list => {
        const normalized = (list || []).map(b => ({
          ...b,
          count: b.asset_count != null ? b.asset_count : (b.count || 0),
          icon:  b.type || 'grid',
        }));
        if (!openProject) window.ZAMPP_DATA.BINS = normalized;
        setBins(normalized);
      })
      .catch(() => {});
  }, [openProject]);

  const createBin = () => {
    if (!openProject) { window.alert('Open a project first (Projects → click a project), then create a bin inside it.'); return; }
    setNewBinName(''); setCreatingBin(true);
  };

  const submitBin = (name) => {
    if (!name || !name.trim()) { setCreatingBin(false); return; }
    setCreatingBin(false);
    window.ZAMPP_API.fetch('/bins', {
      method: 'POST',
      body: JSON.stringify({ project_id: openProject.id, name: name.trim() }),
    })
      .then(() => {
        window.ZAMPP_API.fetch('/bins?project_id=' + openProject.id)
          .then(list => setBins((list || []).map(b => ({ ...b, count: b.asset_count || 0, icon: b.type || 'grid' }))));
      })
      .catch(e => window.alert('Could not create bin: ' + e.message));
  };
  const [view, setView] = React.useState('grid');
  const [filter, setFilter] = React.useState('all');
  const [search, setSearch] = React.useState(window._dfPendingSearch || '');
  React.useEffect(() => { delete window._dfPendingSearch; }, []);
  // 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 [renamingAsset, setRenamingAsset] = React.useState(null);
  const [creatingBin, setCreatingBin] = React.useState(false);
  const [newBinName, setNewBinName] = React.useState('');

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

  const [selectedBinId, setSelectedBinId] = React.useState(null);
  // Clear bin filter on project change so a stale id doesn't hide everything.
  React.useEffect(() => { setSelectedBinId(null); }, [openProject?.id]);
  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()); });
  if (selectedBinId) assets = assets.filter(function(a) { return a.bin_id === selectedBinId; });

  const activeBin = selectedBinId ? BINS.find(b => b.id === selectedBinId) : null;
  const displayTitle = activeBin
    ? (openProject ? openProject.name + ' · ' : '') + activeBin.name
    : (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

            <button className="icon-btn" onClick={createBin}
              title={openProject ? 'Create bin in this project' : 'Open a project to create a bin'}
              style={{ opacity: openProject ? 1 : 0.5 }}>
              <Icon name="plus" size={11} />
            </button>

            {creatingBin && (

                <input
                  className="field-input"
                  autoFocus
                  value={newBinName}
                  onChange={function(e) { setNewBinName(e.target.value); }}
                  onKeyDown={function(e) {
                    if (e.key === 'Enter') submitBin(newBinName);
                    if (e.key === 'Escape') { setCreatingBin(false); }
                  }}
                  onBlur={function() { submitBin(newBinName); }}
                  placeholder="Bin name"
                  style={{ fontSize: 12, height: 26, padding: '0 6px', flex: 1 }}
                />

            )}
            {!creatingBin && BINS.length === 0 ? (

                {openProject ? 'No bins yet — click + to create one.' : 'Open a project to manage bins.'}

            ) : BINS.map(function(b) {
              const isActive = selectedBinId === b.id;
              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); }}
          onRename={function(a) { setCtxMenu(null); setRenamingAsset(a); }}
        />
      )}
      {renamingAsset && (
        <RenameAssetModal
          asset={renamingAsset}
          onClose={function() { setRenamingAsset(null); }}
          onSaved={function() { setRenamingAsset(null); refreshAssets(); }}
        />
      )}

  );
}

function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRename }) {
  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() { if (onRename) onRename(asset); else onClose(); };

  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
            .filter(function(b) { return !asset.project_id || b.project_id === asset.project_id; })
            .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}
                  title={b.project_name ? 'in ' + b.project_name : ''}>
                  <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 inside 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';
}

function RenameAssetModal({ asset, onClose, onSaved }) {
  const [name, setName] = React.useState(asset.display_name || asset.name || '');
  const [saving, setSaving] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const original = asset.display_name || asset.name || '';
  const submit = function() {
    const trimmed = name.trim();
    if (!trimmed || trimmed === original) { onClose(); return; }
    setSaving(true); setErr(null);
    window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ display_name: trimmed }) })
      .then(onSaved)
      .catch(function(e) { setSaving(false); setErr(e.message); });
  };
  return (

Rename asset

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

            Display name
            <input className="field-input" autoFocus value={name}
                   onChange={function(e) { setName(e.target.value); }}
                   onKeyDown={function(e) { if (e.key === 'Enter') submit(); if (e.key === 'Escape') onClose(); }} />

          {err &&
{err}
}

          <button className="btn ghost sm" onClick={onClose}>Cancel</button>
          <button className="btn primary sm" onClick={submit} disabled={saving || !name.trim()}>{saving ? 'Saving…' : 'Rename'}</button>

  );
}

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

fix(library): RenameAssetModal replaces prompt(), inline bin name input replaces prompt()


OK | commit:6fe5f7d | Updated services/web-ui/public/screens-library.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?.ASSETS || [];
  const [search, setSearch] = React.useState('');
  const [view, setView] = React.useState('grid');
  const [showNew, setShowNew] = React.useState(false);
  const [menuFor, setMenuFor] = React.useState(null);
  const [renamingProject, setRenamingProject] = React.useState(null);

  const refresh = React.useCallback(() => {
    window.ZAMPP_API.fetch('/projects')
      .then(list => {
        const updated = (list || []).map((p, i) => ({
          ...p,
          color: (window.ZAMPP_DATA.PROJECTS.find(x => x.id === p.id) || {}).color
                 || window.PROJECT_COLORS?.[i % (window.PROJECT_COLORS?.length || 1)]
                 || 'var(--accent)',
          assets: (ASSETS || []).filter(a => a.project_id === p.id).length,
          updated: window.ZAMPP_API.fmtRelative(p.updated_at),
        }));
        window.ZAMPP_DATA.PROJECTS = updated;
        setProjects(updated);
      })
      .catch(() => {});
  }, [ASSETS]);

  const onCreated = (p) => { refresh(); };

  const renameProject = (p) => { setMenuFor(null); setRenamingProject(p); };

  const deleteProject = (p) => {
    setMenuFor(null);
    if (!confirm('Delete project "' + p.name + '"?\nThis fails if there are still assets attached.')) return;
    window.ZAMPP_API.fetch('/projects/' + p.id, { method: 'DELETE' })
      .then(refresh)
      .catch(e => alert('Delete failed: ' + e.message));
  };

  React.useEffect(() => {
    if (!menuFor) return;
    const close = () => setMenuFor(null);
    window.addEventListener('click', close);
    return () => window.removeEventListener('click', close);
  }, [menuFor]);

  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 || '—'}

 e.stopPropagation()}>
                  <button className="icon-btn" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === p.id ? null : p.id); }}><Icon name="more" /></button>
                  {menuFor === p.id && (

 e.stopPropagation()}>
                      <button onClick={() => { setMenuFor(null); onOpenProject(p); }}><Icon name="library" size={11} />Open</button>
                      <button onClick={() => renameProject(p)}><Icon name="edit" size={11} />Rename…</button>
                      <button className="danger" onClick={() => deleteProject(p)}><Icon name="trash" size={11} />Delete</button>

                  )}

            ))}

        )}

      {showNew && <NewProjectModal onClose={() => setShowNew(false)} onCreated={onCreated} />}
      {renamingProject && (
        <RenameProjectModal
          project={renamingProject}
          onClose={() => setRenamingProject(null)}
          onSaved={() => { setRenamingProject(null); refresh(); }}
        />
      )}

  );
}

function RenameProjectModal({ project, onClose, onSaved }) {
  const [name, setName] = React.useState(project.name || '');
  const [saving, setSaving] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const submit = () => {
    const trimmed = name.trim();
    if (!trimmed || trimmed === project.name) { onClose(); return; }
    setSaving(true); setErr(null);
    window.ZAMPP_API.fetch('/projects/' + project.id, { method: 'PATCH', body: JSON.stringify({ name: trimmed }) })
      .then(onSaved)
      .catch(e => { setSaving(false); setErr(e.message); });
  };
  return (

 e.stopPropagation()}>

Rename project

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

            Project name
            <input className="field-input" autoFocus value={name}
                   onChange={e => setName(e.target.value)}
                   onKeyDown={e => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') onClose(); }} />

          {err &&
{err}
}

          <button className="btn ghost sm" onClick={onClose}>Cancel</button>
          <button className="btn primary sm" onClick={submit} disabled={saving || !name.trim()}>{saving ? 'Saving…' : 'Rename'}</button>

  );
}

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

  // Real status distribution — ready vs processing/live vs error.
  const total = ofProject.length || 1;
  const ready      = ofProject.filter(a => a.status === 'ready').length;
  const inFlight   = ofProject.filter(a => a.status === 'processing' || a.status === 'live').length;
  const errored    = ofProject.filter(a => a.status === 'error').length;
  const readyPct    = (ready / total) * 100;
  const inFlightPct = (inFlight / total) * 100;
  const errPct      = (errored / total) * 100;

  return (

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

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

        ))}

          {project.name}

          {ofProject.length} asset{ofProject.length === 1 ? '' : 's'}
          ·
          updated {project.updated || '—'}

        {ofProject.length > 0 ? (
          <div className="project-bar" title={`ready ${ready} · in-flight ${inFlight} · errored ${errored}`}>
            {ready    > 0 &&
}
            {inFlight > 0 &&
}
            {errored  > 0 &&
}

        ) : (

        )}

  );
}

window.Projects = Projects;

fix(projects): RenameProjectModal replaces prompt()


OK | commit:004bdd0 | Updated services/web-ui/public/screens-projects.jsx


Both library and projects pushed successfully. Now pushing screens-admin.jsx (the large file):