Search the Dragonflight MAM frontend for any remaining dead buttons, placeholder handlers, and UI rough edges. The project is at /tmp/wd/wild-dragon/services/web-ui/public/.

Look for these patterns across ALL .jsx files:
1. `onClick={() => {}}` — empty handlers
2. `onClick={() => alert(` — alert-based stubs
3. `console.log(` — debug logging left in
4. `// TODO` comments
5. `disabled` buttons that could be wired up
6. `// stub` or `// placeholder` comments
7. Any `prompt(` calls
8. Any `href="#"` links
9. `showToast` calls that show "coming soon" or similar stub messages
10. State variables declared but never used in JSX

Files to check: screens-asset.jsx, screens-library.jsx, screens-ingest.jsx, screens-admin.jsx, screens-projects.jsx, screens-jobs.jsx, screens-editor.jsx, shell.jsx, modal-new-recorder.jsx

For each file, report the exact line numbers and the issue. This is a read-only search — do not modify files.


I'll systematically search the Dragonflight MAM frontend for dead buttons, placeholder handlers, and UI rough edges. Let me start by examining all the specified .jsx files.


1	// screens-asset.jsx — asset detail (Frame.io-style player, filmstrip, comments)
2
3	// Simple gradient palette — replaces the missing thumbGrad function
4	const _FRAME_GRADIENTS = [
5	  'linear-gradient(135deg,#1a1f2e 0%,#2a3045 100%)',
6	  'linear-gradient(135deg,#1e2030 0%,#2d2040 100%)',
7	  'linear-gradient(135deg,#0d1520 0%,#1a2535 100%)',
8	  'linear-gradient(135deg,#1f1a2e 0%,#2a2040 100%)',
9	  'linear-gradient(135deg,#0f1e18 0%,#1a3028 100%)',
10	  'linear-gradient(135deg,#1e1510 0%,#302018 100%)',
11	  'linear-gradient(135deg,#1a1020 0%,#281830 100%)',
12	  'linear-gradient(135deg,#101828 0%,#182438 100%)',
13	  'linear-gradient(135deg,#1e2820 0%,#283830 100%)',
14	  'linear-gradient(135deg,#201820 0%,#302030 100%)',
15	  'linear-gradient(135deg,#181e28 0%,#202838 100%)',
16	];
17
18	function AssetDetail({ asset, onClose }) {
19	  const [playing, setPlaying] = React.useState(false);
20	  const [currentMs, setCurrentMs] = React.useState(0);
21	  const [tab, setTab] = React.useState("comments");
22	  const [showResolved, setShowResolved] = React.useState(false);
23	  const [comments, setComments] = React.useState([]);
24	  const [newComment, setNewComment] = React.useState("");
25	  const [commentsLoading, setCommentsLoading] = React.useState(false);
26
27	  // Stream / video state
28	  const [streamUrl, setStreamUrl] = React.useState(null);
29	  const [streamType, setStreamType] = React.useState(null);
30	  const [streamLoading, setStreamLoading] = React.useState(false);
31	  const [videoDuration, setVideoDuration] = React.useState(0);
32	  const [retrying, setRetrying] = React.useState(false);
33	  const videoRef = React.useRef(null);
34
35	  const assetId = asset && asset.id;
36	  const totalMs = videoDuration > 0 ? videoDuration : parseDuration(asset.duration);
37
38	  // Fetch stream URL when asset changes
39	  React.useEffect(() => {
40	    if (!assetId) return;
41	    setStreamUrl(null);
42	    setStreamType(null);
43	    setVideoDuration(0);
44	    setCurrentMs(0);
45	    setPlaying(false);
46	    setStreamLoading(true);
47	    window.ZAMPP_API.fetch('/assets/' + assetId + '/stream')
48	      .then(function(r) {
49	        if (r && r.url) {
50	          setStreamUrl(r.url);
51	          setStreamType(r.type || 'mp4');
52	        }
53	      })
54	      .catch(function() {})
55	      .finally(function() { setStreamLoading(false); });
56	  }, [assetId]);
57
58	  // Wire hls.js for live HLS streams
59	  React.useEffect(() => {
60	    if (!streamUrl || streamType !== 'hls' || !videoRef.current) return;
61	    if (!window.Hls) return;
62	    const hls = new window.Hls();
63	    hls.loadSource(streamUrl);
64	    hls.attachMedia(videoRef.current);
65	    return function() { hls.destroy(); };
66	  }, [streamUrl, streamType]);
67
68	  // Fake playback timer — only used when no real video stream
69	  React.useEffect(() => {
70	    if (!playing || totalMs <= 0 || streamUrl) return;
71	    const i = setInterval(function() {
72	      setCurrentMs(function(t) {
73	        const next = t + 100;
74	        if (next >= totalMs) { setPlaying(false); return totalMs; }
75	        return next;
76	      });
77	    }, 100);
78	    return function() { clearInterval(i); };
79	  }, [playing, totalMs, streamUrl]);
80
81	  const togglePlay = function() {
82	    if (videoRef.current) {
83	      if (videoRef.current.paused) { videoRef.current.play(); }
84	      else { videoRef.current.pause(); }
85	    } else {
86	      setPlaying(function(p) { return !p; });
87	    }
88	  };
89
90	  const seek = function(ms) {
91	    const clamped = Math.max(0, Math.min(totalMs || 0, ms));
92	    setCurrentMs(clamped);
93	    if (videoRef.current) videoRef.current.currentTime = clamped / 1000;
94	  };
95
96	  // Pull a presigned hi-res URL and trigger a browser download with the
97	  // asset's display name as the filename. Falls back to opening in a new tab.
98	  const [downloading, setDownloading] = React.useState(false);
99	  const downloadHires = function() {
100	    if (downloading) return;
101	    setDownloading(true);
102	    window.ZAMPP_API.fetch('/assets/' + assetId + '/hires')
103	      .then(function(r) {
104	        if (!r || !r.url) { window.alert('No hi-res source available for this asset.'); return; }
105	        const a = document.createElement('a');
106	        a.href = r.url;
107	        a.download = r.filename || (asset.name + '.' + (r.ext || 'mov'));
108	        a.target  = '_blank';
109	        a.rel     = 'noopener';
110	        document.body.appendChild(a);
111	        a.click();
112	        document.body.removeChild(a);
113	      })
114	      .catch(function(e) { window.alert('Download failed: ' + (e.message || 'unknown error')); })
115	      .finally(function() { setDownloading(false); });
116	  };
117
118	  // Right-click style menu on the kebab icon — delete, copy ID.
119	  const [menuOpen, setMenuOpen] = React.useState(false);
120	  const moreBtnRef = React.useRef(null);
121	  React.useEffect(function() {
122	    if (!menuOpen) return;
123	    const close = function() { setMenuOpen(false); };
124	    window.addEventListener('click', close);
125	    return function() { window.removeEventListener('click', close); };
126	  }, [menuOpen]);
127
128	  const copyId = function() {
129	    if (navigator.clipboard) navigator.clipboard.writeText(assetId).catch(function() {});
130	    setMenuOpen(false);
131	  };
132	  const deleteAsset = function() {
133	    setMenuOpen(false);
134	    if (!window.confirm('Delete "' + (asset.name || assetId) + '" permanently?\nRemoves DB row + S3 objects. Cannot be undone.')) return;
135	    window.ZAMPP_API.fetch('/assets/' + assetId + '?hard=true', { method: 'DELETE' })
136	      .then(function() { onClose && onClose(); })
137	      .catch(function(e) { window.alert('Delete failed: ' + e.message); });
138	  };
139
140	  const retryProcessing = function() {
141	    if (retrying) return;
142	    setRetrying(true);
143	    window.ZAMPP_API.fetch('/assets/' + assetId + '/retry', { method: 'POST' })
144	      .then(function() { window.alert('Re-queued for processing. Refresh in a moment.'); })
145	      .catch(function(e) { window.alert('Retry failed: ' + (e.message || 'unknown error')); })
146	      .finally(function() { setRetrying(false); });
147	  };
148
149	  // Map a /assets/:id/comments row into the legacy shape the consumer
150	  // components (PlaybackBar pins, FilmStrip pins, comment list) already expect.
151	  function _normalizeComment(row) {
152	    const frameMs = row.frame_ms != null ? row.frame_ms : 0;
153	    const real = row.created_at
154	      ? window.ZAMPP_API.fmtRelative(row.created_at)
155	      : 'just now';
156	    return {
157	      id:       row.id,
158	      who:      row.author_name || 'You',
159	      avatar:   row.author_initials || (row.author_name ? row.author_name.slice(0, 2).toUpperCase() : 'ZG'),
160	      time:     msToTimecode(frameMs),
161	      frame_ms: frameMs,
162	      real,
163	      text:     row.body,
164	      resolved: !!row.resolved,
165	      frame:    Math.floor(frameMs / 1000 * 30),
166	    };
167	  }
168
169	  // Load persisted comments whenever the open asset changes.
170	  React.useEffect(() => {
171	    if (!assetId) return;
172	    setCommentsLoading(true);
173	    window.ZAMPP_API.fetch('/assets/' + assetId + '/comments')
174	      .then(function(r) { setComments(((r && r.comments) || []).map(_normalizeComment)); })
175	      .catch(function() { setComments([]); })
176	      .finally(function() { setCommentsLoading(false); });
177	  }, [assetId]);
178
179	  const addComment = function() {
180	    const text = newComment.trim();
181	    if (!text) return;
182	    setNewComment('');
183	    window.ZAMPP_API.fetch('/assets/' + assetId + '/comments', {
184	      method: 'POST',
185	      body: JSON.stringify({ body: text, frame_ms: Math.round(currentMs) }),
186	    })
187	      .then(function(row) {
188	        setComments(function(c) { return [...c, _normalizeComment(row)]; });
189	      })
190	      .catch(function(e) {
191	        // Best-effort fallback so the user doesn't lose the typed comment.
192	        window.alert('Could not post comment: ' + (e.message || 'unknown error'));
193	        setNewComment(text);
194	      });
195	  };
196
197	  const toggleResolved = function(c) {
198	    window.ZAMPP_API.fetch('/assets/' + assetId + '/comments/' + c.id, {
199	      method: 'PATCH',
200	      body: JSON.stringify({ resolved: !c.resolved }),
201	    })
202	      .then(function(row) {
203	        setComments(function(prev) { return prev.map(x => x.id === c.id ? _normalizeComment(row) : x); });
204	      })
205	      .catch(function() {});
206	  };
207
208	  const deleteComment = function(c) {
209	    if (!confirm('Delete this comment?')) return;
210	    window.ZAMPP_API.fetch('/assets/' + assetId + '/comments/' + c.id, { method: 'DELETE' })
211	      .then(function() {
212	        setComments(function(prev) { return prev.filter(x => x.id !== c.id); });
213	      })
214	      .catch(function(e) { window.alert('Delete failed: ' + e.message); });
215	  };
216
217	  const visibleComments = comments.filter(function(c) { return showResolved || !c.resolved; });
218
219	  const statusMessage =
220	    asset.status === 'processing' ? 'Processing…' :
221	    asset.status === 'live'       ? 'Live recording in progress' :
222	    asset.status === 'error'      ? 'Processing failed' :
223	                                    'Preview not yet available';
224
225	  return (
226

227

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

230

231	            {asset.name}
232	            <StatusDot status={asset.status} />
233

234

235	            {asset.project}·updated {asset.updated}
236

237

238

239	        <button className="btn ghost sm" onClick={downloadHires} disabled={downloading} title="Download the hi-res master file">
240	          <Icon name="download" />{downloading ? 'Preparing…' : 'Download'}
241	        </button>
242

243	          <button ref={moreBtnRef} className="icon-btn" onClick={function(e) { e.stopPropagation(); setMenuOpen(function(v) { return !v; }); }}>
244	            <Icon name="more" />
245	          </button>
246	          {menuOpen && (
247

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

251	          )}
252

253

254
255

256

257

258

259	              {streamUrl ? (
260	                <video
261	                  key={streamUrl}
262	                  ref={videoRef}
263	                  src={streamType !== 'hls' ? streamUrl : undefined}
264	                  style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block', background: '#000' }}
265	                  onTimeUpdate={function() { if (videoRef.current) setCurrentMs(videoRef.current.currentTime * 1000); }}
266	                  onLoadedMetadata={function() { if (videoRef.current) setVideoDuration(videoRef.current.duration * 1000); }}
267	                  onPlay={function() { setPlaying(true); }}
268	                  onPause={function() { setPlaying(false); }}
269	                  onEnded={function() { setPlaying(false); }}
270	                />
271	              ) : (
272	                <React.Fragment>
273	                  <FauxFrame seed={asset.seed || 1} />
274

275

276

277	                      {streamLoading ? (
278
Loading…

279	                      ) : (
280	                        <React.Fragment>
281
{statusMessage}

282	                          <StatusDot status={asset.status} />
283	                          {asset.status === 'error' && (
284	                            <button
285	                              className="btn ghost sm"
286	                              style={{ marginTop: 12, display: 'block', margin: '12px auto 0' }}
287	                              onClick={retryProcessing}
288	                              disabled={retrying}
289	                            >
290	                              {retrying ? 'Retrying…' : 'Retry processing'}
291	                            </button>
292	                          )}
293	                        </React.Fragment>
294	                      )}
295

296

297	                  {!streamLoading && !playing && totalMs > 0 && (
298	                    <button className="player-play-overlay" onClick={togglePlay}>
299	                      <Icon name="play" size={28} />
300	                    </button>
301	                  )}
302	                </React.Fragment>
303	              )}
304
305

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

311
{c.avatar}

312
{c.text}

313

314	                    );
315	                  })}
316

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

319	                  LIVE · REC
320

321	              )}
322	              {totalMs > 0 && (
323

324	                  {msToTimecode(currentMs)}
325	                   / {asset.duration}
326

327	              )}
328

329
330	            {totalMs > 0 && (
331

332	                <button className="icon-btn" onClick={togglePlay}>
333	                  <Icon name={playing ? "pause" : "play"} size={14} />
334	                </button>
335	                {msToTimecode(currentMs)}
336	                <PlaybackBar current={currentMs} total={totalMs} onSeek={seek} comments={visibleComments} />
337	                {asset.duration}
338	                {streamUrl && (
339	                  <React.Fragment>
340

341	                    <button
342	                      className="icon-btn"
343	                      title="Toggle mute"
344	                      onClick={function() { if (videoRef.current) { videoRef.current.muted = !videoRef.current.muted; } }}>
345	                      <Icon name="audio" size={14} />
346	                    </button>
347	                    <button
348	                      className="icon-btn"
349	                      title="Toggle fullscreen"
350	                      onClick={function() { if (videoRef.current && videoRef.current.requestFullscreen) videoRef.current.requestFullscreen().catch(function() {}); }}>
351	                      <Icon name="layout" size={14} />
352	                    </button>
353	                  </React.Fragment>
354	                )}
355

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

362
363

364	            <button className={tab === "comments" ? "active" : ""} onClick={function() { setTab("comments"); }}>
365	              Comments {comments.length}
366	            </button>
367	            <button className={tab === "versions" ? "active" : ""} onClick={function() { setTab("versions"); }}>
368	              Versions
369	            </button>
370	            <button className={tab === "metadata" ? "active" : ""} onClick={function() { setTab("metadata"); }}>
371	              Metadata
372	            </button>
373	            <button className={tab === "audio" ? "active" : ""} onClick={function() { setTab("audio"); }}>
374	              Audio
375	            </button>
376

377	            {tab === "comments" && (
378
379	                <input type="checkbox" checked={showResolved} onChange={function(e) { setShowResolved(e.target.checked); }} /> Show resolved
380
381	            )}
382

383
384

385	            {tab === "comments" && (
386	              commentsLoading ? (
387
Loading comments…

388	              ) : (
389	                <CommentsList
390	                  comments={visibleComments}
391	                  onSeek={function(c) { seek(c.frame_ms != null ? c.frame_ms : parseDuration(c.time)); }}
392	                  onResolve={function(id) {
393	                    const c = comments.find(x => x.id === id);
394	                    if (c) toggleResolved(c);
395	                  }}
396	                  onDelete={function(id) {
397	                    const c = comments.find(x => x.id === id);
398	                    if (c) deleteComment(c);
399	                  }}
400	                />
401	              )
402	            )}
403	            {tab === "versions" && <VersionsTab />}
404	            {tab === "metadata" && <MetadataTab asset={asset} />}
405	            {tab === "audio" && <AudioTab asset={asset} />}
406

407

408
409

410	          <CommentComposer
411	            asset={asset}
412	            currentMs={currentMs}
413	            value={newComment}
414	            onChange={setNewComment}
415	            onSubmit={addComment}
416	          />
417

418

419

420	  );
421	}
422
423	function PlaybackBar({ current, total, onSeek, comments }) {
424	  const ref = React.useRef(null);
425	  const handle = function(e) {
426	    const r = ref.current.getBoundingClientRect();
427	    const p = (e.clientX - r.left) / r.width;
428	    onSeek(Math.max(0, Math.min(1, p)) * total);
429	  };
430	  const pct = total > 0 ? (current / total) * 100 : 0;
431	  return (
432

433

434

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

446	            {c.avatar}
447

448	        );
449	      })}
450

451	  );
452	}
453
454	function FilmStrip({ seed, current, total, onSeek, comments }) {
455	  const ref = React.useRef(null);
456	  const frames = 28;
457	  const handle = function(e) {
458	    if (!ref.current || total <= 0) return;
459	    const r = ref.current.getBoundingClientRect();
460	    onSeek(((e.clientX - r.left) / r.width) * total);
461	  };
462	  const pct = total > 0 ? (current / total) * 100 : 0;
463	  return (
464

465

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

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

471	          );
472	        })}
473

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

480	              {c.avatar}
481

482	          );
483	        })}
484

485

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

490

491	  );
492	}
493
494	function CommentsList({ comments, onSeek, onResolve, onDelete }) {
495	  if (comments.length === 0) {
496	    return
No comments yet. Add one below to mark a frame.
;
497	  }
498	  return (
499

500	      {comments.map(function(c) {
501	        return (
502

503
{c.avatar}

504

505

506	                {c.who}
507	                <button className="comment-time" onClick={function() { onSeek(c); }} title="Jump to this frame"><Icon name="clock" size={10} />{c.time}</button>
508	                {c.real}
509

510	                <button className="comment-action" onClick={function() { onResolve(c.id); }}
511	                  title={c.resolved ? 'Reopen' : 'Mark resolved'}>
512	                  <Icon name={c.resolved ? "refresh" : "check"} size={12} />
513	                </button>
514	                {onDelete && (
515	                  <button className="comment-action" onClick={function() { onDelete(c.id); }} title="Delete comment">
516	                    <Icon name="trash" size={12} />
517	                  </button>
518	                )}
519

520
{c.text}

521	              {c.resolved &&
✓ Resolved
}
522

523

524	        );
525	      })}
526

527	  );
528	}
529
530	function CommentComposer({ asset, currentMs, value, onChange, onSubmit }) {
531	  return (
532

533

534	        Reviewers
535

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

541

542

543	        @ {msToTimecode(currentMs)}
544

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

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

557

558	  );
559	}
560
561	function VersionsTab() {
562	  return (
563

564	      Version history not yet available.
565

566	  );
567	}
568
569	function MetadataTab({ asset }) {
570	  const rows = [
571	    { k: "Filename",   v: asset.name },
572	    { k: "Duration",   v: asset.duration || '—' },
573	    { k: "Resolution", v: asset.res || '—' },
574	    { k: "Codec",      v: asset.codec || '—' },
575	    { k: "File size",  v: asset.size || '—' },
576	    { k: "Status",     v: asset.status || '—' },
577	    { k: "Updated",    v: asset.updated || '—' },
578	    { k: "Project",    v: asset.project || '—' },
579	  ];
580	  return (
581

582	      {rows.map(function(r) {
583	        return (
584

585
{r.k}

586
{r.v}

587

588	        );
589	      })}
590

591	  );
592	}
593
594	function AudioTab({ asset }) {
595	  return (
596

597

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

601

602	  );
603	}
604
605	function parseDuration(d) {
606	  if (!d || d === '—' || typeof d !== 'string') return 0;
607	  const parts = d.split(':');
608	  if (parts.length < 2) return 0;
609	  const nums = parts.map(Number);
610	  if (nums.some(isNaN)) return 0;
611	  if (nums.length === 3) return ((nums[0] * 60 + nums[1]) * 60 + nums[2]) * 1000;
612	  return (nums[0] * 60 + nums[1]) * 1000;
613	}
614
615	function msToTimecode(ms) {
616	  const total = Math.floor((ms || 0) / 1000);
617	  const h = Math.floor(total / 3600);
618	  const m = Math.floor((total % 3600) / 60);
619	  const s = total % 60;
620	  return String(h).padStart(2, "0") + ':' + String(m).padStart(2, "0") + ':' + String(s).padStart(2, "0");
621	}
622
623	function avatarColor(initials) {
624	  let h = 0;
625	  for (const c of (initials || '?')) h = (h * 31 + c.charCodeAt(0)) & 0xff;
626	  return 'linear-gradient(135deg, hsl(' + (h % 360) + ' 60% 50%), hsl(' + ((h + 60) % 360) + ' 60% 45%))';
627	}
628
629	Object.assign(window, { AssetDetail, msToTimecode, parseDuration, avatarColor });
630


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

106
107

108
Projects

109

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

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

123	              );
124	            })}
125

126

127

128

129
Bins

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

136

137	            {BINS.length === 0 ? (
138

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

141	            ) : BINS.map(function(b) {
142	              const isActive = selectedBinId === b.id;
143	              return (
144

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

153	              );
154	            })}
155

156

157

158
Smart filters

159

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

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

163

164

165
166
167

168

169
{displayTitle}

170	          · {assets.length} assets
171

172

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

176

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

185

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

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

191
192	        {assets.length === 0 ? (
193
No assets match this filter.

194	        ) : view === 'grid' ? (
195

196	            {assets.map(function(a) {
197	              return <AssetCard key={a.id} asset={a}
198	                       onOpen={function() { onOpenAsset(a); }}
199	                       onContextMenu={function(e) { openCtx(a, e); }} />;
200	            })}
201

202	        ) : (
203

204

205

Name

Duration

Resolution

Codec

Size

Updated

206

207	            {assets.map(function(a) {
208	              return (
209

210
<AssetThumb asset={a} />

211

212
{a.name}

213

214	                      <StatusDot status={a.status} />
215	                      {a.status}
216

217

218
{a.duration}

219
{a.res}

220
{a.codec || '—'}

221
{a.size}

222
{a.updated}

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

225	              );
226	            })}
227

228	        )}
229

230	      {ctxMenu && (
231	        <AssetContextMenu
232	          asset={ctxMenu.asset}
233	          x={ctxMenu.x}
234	          y={ctxMenu.y}
235	          bins={BINS}
236	          onClose={function() { setCtxMenu(null); }}
237	          onChanged={refreshAssets}
238	          onOpen={function() { onOpenAsset(ctxMenu.asset); }}
239	        />
240	      )}
241

242	  );
243	}
244
245	function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen }) {
246	  const ref = React.useRef(null);
247	  // Pin the menu inside the viewport even if the user right-clicked near
248	  // the bottom-right edge of the grid.
249	  const [pos, setPos] = React.useState({ left: x, top: y });
250	  React.useLayoutEffect(() => {
251	    if (!ref.current) return;
252	    const r = ref.current.getBoundingClientRect();
253	    const margin = 8;
254	    let nx = x, ny = y;
255	    if (x + r.width  + margin > window.innerWidth)  nx = window.innerWidth  - r.width  - margin;
256	    if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin;
257	    setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) });
258	  }, [x, y]);
259
260	  const rename = function() {
261	    onClose();
262	    const next = prompt('Rename asset', asset.display_name || asset.name || '');
263	    if (next == null) return;
264	    const trimmed = next.trim();
265	    if (!trimmed || trimmed === (asset.display_name || asset.name)) return;
266	    window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ display_name: trimmed }) })
267	      .then(onChanged)
268	      .catch(function(e) { alert('Rename failed: ' + e.message); });
269	  };
270
271	  const moveToBin = function(binId) {
272	    onClose();
273	    window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) })
274	      .then(onChanged)
275	      .catch(function(e) { alert('Move failed: ' + e.message); });
276	  };
277
278	  const copyId = function() {
279	    onClose();
280	    if (navigator.clipboard) navigator.clipboard.writeText(asset.id).catch(function() {});
281	  };
282
283	  const remove = function() {
284	    onClose();
285	    if (!confirm('Delete "' + (asset.display_name || asset.name) + '" permanently?\nThis removes the database row and the S3 objects.\nThis cannot be undone.')) return;
286	    window.ZAMPP_API.fetch('/assets/' + asset.id + '?hard=true', { method: 'DELETE' })
287	      .then(onChanged)
288	      .catch(function(e) { alert('Delete failed: ' + e.message); });
289	  };
290
291	  return (
292

295
{asset.display_name || asset.name}

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

299	      {(bins && bins.length > 0) ? (
300	        <>
301
Move to bin

302	          {bins
303	            .filter(function(b) { return !asset.project_id || b.project_id === asset.project_id; })
304	            .slice(0, 10)
305	            .map(function(b) {
306	              const isCurrent = asset.bin_id === b.id;
307	              return (
308	                <button key={b.id} onClick={function() { moveToBin(b.id); }} disabled={isCurrent}
309	                  title={b.project_name ? 'in ' + b.project_name : ''}>
310	                  <Icon name="folder" size={11} />{b.name}{isCurrent && current}
311	                </button>
312	              );
313	            })}
314	          {asset.bin_id && (
315	            <button onClick={function() { moveToBin(null); }}>
316	              <Icon name="x" size={11} />Remove from bin
317	            </button>
318	          )}
319	        </>
320	      ) : (
321
No bins — create one inside a project

322	      )}
323

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

327	  );
328	}
329
330	function AssetCard({ asset, onOpen, onContextMenu }) {
331	  const [hoverStream, setHoverStream] = React.useState(null);
332	  const [hovered, setHovered] = React.useState(false);
333	  const timerRef = React.useRef(null);
334	  const hlsRef = React.useRef(null);
335	  const videoRef = React.useRef(null);
336
337	  const handleMouseEnter = function() {
338	    timerRef.current = setTimeout(function() {
339	      setHovered(true);
340	      if (!hoverStream) {
341	        window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream')
342	          .then(function(r) { if (r && r.url) setHoverStream(r); })
343	          .catch(function() {});
344	      }
345	    }, 350);
346	  };
347
348	  const handleMouseLeave = function() {
349	    clearTimeout(timerRef.current);
350	    setHovered(false);
351	  };
352
353	  // HLS wiring
354	  React.useEffect(function() {
355	    if (!hovered || !hoverStream || hoverStream.type !== 'hls' || !videoRef.current) return;
356	    if (!window.Hls) return;
357	    hlsRef.current = new window.Hls({ maxBufferLength: 10 });
358	    hlsRef.current.loadSource(hoverStream.url);
359	    hlsRef.current.attachMedia(videoRef.current);
360	    return function() {
361	      if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
362	    };
363	  }, [hovered, hoverStream]);
364
365	  const showVideo = hovered && hoverStream;
366
367	  return (
368

369

370	        <AssetThumb asset={asset} />
371	        {showVideo && (
372	          <video
373	            key={hoverStream.url}
374	            ref={videoRef}
375	            src={hoverStream.type !== 'hls' ? hoverStream.url : undefined}
376	            autoPlay
377	            muted
378	            loop
379	            playsInline
380	            style={{
381	              position: 'absolute',
382	              inset: 0,
383	              width: '100%',
384	              height: '100%',
385	              objectFit: 'cover',
386	              borderRadius: 6,
387	            }}
388	          />
389	        )}
390

391

392	        {asset.status === 'live' && LIVE}
393	        {asset.status === 'processing' && Processing}
394	        {asset.status === 'error' && Error}
395

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

398
{asset.name}

399

400	          {asset.res}
401	          ·
402	          {asset.size}
403

404

405

406	  );
407	}
408
409	function binIcon(name) {
410	  return { grid: 'library', live: 'record', film: 'film', proxy: 'proxy', audio: 'audio', package: 'package' }[name] || 'folder';
411	}
412
413	window.Library = Library;
414	window.AssetCard = AssetCard;
415


1	// screens-ingest.jsx — Upload, Recorders, Capture, Monitors
2
3	/* ===== Upload helpers ===== */
4	const _SIMPLE_MAX = 50 * 1024 * 1024;  // 50 MB → simple upload
5	const _PART_SIZE  = 10 * 1024 * 1024;  // 10 MB chunks for multipart
6
7	function _xhrPost(url, formData, onProgress) {
8	  return new Promise((resolve, reject) => {
9	    const xhr = new XMLHttpRequest();
10	    xhr.withCredentials = true;
11	    xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded, e.total); };
12	    xhr.onload = () => {
13	      if (xhr.status >= 200 && xhr.status < 300) {
14	        try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); }
15	      } else {
16	        let msg = xhr.status + ' ' + xhr.statusText;
17	        try { const j = JSON.parse(xhr.responseText); msg = j.error || j.message || msg; } catch {}
18	        reject(new Error(msg));
19	      }
20	    };
21	    xhr.onerror = () => reject(new Error('Network error'));
22	    xhr.open('POST', url);
23	    xhr.send(formData);
24	  });
25	}
26
27	async function _uploadFile(file, projectId, onProgress) {
28	  const mime = file.type || 'application/octet-stream';
29
30	  if (file.size <= _SIMPLE_MAX) {
31	    const fd = new FormData();
32	    fd.append('file', file, file.name);
33	    fd.append('filename', file.name);
34	    fd.append('projectId', projectId);
35	    fd.append('contentType', mime);
36	    return _xhrPost('/api/v1/upload/simple', fd,
37	      (loaded, total) => onProgress(Math.round((loaded / total) * 100)));
38	  }
39
40	  // — Multipart —
41	  const init = await window.ZAMPP_API.fetch('/upload/init', {
42	    method: 'POST',
43	    body: JSON.stringify({ filename: file.name, fileSize: file.size, contentType: mime, projectId }),
44	  });
45	  const { assetId, uploadId, key } = init;
46	  const totalParts = Math.ceil(file.size / _PART_SIZE);
47	  const parts = [];
48
49	  for (let i = 0; i < totalParts; i++) {
50	    const chunk = file.slice(i * _PART_SIZE, Math.min((i + 1) * _PART_SIZE, file.size));
51	    const fd = new FormData();
52	    fd.append('file', chunk, file.name);
53	    fd.append('uploadId', uploadId);
54	    fd.append('key', key);
55	    fd.append('partNumber', String(i + 1));
56	    const partRes = await _xhrPost('/api/v1/upload/part', fd,
57	      (loaded, total) => onProgress(Math.round(((i + loaded / total) / totalParts) * 100)));
58	    parts.push({ PartNumber: i + 1, ETag: partRes.etag || partRes.ETag });
59	  }
60
61	  await window.ZAMPP_API.fetch('/upload/complete', {
62	    method: 'POST',
63	    body: JSON.stringify({ uploadId, key, assetId, parts }),
64	  });
65	  return { id: assetId };
66	}
67
68	/* ===== Upload ===== */
69	function Upload({ navigate }) {
70	  const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
71	  const [files, setFiles] = React.useState([]);
72	  const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
73
74	  const updateFile = React.useCallback((id, patch) => {
75	    setFiles(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f));
76	  }, []);
77
78	  const startUpload = React.useCallback((entry, pid) => {
79	    _uploadFile(entry.file, pid, (pct) => updateFile(entry.id, { progress: pct }))
80	      .then(() => updateFile(entry.id, { status: 'done', progress: 100 }))
81	      .catch(e => updateFile(entry.id, { status: 'error', progress: 0, error: e.message }));
82	  }, [updateFile]);
83
84	  const handleDrop = React.useCallback((e) => {
85	    e.preventDefault();
86	    const dropped = Array.from(e.dataTransfer ? e.dataTransfer.files : e.target.files);
87	    const pid = projectId || PROJECTS[0]?.id || '';
88	    const newEntries = dropped.map((f, i) => ({
89	      id: Date.now() + i,
90	      name: f.name,
91	      size: window.ZAMPP_API.fmtSize(f.size),
92	      file: f,
93	      progress: 0,
94	      status: 'uploading',
95	      error: null,
96	    }));
97	    setFiles(prev => [...prev, ...newEntries]);
98	    newEntries.forEach(entry => startUpload(entry, pid));
99	  }, [projectId, startUpload]);
100
101	  return (
102

103

104
Upload

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

107

108

109

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

118

119
120
 e.preventDefault()}
123	          onClick={() => {
124	            const inp = document.createElement('input');
125	            inp.type = 'file'; inp.multiple = true;
126	            inp.onchange = handleDrop;
127	            inp.click();
128	          }}>
129	          <Icon name="upload" size={32} style={{ color: 'var(--text-3)' }} />
130
Drop files here or click to browse

131
Video, audio, and image files

132

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

136

137
138	        {files.length > 0 && (
139

140

141	              Queue {files.length}
142
143	              <button className="btn ghost sm" onClick={() => setFiles(f => f.filter(x => x.status === 'uploading'))}>Clear done</button>
144

145

146	              {files.map(f => (
147

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

151

152	                      {f.name}
153	                      {f.size}
154

155

156

161

162	                    {f.status === 'error' && (
163
{f.error}

164	                    )}
165

166
170	                    {f.status === 'done' ? '✓ done'
171	                     : f.status === 'error' ? '✗ failed'
172	                     : f.progress + '%'}
173
174

175	              ))}
176

177

178	        )}
179

180

181	  );
182	}
183
184	/* ===== Live preview (HLS) ====================================
185	   Shared by RecorderRow + MonitorTile. The capture container writes
186	   HLS segments to /live/{assetId}/index.m3u8 (see capture-manager.js
187	   and nginx.conf); we attach hls.js to a <video> when a recorder is
188	   actively recording and has a live asset.
189	   ============================================================ */
190	function HlsPreview({ assetId, muted = true, controls = false, className }) {
191	  const videoRef = React.useRef(null);
192	  const [err, setErr] = React.useState(null);
193
194	  React.useEffect(() => {
195	    if (!assetId || !videoRef.current) return;
196	    const url = '/live/' + assetId + '/index.m3u8';
197	    const v = videoRef.current;
198
199	    // Safari can play HLS natively; everything else needs hls.js.
200	    if (v.canPlayType('application/vnd.apple.mpegurl')) {
201	      v.src = url;
202	      const onErr = () => setErr('playback failed');
203	      v.addEventListener('error', onErr);
204	      return () => v.removeEventListener('error', onErr);
205	    }
206	    if (!window.Hls) { setErr('hls.js missing'); return; }
207
208	    const hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true });
209	    hls.loadSource(url);
210	    hls.attachMedia(v);
211	    hls.on(window.Hls.Events.ERROR, (_e, data) => {
212	      if (data.fatal) setErr(data.details || 'hls error');
213	    });
214	    return () => { try { hls.destroy(); } catch (_) {} };
215	  }, [assetId]);
216
217	  return (
218

219	      <video
220	        ref={videoRef}
221	        autoPlay
222	        playsInline
223	        muted={muted}
224	        controls={controls}
225	        style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
226	      />
227	      {err && (
228

230	          {err}
231

232	      )}
233

234	  );
235	}
236
237	/* ===== Recorders ===== */
238	function _normRecorder(r) {
239	  let elapsed = '—';
240	  if (r.status === 'recording' && r.started_at) {
241	    const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
242	    elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
243	              String(Math.floor((s % 3600) / 60)).padStart(2, '00') + ':' +
244	              String(s % 60).padStart(2, '0');
245	  }
246	  const cfg = r.source_config || {};
247	  return {
248	    ...r,
249	    source: r.source_type || '—',
250	    url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '—',
251	    codec: r.recording_codec || '—',
252	    res: r.recording_resolution || '—',
253	    node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
254	    elapsed,
255	    bitrate: '—',
256	    health: 100,
257	    audio: false,
258	  };
259	}
260
261	function Recorders({ navigate, onNew }) {
262	  const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
263
264	  const refresh = React.useCallback(() => {
265	    window.ZAMPP_API.fetch('/recorders')
266	      .then(raw => {
267	        const norm = (raw || []).map(_normRecorder);
268	        window.ZAMPP_DATA.RECORDERS = norm;
269	        setRecorders(norm);
270	      })
271	      .catch(() => {});
272	  }, []);
273
274	  React.useEffect(() => {
275	    refresh();
276	    const id = setInterval(refresh, 10000);
277	    return () => clearInterval(id);
278	  }, []);
279
280	  const liveCount = recorders.filter(r => r.status === 'recording').length;
281	  const errCount  = recorders.filter(r => r.status === 'error').length;
282
283	  return (
284

285

286
Recorders

287	        Live ingest from SRT, RTMP, and SDI sources
288

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

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

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

298

299	        {recorders.length === 0 ? (
300

301	            No recorders configured.
302
<button className="btn primary" onClick={onNew}><Icon name="plus" />Add recorder</button>

303

304	        ) : (
305

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

308	        )}
309

310

311	  );
312	}
313
314	function RecorderRow({ recorder: initialRecorder, onRefresh }) {
315	  const [recorder, setRecorder] = React.useState(initialRecorder);
316	  const [pending, setPending] = React.useState(false);
317	  const [err, setErr] = React.useState(null);
318	  const [liveStatus, setLiveStatus] = React.useState(null);
319	  const [clipName, setClipName] = React.useState('');
320	  const isRec = recorder.status === 'recording';
321
322	  React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);
323
324	  // Poll the status endpoint every 3s while recording for live feedback.
325	  React.useEffect(() => {
326	    if (!isRec) { setLiveStatus(null); return; }
327	    const poll = () => {
328	      window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/status')
329	        .then(s => setLiveStatus(s))
330	        .catch(() => {});
331	    };
332	    poll();
333	    const id = setInterval(poll, 3000);
334	    return () => clearInterval(id);
335	  }, [isRec, recorder.id]);
336
337	  const displayElapsed = React.useMemo(() => {
338	    if (liveStatus && liveStatus.duration != null) {
339	      const d = Math.max(0, liveStatus.duration);
340	      return String(Math.floor(d / 3600)).padStart(2, '0') + ':' +
341	             String(Math.floor((d % 3600) / 60)).padStart(2, '0') + ':' +
342	             String(d % 60).padStart(2, '0');
343	    }
344	    return recorder.elapsed;
345	  }, [liveStatus, recorder.elapsed]);
346
347	  const displaySignal = liveStatus
348	    ? (liveStatus.signal || '—')
349	    : (isRec ? 'connecting…' : '—');
350
351	  const signalColor = displaySignal === 'receiving' ? 'var(--success)'
352	    : displaySignal === 'stopped' ? 'var(--danger)'
353	    : 'var(--text-3)';
354
355	  const toggle = () => {
356	    if (pending) return;
357	    const action = isRec ? 'stop' : 'start';
358	    setPending(true);
359	    setErr(null);
360	    setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
361	    // Ship the operator-typed clip name on start; stop has no body.
362	    const body = (action === 'start' && clipName.trim())
363	      ? JSON.stringify({ clipName: clipName.trim() })
364	      : undefined;
365	    window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST', body })
366	      .then(() => {
367	        setPending(false);
368	        // Clear the input on a successful stop so the next take starts fresh.
369	        if (action === 'stop') setClipName('');
370	        onRefresh();
371	      })
372	      .catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
373	  };
374
375	  const handleDelete = () => {
376	    if (!window.confirm('Delete recorder "' + recorder.name + '"?\nThis will stop any active recording and cannot be undone.')) return;
377	    window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'DELETE' })
378	      .then(() => onRefresh())
379	      .catch(e => setErr(e.message || 'Delete failed'));
380	  };
381
382	  return (
383

384

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

391

392

393	          {recorder.name}
394
395	            <StatusDot status={recorder.status} /> {recorder.status.toUpperCase()}
396
397	          {recorder.source}
398

399
{recorder.url}

400

401	          {recorder.codec}·
402	          {recorder.res}
403

404	        {err &&
{err}
}
405	        {liveStatus?.lastError && isRec && (
406
{liveStatus.lastError}

407	        )}
408

409

410

411
Elapsed

412
{displayElapsed}

413

414

415
Signal

416
{displaySignal}

417

418	        {liveStatus?.currentFps != null && (
419

420
FPS

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

422

423	        )}
424

425

426	        {!isRec && (
427	          <input
428	            className="field-input"
429	            value={clipName}
430	            onChange={e => setClipName(e.target.value)}
431	            placeholder="Clip name (optional)"
432	            disabled={pending}
433	            maxLength={80}
434	            onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
435	            style={{ width: 180, padding: '5px 8px', fontSize: 12 }}
436	            title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
437	          />
438	        )}
439	        {isRec
440	          ? <button className="btn danger sm" onClick={toggle} disabled={pending}>
441	              {pending ? '…' : <>Stop</>}
442	            </button>
443	          : <button className="btn subtle sm" onClick={toggle} disabled={pending}>
444	              {pending ? '…' : <>Record</>}
445	            </button>}
446	        <button className="icon-btn" onClick={handleDelete} title="Delete recorder" style={{ color: 'var(--text-3)' }}>
447	          <Icon name="x" />
448	        </button>
449

450

451	  );
452	}
453
454	function badgeForStatus(s) {
455	  return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
456	}
457
458	/* ===== Capture ===== */
459	function Capture({ navigate }) {
460	  const [devices, setDevices] = React.useState([]);
461	  const [activeIdx, setActiveIdx] = React.useState(0);
462
463	  const loadDevices = () => {
464	    window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
465	      .then(devs => setDevices(Array.isArray(devs) ? devs : []))
466	      .catch(() => setDevices([]));
467	  };
468
469	  React.useEffect(() => { loadDevices(); }, []);
470
471	  if (devices.length === 0) {
472	    return (
473

474

475
Capture

476	          DeckLink SDI ingest
477

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

480

481

482	            No DeckLink devices found in cluster.
483

484

485

486	    );
487	  }
488
489	  const active = devices[activeIdx] || devices[0];
490
491	  return (
492

493

494
Capture

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

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

499

500

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

507

508

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

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

512
{active.hostname} · {active.ip_address}

513

514

515
Connect a source and click Refresh to see port status.

516

517

518

519	  );
520	}
521
522	/* ===== Monitors ===== */
523	function Monitors({ navigate }) {
524	  const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
525	  const [grid, setGrid] = React.useState(4);
526
527	  React.useEffect(() => {
528	    const refresh = () => {
529	      window.ZAMPP_API.fetch('/recorders')
530	        .then(raw => {
531	          const norm = (raw || []).map(_normRecorder);
532	          window.ZAMPP_DATA.RECORDERS = norm;
533	          setRecorders(norm);
534	        })
535	        .catch(() => {});
536	    };
537	    refresh();
538	    const id = setInterval(refresh, 5000);
539	    return () => clearInterval(id);
540	  }, []);
541
542	  const videoFeeds = recorders.filter(r => !r.audio);
543	  const audioFeeds = recorders.filter(r => r.audio).map(r => ({ ...r, kind: 'audio' }));
544	  const allFeeds = [...videoFeeds.map(r => ({ ...r, kind: 'video' })), ...audioFeeds];
545	  const feeds = allFeeds.slice(0, grid * grid);
546
547	  return (
548

549

550
Monitors

551	        Multi-cam live monitoring
552

553

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

558

559

560	        {feeds.length === 0 ? (
561
No active feeds. Start a recorder to see live video here.

562	        ) : (
563

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

566	        )}
567

568

569	  );
570	}
571
572	function MonitorTile({ feed, seed }) {
573	  const [levels, setLevels] = React.useState([0.65, 0.78]);
574	  const isLive = feed.status === 'recording';
575
576	  React.useEffect(() => {
577	    if (!isLive) return;
578	    const id = setInterval(() => {
579	      setLevels([0.3 + Math.random() * 0.55, 0.3 + Math.random() * 0.55]);
580	    }, 180);
581	    return () => clearInterval(id);
582	  }, [isLive]);
583
584	  if (feed.kind === 'audio') {
585	    return (
586

587

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

590

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

594

595	          LIVE
596	          {feed.name}
597

598

599	    );
600	  }
601
602	  return (
603

604	      {isLive && feed.live_asset_id
605	        ? <HlsPreview assetId={feed.live_asset_id} />
606	        : <FauxFrame />}
607	      {isLive &&
}
608

609	        {isLive                    && REC}
610	        {feed.status === 'stopped' && IDLE}
611	        {feed.status === 'idle'    && IDLE}
612	        {feed.status === 'error'   && ERR}
613

614	      {isLive && (
615

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

619	      )}
620

621	        {feed.name}
622	        {feed.elapsed && feed.elapsed !== '—' && {feed.elapsed}}
623

624

625	  );
626	}
627
628	/* ===== Schedule ===== */
629
630	const _STATUS_BADGE = {
631	  pending:   { cls: 'neutral',  label: 'pending' },
632	  running:   { cls: 'success',  label: 'recording' },
633	  completed: { cls: 'accent',   label: 'completed' },
634	  cancelled: { cls: 'warning',  label: 'cancelled' },
635	  failed:    { cls: 'danger',   label: 'failed' },
636	};
637
638	function _fmtWhen(iso) {
639	  if (!iso) return '—';
640	  const d = new Date(iso);
641	  // Local-time, short, human; e.g. "May 22 · 7:30 PM"
642	  return d.toLocaleString(undefined, {
643	    month: 'short', day: 'numeric',
644	    hour: 'numeric', minute: '2-digit',
645	  });
646	}
647
648	function _durationMin(startISO, endISO) {
649	  return Math.round((new Date(endISO) - new Date(startISO)) / 60000);
650	}
651
652	function Schedule({ navigate }) {
653	  const [schedules, setSchedules]   = React.useState(null);
654	  const [recorders, setRecorders]   = React.useState([]);
655	  const [showNew, setShowNew]       = React.useState(false);
656	  const [editing, setEditing]       = React.useState(null);
657	  const [filter, setFilter]         = React.useState('upcoming');
658
659	  const load = React.useCallback(() => {
660	    window.ZAMPP_API.fetch('/schedules?status=' + filter)
661	      .then(d => setSchedules(d.schedules || []))
662	      .catch(() => setSchedules([]));
663	  }, [filter]);
664
665	  React.useEffect(() => { load(); }, [load]);
666	  React.useEffect(() => { window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => setRecorders([])); }, []);
667
668	  // Auto-refresh every 10s so the list reflects the tick loop's transitions
669	  React.useEffect(() => {
670	    const t = setInterval(load, 10_000);
671	    return () => clearInterval(t);
672	  }, [load]);
673
674	  const cancel = (s) => {
675	    if (!confirm(`Cancel scheduled recording "${s.name}"?`)) return;
676	    window.ZAMPP_API.fetch('/schedules/' + s.id + '/cancel', { method: 'POST' })
677	      .then(load)
678	      .catch(e => alert('Cancel failed: ' + e.message));
679	  };
680	  const remove = (s) => {
681	    if (!confirm(`Delete schedule "${s.name}"?`)) return;
682	    window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' })
683	      .then(load)
684	      .catch(e => alert('Delete failed: ' + e.message));
685	  };
686
687	  return (
688

689

690
Schedule

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

693

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

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

703
704

705	        {schedules === null && (
706
Loading…

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

710
📅

711
No {filter} recordings

712	            {filter === 'upcoming' && recorders.length === 0 && (
713
Create a recorder first, then schedule it here.

714	            )}
715	            {filter === 'upcoming' && recorders.length > 0 && (
716
Click New schedule to plan a future recording.

717	            )}
718

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

722

723
Name

724
Recorder

725
Starts

726
Duration

727
Recurrence

728
Status

729

730

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

735

736	                    {s.name}
737	                    {s.error_message && (
738
{s.error_message}

739	                    )}
740

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

742
{_fmtWhen(s.start_at)}

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

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

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

746

747	                    {s.status === 'pending' && (
748	                      <button className="btn ghost sm" onClick={() => setEditing(s)} title="Edit name / time / recurrence">Edit</button>
749	                    )}
750	                    {(s.status === 'pending' || s.status === 'running') && (
751	                      <button className="btn ghost sm" onClick={() => cancel(s)}>Cancel</button>
752	                    )}
753	                    {(s.status === 'completed' || s.status === 'failed' || s.status === 'cancelled' || s.status === 'pending') && (
754	                      <button className="btn ghost sm" onClick={() => remove(s)} title="Delete schedule row">Delete</button>
755	                    )}
756

757

758	              );
759	            })}
760

761	        )}
762

763
764	      {showNew && <NewScheduleModal recorders={recorders} onClose={() => setShowNew(false)} onCreated={() => { setShowNew(false); load(); }} />}
765	      {editing && <EditScheduleModal schedule={editing} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load(); }} />}
766

767	  );
768	}
769
770	function EditScheduleModal({ schedule, onClose, onSaved }) {
771	  const toLocalInput = (iso) => {
772	    const d = new Date(iso);
773	    const tz = d.getTimezoneOffset() * 60_000;
774	    return new Date(d.getTime() - tz).toISOString().slice(0, 16);
775	  };
776	  const [form, setForm] = React.useState({
777	    name:       schedule.name,
778	    start_at:   toLocalInput(schedule.start_at),
779	    end_at:     toLocalInput(schedule.end_at),
780	    recurrence: schedule.recurrence || 'none',
781	  });
782	  const [saving, setSaving] = React.useState(false);
783	  const [err, setErr] = React.useState(null);
784
785	  const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
786
787	  const submit = () => {
788	    setErr(null);
789	    if (!form.name.trim()) return setErr('Name is required');
790	    const startD = new Date(form.start_at);
791	    const endD   = new Date(form.end_at);
792	    if (endD <= startD) return setErr('End must be after start');
793	    setSaving(true);
794	    window.ZAMPP_API.fetch('/schedules/' + schedule.id, {
795	      method: 'PUT',
796	      body: JSON.stringify({
797	        name: form.name.trim(),
798	        start_at: startD.toISOString(),
799	        end_at:   endD.toISOString(),
800	        recurrence: form.recurrence,
801	      }),
802	    })
803	      .then(onSaved)
804	      .catch(e => { setSaving(false); setErr(e.message || 'Save failed'); });
805	  };
806
807	  return (
808

809
 e.stopPropagation()}>
810

811
Edit scheduled recording

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

814

815

816	            Name
817	            <input className="field-input" autoFocus value={form.name}
818	                   onChange={e => set('name', e.target.value)} />
819

820

821	            Recorder
822	            <input className="field-input mono" value={schedule.recorder_name || schedule.recorder_id} readOnly
823	                   style={{ color: 'var(--text-3)' }} />
824
Recorder can't be reassigned — delete + recreate to change.

825

826

827

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

832

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

837

838

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

848	          {err &&
{err}
}
849

850

851	          <button className="btn ghost sm" onClick={onClose}>Cancel</button>
852	          <button className="btn primary sm" onClick={submit} disabled={saving}>{saving ? 'Saving…' : 'Save changes'}</button>
853

854

855

856	  );
857	}
858
859	function NewScheduleModal({ recorders, onClose, onCreated }) {
860	  // Default: start in 5 minutes, run for 30 min — gives the operator something
861	  // sensible the moment the modal opens.
862	  const now = new Date();
863	  now.setSeconds(0, 0);
864	  const startDefault = new Date(now.getTime() + 5 * 60 * 1000);
865	  const endDefault   = new Date(now.getTime() + 35 * 60 * 1000);
866	  const toLocalInput = (d) => {
867	    const tz = d.getTimezoneOffset() * 60_000;
868	    return new Date(d.getTime() - tz).toISOString().slice(0, 16);
869	  };
870
871	  const [form, setForm] = React.useState({
872	    name: '',
873	    recorder_id: recorders[0]?.id || '',
874	    start_at: toLocalInput(startDefault),
875	    end_at:   toLocalInput(endDefault),
876	    recurrence: 'none',
877	  });
878	  const [saving, setSaving] = React.useState(false);
879	  const [err, setErr] = React.useState(null);
880
881	  const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
882
883	  const submit = () => {
884	    setErr(null);
885	    if (!form.name.trim())   return setErr('Name is required');
886	    if (!form.recorder_id)   return setErr('Pick a recorder');
887	    if (!form.start_at)      return setErr('Start time is required');
888	    if (!form.end_at)        return setErr('End time is required');
889	    const startD = new Date(form.start_at);
890	    const endD   = new Date(form.end_at);
891	    if (endD <= startD) return setErr('End must be after start');
892
893	    // Warn (but allow) start times in the past — the scheduler tick will fire
894	    // them immediately, which is occasionally what the operator wants
895	    // (e.g. "record the next 30 minutes starting now").
896	    if (startD < new Date(Date.now() - 60_000)) {
897	      if (!confirm('Start time is in the past — recorder will fire immediately when saved.\nContinue?')) return;
898	    }
899
900	    // Datetime-local inputs are in the browser's local zone; ship as ISO so
901	    // Postgres stores them as TIMESTAMPTZ properly.
902	    const body = {
903	      name:        form.name.trim(),
904	      recorder_id: form.recorder_id,
905	      start_at:    startD.toISOString(),
906	      end_at:      endD.toISOString(),
907	      recurrence:  form.recurrence,
908	    };
909
910	    setSaving(true);
911	    window.ZAMPP_API.fetch('/schedules', { method: 'POST', body: JSON.stringify(body) })
912	      .then(() => onCreated())
913	      .catch(e => { setSaving(false); setErr(e.message || 'Failed to schedule'); });
914	  };
915
916	  const onKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) submit(); };
917
918	  return (
919

920
 e.stopPropagation()}>
921

922
New scheduled recording

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

925

926

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

932

933	            Recorder
934	            <select className="field-input" value={form.recorder_id}
935	                    onChange={e => set('recorder_id', e.target.value)}
936	                    style={{ appearance: 'auto' }}>
937	              {recorders.length === 0 && <option value="">— No recorders defined —</option>}
938	              {recorders.map(r => (
939	                <option key={r.id} value={r.id}>
940	                  {r.name} · {r.source_type?.toUpperCase() || '?'}
941	                </option>
942	              ))}
943	            </select>
944

945

946

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

952

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

958

959

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

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

971

972	          {err &&
{err}
}
973

974

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

980

981

982	  );
983	}
984
985	Object.assign(window, { Upload, Recorders, Capture, Monitors, Schedule });
986


Error: File content (25681 tokens) exceeds maximum allowed tokens (25000). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.


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

18
 e.stopPropagation()}>
19

20
New project

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

23

24

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

30	          {err &&
{err}
}
31

32

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

39

40

41	  );
42	}
43
44	function Projects({ onOpenProject, navigate }) {
45	  const [projects, setProjects] = React.useState(window.ZAMPP_DATA?.PROJECTS || []);
46	  const ASSETS = window.ZAMPP_DATA?.ASSETS || [];
47	  const [search, setSearch] = React.useState('');
48	  const [view, setView] = React.useState('grid');
49	  const [showNew, setShowNew] = React.useState(false);
50	  const [menuFor, setMenuFor] = 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) => {
72	    setMenuFor(null);
73	    const next = prompt('Rename project', p.name);
74	    if (!next || !next.trim() || next.trim() === p.name) return;
75	    window.ZAMPP_API.fetch('/projects/' + p.id, { method: 'PATCH', body: JSON.stringify({ name: next.trim() }) })
76	      .then(refresh)
77	      .catch(e => alert('Rename failed: ' + e.message));
78	  };
79
80	  const deleteProject = (p) => {
81	    setMenuFor(null);
82	    if (!confirm('Delete project "' + p.name + '"?\nThis fails if there are still assets attached.')) return;
83	    window.ZAMPP_API.fetch('/projects/' + p.id, { method: 'DELETE' })
84	      .then(refresh)
85	      .catch(e => alert('Delete failed: ' + e.message));
86	  };
87
88	  React.useEffect(() => {
89	    if (!menuFor) return;
90	    const close = () => setMenuFor(null);
91	    window.addEventListener('click', close);
92	    return () => window.removeEventListener('click', close);
93	  }, [menuFor]);
94
95	  let filtered = projects;
96	  if (search) filtered = filtered.filter(p => p.name.toLowerCase().includes(search.toLowerCase()));
97
98	  return (
99

100

101
Projects

102	        {filtered.length} projects
103

104

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

108

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

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

114

115	        {filtered.length === 0 ? (
116

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

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

122	            )}
123

124	        ) : view === 'grid' ? (
125

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

128	        ) : (
129

130

131
Project

Assets

Storage

Updated

132

133	            {filtered.map(p => (
134
 onOpenProject(p)}>
135

136

137
{p.name}

138

139
{p.assets || 0}

140
—

141
{p.updated || '—'}

142
 e.stopPropagation()}>
143	                  <button className="icon-btn" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === p.id ? null : p.id); }}><Icon name="more" /></button>
144	                  {menuFor === p.id && (
145
 e.stopPropagation()}>
146	                      <button onClick={() => { setMenuFor(null); onOpenProject(p); }}><Icon name="library" size={11} />Open</button>
147	                      <button onClick={() => renameProject(p)}><Icon name="edit" size={11} />Rename…</button>
148	                      <button className="danger" onClick={() => deleteProject(p)}><Icon name="trash" size={11} />Delete</button>
149

150	                  )}
151

152

153	            ))}
154

155	        )}
156

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

159	  );
160	}
161
162	function ProjectCard({ project, assets, onOpen }) {
163	  const ofProject = assets.filter(a => a.project_id === project.id);
164	  const thumbAssets = ofProject.slice(0, 4);
165
166	  // Real status distribution — ready vs processing/live vs error.
167	  const total = ofProject.length || 1;
168	  const ready      = ofProject.filter(a => a.status === 'ready').length;
169	  const inFlight   = ofProject.filter(a => a.status === 'processing' || a.status === 'live').length;
170	  const errored    = ofProject.filter(a => a.status === 'error').length;
171	  const readyPct    = (ready / total) * 100;
172	  const inFlightPct = (inFlight / total) * 100;
173	  const errPct      = (errored / total) * 100;
174
175	  return (
176

177

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

180	            {thumbAssets[i]
181	              ? <AssetThumb asset={thumbAssets[i]} />
182	              : <FauxFrame />}
183

184	        ))}
185

186

187

188
189	          {project.name}
190

191

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

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

202	        ) : (
203

204	        )}
205

206

207	  );
208	}
209
210	window.Projects = Projects;
211


1	// screens-jobs.jsx
2
3	function Jobs({ navigate }) {
4	  const [tab, setTab] = React.useState('all');
5	  const [jobs, setJobs] = React.useState(window.ZAMPP_DATA.JOBS);
6	  const [lastFetch, setLastFetch] = React.useState(Date.now());
7
8	  const normalizeJob = (j) => {
9	    const statusMap = { waiting: 'queued', active: 'running', completed: 'done', failed: 'failed' };
10	    const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode' };
11	    const meta = j.metadata || {};
12	    return {
13	      ...j,
14	      status: statusMap[j.status] || j.status,
15	      kind: kindMap[j.type] || j.type || 'Job',
16	      asset: j.asset_name || meta.filename || '—',
17	      eta: '—',
18	      node: meta.node || '—',
19	      priority: meta.priority || 'normal',
20	      error: j.error || null,
21	      progress: j.progress || 0,
22	    };
23	  };
24
25	  const refresh = React.useCallback(() => {
26	    window.ZAMPP_API.fetch('/jobs')
27	      .then(raw => {
28	        const norm = (raw || []).map(normalizeJob);
29	        window.ZAMPP_DATA.JOBS = norm;
30	        setJobs(norm);
31	        setLastFetch(Date.now());
32	      })
33	      .catch(() => {});
34	  }, []);
35
36	  React.useEffect(() => {
37	    const i = setInterval(refresh, 5000);
38	    return () => clearInterval(i);
39	  }, [refresh]);
40
41	  const handleRetry = React.useCallback((job) => {
42	    window.ZAMPP_API.fetch('/jobs/' + job.id + '/retry', { method: 'POST' })
43	      .then(() => refresh())
44	      .catch(e => alert('Retry failed: ' + e.message));
45	  }, [refresh]);
46
47	  const handleDelete = React.useCallback((job) => {
48	    if (!window.confirm('Remove this job from the queue?')) return;
49	    window.ZAMPP_API.fetch('/jobs/' + job.id, { method: 'DELETE' })
50	      .then(() => setJobs(prev => prev.filter(j => j.id !== job.id)))
51	      .catch(e => alert('Delete failed: ' + e.message));
52	  }, []);
53
54	  // Retry every failed job at once. Useful after a transient infra issue
55	  // (S3 outage, hung worker) — one click per job is painful with 20+ failures.
56	  const handleRetryAll = React.useCallback(() => {
57	    const failedJobs = jobs.filter(j => j.status === 'failed');
58	    if (failedJobs.length === 0) return;
59	    if (!window.confirm(`Re-queue all ${failedJobs.length} failed jobs?`)) return;
60	    Promise.allSettled(
61	      failedJobs.map(j => window.ZAMPP_API.fetch('/jobs/' + j.id + '/retry', { method: 'POST' }))
62	    ).then(refresh);
63	  }, [jobs, refresh]);
64
65	  const counts = {
66	    all:     jobs.length,
67	    running: jobs.filter(j => j.status === 'running').length,
68	    queued:  jobs.filter(j => j.status === 'queued').length,
69	    done:    jobs.filter(j => j.status === 'done').length,
70	    failed:  jobs.filter(j => j.status === 'failed').length,
71	  };
72	  const filtered = tab === 'all' ? jobs : jobs.filter(j => j.status === tab);
73
74	  return (
75

76

77
Jobs

78	        Proxy generation, transcoding, and processing queue
79

80	        {counts.failed > 0 && (
81	          <button className="btn ghost sm" onClick={handleRetryAll} title={`Retry all ${counts.failed} failed jobs`}>
82	            <Icon name="refresh" />Retry all failed
83	          </button>
84	        )}
85	        <button className="btn ghost sm" onClick={refresh}>
86	          <Icon name="refresh" />Refresh
87	        </button>
88

89
90

91

92

93
Running

94
{counts.running}

95
{counts.queued} queued

96

97

98
Completed

99
{counts.done}

100
Total done

101

102

103
Failed

104
{counts.failed}

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

108

109

110
Total jobs

111
{counts.all}

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

113

114

115
116

117	          {[
118	            { id: 'all',     label: 'All · ' + counts.all },
119	            { id: 'running', label: 'Running · ' + counts.running },
120	            { id: 'queued',  label: 'Queued · ' + counts.queued },
121	            { id: 'done',    label: 'Done · ' + counts.done },
122	            { id: 'failed',  label: 'Failed · ' + counts.failed },
123	          ].map(t => (
124	            <button key={t.id} className={tab === t.id ? 'active' : ''} onClick={() => setTab(t.id)}>{t.label}</button>
125	          ))}
126

127
128

129

130

Job

Asset

Node

Progress

ETA

Priority

131

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

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

136

137

138	  );
139	}
140
141	function JobRow({ job, onRetry, onDelete }) {
142	  const iconMap = { Proxy: 'proxy', Transcode: 'film', Thumbnail: 'image', Conform: 'layers' };
143	  return (
144

145
<StatusDot status={job.status} />

146

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

150
{job.asset}

151
{job.node}

152

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

155

156	            {Math.round(job.progress)}%
157

158	        )}
159	        {job.status === 'done' && <Icon name="check" size={12} /> Complete}
160	        {job.status === 'queued' && Waiting…}
161	        {job.status === 'failed' && (
162
164	            <Icon name="alert" size={12} />
165
166	              {(job.error || 'Failed').slice(0, 120)}
167
168
169	        )}
170

171
{job.eta}

172
{job.priority}

173

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

181

182	  );
183	}
184
185	window.Jobs = Jobs;
186


1	// screens-editor.jsx — Editor (timeline)
2
3	function _fmtTimecode(ms) {
4	  const s = Math.floor(ms / 1000);
5	  const f = Math.floor((ms % 1000) / (1000 / 30));
6	  return String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
7	         String(Math.floor((s % 3600) / 60)).padStart(2, '0') + ':' +
8	         String(s % 60).padStart(2, '0') + ':' +
9	         String(f).padStart(2, '0');
10	}
11
12	function Editor() {
13	  const { ASSETS } = window.ZAMPP_DATA;
14	  const [playing, setPlaying] = React.useState(false);
15	  const [currentMs, setCurrentMs] = React.useState(0);
16
17	  React.useEffect(() => {
18	    if (!playing) return;
19	    const i = setInterval(() => setCurrentMs(t => t + 100), 100);
20	    return () => clearInterval(i);
21	  }, [playing]);
22
23	  return (
24

25

26	        New sequence
27	        PREVIEW
28

29	        <button className="btn ghost sm" disabled title="Export not yet implemented — use the Premiere panel for now"><Icon name="download" />Export</button>
30	        <button className="btn primary sm" disabled title="Publish to MAM not yet implemented">Publish</button>
31

32

33
34

35	            Project bin
36
37	            <button className="icon-btn" disabled title="Bin search not yet implemented"><Icon name="search" size={12} /></button>
38

39

40	            {ASSETS.length === 0 ? (
41
No assets in library.

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

44
<AssetThumb asset={a} />

45

46
{a.name}

47
{a.duration}

48

49

50	            ))}
51

52
53

54

55	            <FauxFrame />
56	            {!playing && (
57	              <button className="player-play-overlay" onClick={() => setPlaying(true)}>
58	                <Icon name="play" size={28} />
59	              </button>
60	            )}
61
{_fmtTimecode(currentMs)}

62

63

64	            <button className="icon-btn" onClick={() => setCurrentMs(0)} title="Go to start"><Icon name="arrowLeft" size={14} /></button>
65	            <button className="icon-btn" onClick={() => setPlaying(p => !p)} title={playing ? 'Pause' : 'Play'}>
66	              <Icon name={playing ? 'pause' : 'play'} size={14} />
67	            </button>
68	            <button className="icon-btn" disabled title="Step forward — not yet implemented"><Icon name="arrowRight" size={14} /></button>
69
70	            <button className="btn ghost sm" disabled title="In/out trim points — not yet implemented">Mark in</button>
71	            <button className="btn ghost sm" disabled title="In/out trim points — not yet implemented">Mark out</button>
72	            <button className="btn subtle sm" disabled title="Timeline editing — not yet implemented">Add to timeline</button>
73

74

75
76
Inspector

77

78	            <InspGroup title="Transform">
79	              <InspRow label="Position" value="0, 0" />
80	              <InspRow label="Scale" value="100%" />
81	              <InspRow label="Rotation" value="0°" />
82	            </InspGroup>
83	            <InspGroup title="Color">
84	              <InspRow label="Exposure" value="0.0" />
85	              <InspRow label="Contrast" value="0.0" />
86	              <InspRow label="Saturation" value="0.0" />
87	            </InspGroup>
88	            <InspGroup title="Audio">
89	              <InspRow label="Level" value="0.0 dB" />
90	              <InspRow label="Pan" value="C" />
91	            </InspGroup>
92

93
94

95	      <EditorTimeline currentMs={currentMs} total={60} clips={[]} />
96
97	      {/* IN DEVELOPMENT overlay */}
98

104

109
In Development

110
Non-linear Editor

111

112	            Timeline editing, multi-track audio mixing,

113	            GPU-accelerated export, and color grading

114	            are coming in a future release.
115

116

117

118

119	  );
120	}
121
122	function InspGroup({ title, children }) {
123	  return (
124

125
{title}

126
{children}

127

128	  );
129	}
130	function InspRow({ label, value }) {
131	  return (
132

133	      {label}
134	      {value}
135

136	  );
137	}
138
139	function EditorTimeline({ currentMs, total = 60, clips = [] }) {
140	  const playheadPct = total > 0 ? ((currentMs / 1000) / total) * 100 : 0;
141	  return (
142

143

144	        Timeline
145
146

147

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

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

152	        ))}
153

154	      {clips.length === 0 && (
155
Drop assets from the bin to build a sequence.

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

159	  );
160	}
161
162	window.Editor = Editor;
163