Do a fast pass-3 audit on /tmp/wd/wild-dragon/services/web-ui/public/ and the new mam-api routes I've just touched at /tmp/wd/wild-dragon/services/mam-api/src/routes/.

Context: I've been polishing a self-hosted broadcast MAM all night. Recently fixed:
- Asset Detail Download button + More menu
- Library right-click context menu (Move to bin, Delete, etc.)
- Projects list-view row menu + real asset status bar
- Jobs "Retry all failed" + tooltip clipping
- Schedule past-time confirmation
- Recorder clip-name sanitization
- Containers/Cluster: alert() placeholders replaced with proper modals
- Sidebar Sign-out + topbar Notifications cleanup
- Editor: dead buttons marked as `disabled`

Look for:

1. **Newly introduced bugs** — anywhere I'm calling a function or referencing state that might not exist. Especially in screens-library.jsx, screens-asset.jsx, screens-projects.jsx, screens-admin.jsx, screens-jobs.jsx, screens-ingest.jsx (Schedule section).

2. **Missing CSS classes** — anywhere a JSX element uses a className like `.row-menu`, `.ctx-menu`, `.search-result-*`, `.project-segment`, `.schedule-row` etc. that might not have CSS in styles-rest.css.

3. **Missing icons** — any `<Icon name="..." />` where the icon name doesn't exist in icons.jsx. Current set: home library folder upload record capture jobs editor users token container cluster settings search bell plus chevron chevronDown more play pause film video audio image download key lock edit trash check arrowLeft arrowRight alert clock refresh x grid list power layout audio sliders.

4. **React hook order violations** — useState / useEffect / useCallback that might be called conditionally.

5. **Backend route conflicts** — anywhere two routes might shadow each other (e.g., `POST /:id/X` shadowed by `POST /X`).

Be specific: file:line, what's wrong, suggested fix. Cap at 25 findings. Under 400 words.


I'll do a fast pass-3 audit of both directories. Let me search for potential issues across the frontend and backend.


1	// icons.jsx - inline SVG icon set (lucide-style stroke icons)
2	// expose Icon component on window for all babel scripts to use
3
4	const ICONS = {
5	  home: <path d="M3 11l9-8 9 8M5 9.5V21h14V9.5" />,
6	  library: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
7	  folder: <path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" />,
8	  upload: <><path d="M12 16V4" /><path d="M6 10l6-6 6 6" /><path d="M4 20h16" /></>,
9	  record: <><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M22 8l-6 4 6 4V8z" /></>,
10	  capture: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><circle cx="12" cy="12" r="1" /></>,
11	  jobs: <><path d="M3 6h18" /><path d="M3 12h18" /><path d="M3 18h12" /></>,
12	  editor: <><path d="M14.06 2.94l7 7-11 11H3v-7.06l11.06-10.94z" /><path d="M13 4l7 7" /></>,
13	  users: <><circle cx="9" cy="8" r="4" /><path d="M2 21a7 7 0 0 1 14 0" /><circle cx="17" cy="6" r="3" /><path d="M22 18a5 5 0 0 0-7-4.5" /></>,
14	  token: <><circle cx="8" cy="15" r="4" /><path d="M10.85 12.15L19 4" /><path d="M18 5l3 3" /><path d="M15 8l3 3" /></>,
15	  container: <><rect x="3" y="6" width="18" height="12" rx="1" /><path d="M3 10h18" /><circle cx="7" cy="14" r="1" fill="currentColor" /><circle cx="11" cy="14" r="1" fill="currentColor" /></>,
16	  cluster: <><circle cx="12" cy="5" r="2.5" /><circle cx="5" cy="19" r="2.5" /><circle cx="19" cy="19" r="2.5" /><path d="M12 7.5l-6.5 9M12 7.5l6.5 9M7.5 19h9" /></>,
17	  settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" /></>,
18	  search: <><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></>,
19	  bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 8 3 8H3s3-1 3-8z" /><path d="M10 21a2 2 0 0 0 4 0" /></>,
20	  plus: <><path d="M12 5v14M5 12h14" /></>,
21	  chevron: <path d="M9 6l6 6-6 6" />,
22	  chevronDown: <path d="M6 9l6 6 6-6" />,
23	  more: <><circle cx="5" cy="12" r="1.5" fill="currentColor" /><circle cx="12" cy="12" r="1.5" fill="currentColor" /><circle cx="19" cy="12" r="1.5" fill="currentColor" /></>,
24	  play: <path d="M6 4l14 8-14 8V4z" fill="currentColor" stroke="none" />,
25	  pause: <><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></>,
26	  film: <><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 7h4M3 11h4M3 15h4M3 19h4M17 7h4M17 11h4M17 15h4M17 19h4M7 3v18M17 3v18" /></>,
27	  video: <><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M22 8l-6 4 6 4V8z" /></>,
28	  audio: <><path d="M3 12v-2a2 2 0 0 1 2-2h2l5-4v16l-5-4H5a2 2 0 0 1-2-2z" /><path d="M16 8a5 5 0 0 1 0 8M19 5a9 9 0 0 1 0 14" /></>,
29	  image: <><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="M21 15l-5-5L5 21" /></>,
30	  download: <><path d="M12 4v12M6 10l6 6 6-6" /><path d="M4 20h16" /></>,
31	  key:   <><circle cx="7.5" cy="15.5" r="3.5" /><path d="M10 13l9-9M16 7l3 3M14 9l3 3" /></>,
32	  lock:  <><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>,
33	  edit:  <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /></>,
34	  share: <><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4" /></>,
35	  link: <><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 1 0-7-7l-1 1" /><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 1 0 7 7l1-1" /></>,
36	  check: <path d="M5 12l5 5L20 7" />,
37	  x: <path d="M6 6l12 12M6 18L18 6" />,
38	  filter: <path d="M3 5h18l-7 9v6l-4-2v-4L3 5z" />,
39	  sort: <><path d="M3 6h13M3 12h9M3 18h5" /><path d="M17 14l3 3 3-3M20 9v8" /></>,
40	  grid: <><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></>,
41	  list: <><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1" fill="currentColor" /><circle cx="4" cy="12" r="1" fill="currentColor" /><circle cx="4" cy="18" r="1" fill="currentColor" /></>,
42	  comment: <path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z" />,
43	  clock: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>,
44	  layers: <><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5M2 12l10 5 10-5" /></>,
45	  gpu: <><rect x="3" y="7" width="18" height="10" rx="1" /><rect x="6" y="10" width="4" height="4" /><rect x="14" y="10" width="4" height="4" /><path d="M3 11H1M3 13H1M23 11h-2M23 13h-2" /></>,
46	  cpu: <><rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3" /></>,
47	  hdd: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="1" fill="currentColor" /></>,
48	  sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></>,
49	  moon: <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />,
50	  signal: <><path d="M2 20h.01M7 20v-4M12 20v-8M17 20V8M22 20V4" /></>,
51	  zap: <path d="M13 2L3 14h8l-1 8 10-12h-8l1-8z" fill="currentColor" stroke="none" />,
52	  layout: <><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18M9 21V9" /></>,
53	  sliders: <><path d="M4 6h11M19 6h1M4 12h2M10 12h10M4 18h7M15 18h5" /><circle cx="17" cy="6" r="2" /><circle cx="8" cy="12" r="2" /><circle cx="13" cy="18" r="2" /></>,
54	  panel: <><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 3v18" /></>,
55	  alert: <><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></>,
56	  refresh: <><path d="M21 12a9 9 0 0 1-15 6.7L3 16" /><path d="M3 12a9 9 0 0 1 15-6.7L21 8" /><path d="M21 3v5h-5M3 21v-5h5" /></>,
57	  trash: <><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" /></>,
58	  copy: <><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></>,
59	  eye: <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z" /><circle cx="12" cy="12" r="3" /></>,
60	  arrowRight: <path d="M5 12h14M13 5l7 7-7 7" />,
61	  arrowLeft: <path d="M19 12H5M11 5l-7 7 7 7" />,
62	  externalLink: <><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><path d="M15 3h6v6M10 14L21 3" /></>,
63	  monitor: <><rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8M12 17v4" /></>,
64	  diamond: <path d="M12 2l10 10-10 10L2 12 12 2z" />,
65	  power: <><path d="M18.36 6.64a9 9 0 1 1-12.73 0" /><path d="M12 2v10" /></>,
66	  globe: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></>,
67	  package: <><path d="M3 7l9-4 9 4M3 7v10l9 4 9-4V7M3 7l9 4 9-4M12 11v10" /></>,
68	  proxy: <><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 12l3-3 3 3M12 9v8" /></>,
69	};
70
71	function Icon({ name, size = 16, className, style }) {
72	  const path = ICONS[name];
73	  if (!path) return null;
74	  return (
75	    <svg
76	      width={size}
77	      height={size}
78	      viewBox="0 0 24 24"
79	      fill="none"
80	      stroke="currentColor"
81	      strokeWidth="1.5"
82	      strokeLinecap="round"
83	      strokeLinejoin="round"
84	      className={className}
85	      style={style}
86	    >
87	      {path}
88	    </svg>
89	  );
90	}
91
92	window.Icon = Icon;
93


1	/* ========== Projects ========== */
2	.projects-grid {
3	  display: grid;
4	  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
5	  gap: 14px;
6	}
7	.project-card {
8	  background: var(--bg-1);
9	  border: 1px solid var(--border);
10	  border-radius: var(--r-lg);
11	  overflow: hidden;
12	  cursor: pointer;
13	  transition: transform 120ms, border 80ms;
14	}
15	.project-card:hover {
16	  transform: translateY(-2px);
17	  border-color: var(--border-stronger);
18	}
19	.project-thumb-grid {
20	  display: grid;
21	  grid-template-columns: 2fr 1fr;
22	  grid-template-rows: 1fr 1fr;
23	  gap: 2px;
24	  aspect-ratio: 16 / 9;
25	  background: var(--bg-2);
26	}
27	.project-thumb-grid .project-thumb-cell:first-child { grid-row: span 2; }
28	.project-thumb-cell { position: relative; overflow: hidden; background: var(--bg-2); }
29	.project-thumb-cell .thumb-svg { width: 100%; height: 100%; }
30	.project-card-body { padding: 12px 14px; }
31	.project-meta {
32	  display: flex; gap: 6px;
33	  font-size: 11.5px; color: var(--text-3);
34	  margin-top: 4px;
35	}
36	.project-bar {
37	  margin-top: 10px;
38	  display: flex;
39	  height: 4px;
40	  border-radius: 99px;
41	  overflow: hidden;
42	  background: var(--bg-3);
43	  gap: 1px;
44	}
45
46	/* ========== Upload ========== */
47	.field-label {
48	  font-size: 10.5px;
49	  font-weight: 600;
50	  text-transform: uppercase;
51	  letter-spacing: 0.06em;
52	  color: var(--text-4);
53	  display: block;
54	  margin-bottom: 6px;
55	}
56	.select-faux, .field-input {
57	  height: 34px;
58	  background: var(--bg-1);
59	  border: 1px solid var(--border);
60	  border-radius: var(--r-sm);
61	  padding: 0 10px;
62	  display: flex; align-items: center; gap: 8px;
63	  color: var(--text-1);
64	  font-size: 12.5px;
65	  width: 100%;
66	  cursor: pointer;
67	}
68	.field-input { font-size: 12.5px; outline: 0; }
69	.field-input:focus { border-color: var(--accent); background: var(--bg-2); }
70	.field-input.select { justify-content: space-between; }
71
72	.dropzone {
73	  background: var(--bg-1);
74	  border: 1.5px dashed var(--border-stronger);
75	  border-radius: var(--r-lg);
76	  padding: 40px 24px;
77	  display: flex;
78	  flex-direction: column;
79	  align-items: center;
80	  gap: 8px;
81	  cursor: pointer;
82	  transition: background 120ms, border 120ms;
83	}
84	.dropzone:hover { background: var(--bg-2); border-color: var(--accent); }
85	.dropzone-formats { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; justify-content: center; }
86	.upload-row {
87	  display: flex; align-items: center; gap: 12px;
88	  padding: 12px 14px;
89	  border-bottom: 1px solid var(--border);
90	}
91	.upload-row:last-child { border-bottom: 0; }
92
93	/* ========== Recorders ========== */
94	.recorders-list { display: flex; flex-direction: column; gap: 10px; }
95	.recorder-row {
96	  background: var(--bg-1);
97	  border: 1px solid var(--border);
98	  border-radius: var(--r-lg);
99	  padding: 12px;
100	  display: grid;
101	  grid-template-columns: 220px 1fr 380px auto;
102	  align-items: center;
103	  gap: 16px;
104	  transition: border 80ms;
105	}
106	.recorder-row:hover { border-color: var(--border-stronger); }
107	.recorder-row.recording { border-color: rgba(255,59,48,0.25); }
108	.recorder-row.error { border-color: rgba(255,91,91,0.25); }
109	.recorder-preview {
110	  height: 56px;
111	  border-radius: var(--r-sm);
112	  background: var(--bg-2);
113	  overflow: hidden;
114	  position: relative;
115	}
116	.recorder-empty {
117	  height: 100%;
118	  display: grid; place-items: center;
119	  color: var(--text-3);
120	}
121	.recorder-audio-prev {
122	  height: 100%;
123	  display: flex; align-items: center; gap: 10px;
124	  padding: 0 12px;
125	}
126	.recorder-audio-prev .waveform { flex: 1; height: 80%; }
127	.recorder-info { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
128	.recorder-sub {
129	  font-size: 11.5px;
130	  color: var(--text-3);
131	  display: flex; gap: 6px;
132	}
133	.recorder-sub.mono { font-family: var(--font-mono); font-size: 11px; }
134	.recorder-stats {
135	  display: grid;
136	  grid-template-columns: 90px 90px 1fr;
137	  gap: 12px;
138	}
139	.recorder-stat .stat-label {
140	  font-size: 10px;
141	  text-transform: uppercase;
142	  letter-spacing: 0.06em;
143	  font-weight: 600;
144	  color: var(--text-4);
145	  margin-bottom: 3px;
146	}
147	.recorder-stat .stat-val { font-size: 12.5px; }
148	.recorder-actions { display: flex; align-items: center; gap: 4px; }
149	.rec-dot {
150	  width: 8px; height: 8px;
151	  border-radius: 50%;
152	  background: currentColor;
153	}
154
155	/* ========== Capture / DeckLink ========== */
156	.decklink-card {
157	  background: var(--bg-1);
158	  border: 1px solid var(--border);
159	  border-radius: var(--r-lg);
160	  overflow: hidden;
161	}
162	.decklink-head {
163	  padding: 14px 16px;
164	  display: flex; justify-content: space-between; align-items: center;
165	  border-bottom: 1px solid var(--border);
166	}
167	.decklink-body {
168	  padding: 24px;
169	  display: grid;
170	  grid-template-columns: 60px 1fr;
171	  gap: 20px;
172	  align-items: stretch;
173	  background:
174	    radial-gradient(ellipse at top, rgba(91,124,250,0.05), transparent 60%),
175	    var(--bg-0);
176	}
177	.decklink-card-face {
178	  background: linear-gradient(180deg, #1c1f28, #0d0e13);
179	  border: 1px solid var(--border-strong);
180	  border-radius: 6px;
181	  display: flex; flex-direction: column;
182	  justify-content: space-between;
183	  padding: 12px 6px;
184	  position: relative;
185	}
186	.decklink-label {
187	  writing-mode: vertical-rl;
188	  transform: rotate(180deg);
189	  font-family: var(--font-mono);
190	  font-size: 8.5px;
191	  letter-spacing: 0.12em;
192	  color: var(--text-3);
193	  text-align: center;
194	  margin: 0 auto;
195	}
196	.decklink-leds {
197	  display: flex; gap: 6px;
198	  justify-content: center;
199	}
200	.decklink-led {
201	  width: 6px; height: 6px;
202	  border-radius: 50%;
203	  background: rgba(255,255,255,0.1);
204	}
205	.decklink-led.on {
206	  background: var(--success);
207	  box-shadow: 0 0 6px var(--success);
208	}
209	.bnc-ports { display: flex; flex-direction: column; gap: 10px; }
210	.bnc-port {
211	  display: grid;
212	  grid-template-columns: 32px 1fr auto;
213	  align-items: center;
214	  gap: 12px;
215	  padding: 10px;
216	  background: var(--bg-2);
217	  border: 1px solid var(--border);
218	  border-radius: var(--r-sm);
219	  text-align: left;
220	  cursor: pointer;
221	  position: relative;
222	  transition: background 80ms, border 80ms;
223	}
224	.bnc-port:hover { border-color: var(--border-stronger); }
225	.bnc-port.active { background: var(--bg-3); }
226	.bnc-port.active.live::before {
227	  content: "";
228	  position: absolute;
229	  left: 0; top: 0; bottom: 0;
230	  width: 2px;
231	  background: var(--success);
232	}
233	.bnc-port.recording::before { background: var(--live); }
234	.bnc-port.active { box-shadow: inset 0 0 0 1px var(--accent); border-color: var(--accent); }
235	.bnc-connector {
236	  width: 32px; height: 32px;
237	  border-radius: 50%;
238	  background: radial-gradient(circle, #1a1d24, #0a0c11);
239	  display: grid; place-items: center;
240	  border: 1.5px solid var(--border-stronger);
241	  position: relative;
242	}
243	.bnc-pin {
244	  width: 6px; height: 6px;
245	  background: linear-gradient(135deg, #6b7280, #2a2f3a);
246	  border-radius: 50%;
247	}
248	.bnc-ring {
249	  position: absolute;
250	  inset: 4px;
251	  border: 1px dashed rgba(255,255,255,0.08);
252	  border-radius: 50%;
253	}
254	.bnc-port.live .bnc-pin { background: linear-gradient(135deg, #FFC857, #C8862D); box-shadow: 0 0 8px rgba(255,200,87,0.4); }
255	.bnc-port.recording .bnc-pin { background: linear-gradient(135deg, #FF5C5C, #C8362D); box-shadow: 0 0 8px rgba(255,92,92,0.5); }
256	.bnc-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
257	.bnc-num { font-size: 12.5px; font-weight: 600; }
258	.bnc-label { font-size: 11px; color: var(--text-2); }
259	.bnc-sig { font-size: 10.5px; color: var(--text-3); }
260	.bnc-port.live .bnc-sig { color: var(--success); }
261	.bnc-rec {
262	  width: 10px; height: 10px;
263	  border-radius: 50%;
264	  background: var(--live);
265	  animation: pulse 1.2s ease-in-out infinite;
266	}
267	.bnc-signal-bar {
268	  grid-column: 2 / 4;
269	  height: 3px;
270	  background: var(--bg-3);
271	  border-radius: 99px;
272	  overflow: hidden;
273	  margin-top: 4px;
274	}
275	.bnc-signal-fill {
276	  height: 100%;
277	  background: linear-gradient(90deg, var(--success), var(--warning), var(--danger));
278	  background-size: 200% 100%;
279	  background-position: 0% 0;
280	  transition: width 200ms;
281	}
282	.decklink-foot {
283	  padding: 10px 16px;
284	  border-top: 1px solid var(--border);
285	  display: flex; gap: 20px;
286	  font-size: 11.5px;
287	  color: var(--text-3);
288	}
289	.decklink-foot strong { color: var(--text-1); font-weight: 600; }
290
291	.capture-detail {
292	  background: var(--bg-1);
293	  border: 1px solid var(--border);
294	  border-radius: var(--r-lg);
295	  padding: 16px;
296	}
297	.capture-detail.empty {
298	  display: flex; flex-direction: column;
299	  align-items: center; justify-content: center;
300	  padding: 80px 24px;
301	  color: var(--text-3);
302	}
303	.capture-preview {
304	  position: relative;
305	  aspect-ratio: 16 / 9;
306	  border-radius: var(--r-md);
307	  overflow: hidden;
308	  background: #000;
309	}
310	.capture-overlay-meters {
311	  position: absolute;
312	  right: 8px; top: 8px;
313	  display: flex; gap: 4px;
314	}
315	.capture-tc {
316	  position: absolute;
317	  bottom: 8px; right: 8px;
318	  background: rgba(0,0,0,0.7);
319	  padding: 3px 8px;
320	  border-radius: 3px;
321	  font-family: var(--font-mono);
322	  font-size: 11px;
323	  color: white;
324	}
325	.capture-stats {
326	  margin-top: 16px;
327	  display: grid;
328	  grid-template-columns: repeat(3, 1fr);
329	  gap: 8px;
330	}
331	.capture-stat {
332	  background: var(--bg-2);
333	  border: 1px solid var(--border);
334	  border-radius: var(--r-sm);
335	  padding: 8px 10px;
336	}
337	.capture-stat-label {
338	  font-size: 10px;
339	  text-transform: uppercase;
340	  letter-spacing: 0.06em;
341	  font-weight: 600;
342	  color: var(--text-4);
343	}
344	.capture-stat-value { font-size: 13px; margin-top: 2px; }
345
346	/* ========== Monitors ========== */
347	.monitors-grid {
348	  display: grid;
349	  gap: 10px;
350	  background: var(--bg-2);
351	  border-radius: var(--r-lg);
352	  padding: 10px;
353	}
354	.monitor-tile {
355	  position: relative;
356	  aspect-ratio: 16 / 9;
357	  background: #000;
358	  border-radius: var(--r-sm);
359	  overflow: hidden;
360	  border: 1px solid var(--border);
361	  display: flex; flex-direction: column;
362	  cursor: pointer;
363	}
364	.monitor-tile.audio { background: linear-gradient(135deg, hsl(180 30% 12%), hsl(200 25% 6%)); }
365	.monitor-tile-label {
366	  position: absolute;
367	  bottom: 0; left: 0; right: 0;
368	  padding: 8px 10px;
369	  background: linear-gradient(180deg, transparent, rgba(0,0,0,0.8));
370	  display: flex; align-items: center; gap: 6px;
371	}
372	.monitor-tile-label .name {
373	  color: white;
374	  font-size: 11.5px;
375	  font-weight: 500;
376	}
377	.monitor-tile-label .time {
378	  margin-left: auto;
379	  color: white;
380	  font-family: var(--font-mono);
381	  font-size: 10.5px;
382	}
383
384	/* ========== Jobs ========== */
385	.jobs-stats {
386	  display: grid;
387	  grid-template-columns: repeat(4, 1fr);
388	  gap: 12px;
389	}
390	.job-row {
391	  display: grid;
392	  grid-template-columns: 20px 110px 1fr 90px 200px 70px 80px 90px;
393	  align-items: center;
394	  gap: 12px;
395	  padding: 10px 16px;
396	  border-bottom: 1px solid var(--border);
397	  font-size: 12.5px;
398	}
399	.job-row:last-child { border-bottom: 0; }
400	.job-row.head {
401	  font-size: 10.5px;
402	  font-weight: 600;
403	  text-transform: uppercase;
404	  letter-spacing: 0.06em;
405	  color: var(--text-4);
406	}
407	.job-progress-wrap {
408	  display: flex; align-items: center; gap: 8px;
409	}
410	.job-progress-bar {
411	  flex: 1;
412	  height: 5px;
413	  background: var(--bg-3);
414	  border-radius: 99px;
415	  overflow: hidden;
416	  position: relative;
417	}
418	.job-progress-fill {
419	  height: 100%;
420	  background: linear-gradient(90deg, var(--accent), #7C9EFF);
421	  background-size: 200% 100%;
422	  animation: shimmer 2s linear infinite;
423	  transition: width 300ms;
424	}
425
426	/* ========== Editor ========== */
427	.editor-shell {
428	  display: flex; flex-direction: column;
429	  height: 100%;
430	  background: var(--bg-0);
431	}
432	.editor-topbar {
433	  height: 44px;
434	  border-bottom: 1px solid var(--border);
435	  padding: 0 14px;
436	  display: flex; align-items: center; gap: 10px;
437	}
438	.editor-body {
439	  flex: 1;
440	  display: grid;
441	  grid-template-columns: 240px 1fr 240px;
442	  min-height: 0;
443	}
444	.editor-bins, .editor-insp { background: var(--bg-1); border-right: 1px solid var(--border); overflow-y: auto; }
445	.editor-insp { border-right: 0; border-left: 1px solid var(--border); }
446	.editor-bin-item {
447	  display: flex; align-items: center; gap: 8px;
448	  padding: 6px;
449	  border-radius: var(--r-sm);
450	  cursor: pointer;
451	}
452	.editor-bin-item:hover { background: var(--hover); }
453	.editor-bin-thumb { width: 48px; aspect-ratio: 16/9; border-radius: 3px; overflow: hidden; background: var(--bg-2); flex-shrink: 0; }
454	.editor-viewer { background: #000; display: flex; flex-direction: column; }
455	.editor-canvas {
456	  flex: 1;
457	  position: relative;
458	  background: #050608;
459	  display: grid; place-items: center;
460	  overflow: hidden;
461	  max-height: 50vh;
462	}
463	.editor-canvas .thumb-svg { width: 100%; height: 100%; }
464	.editor-transport {
465	  height: 44px;
466	  display: flex; align-items: center; gap: 8px;
467	  padding: 0 14px;
468	  background: var(--bg-1);
469	  border-top: 1px solid var(--border);
470	}
471	.editor-timeline {
472	  background: var(--bg-0);
473	  border-top: 1px solid var(--border);
474	  height: 240px;
475	  display: flex; flex-direction: column;
476	  position: relative;
477	  overflow: hidden;
478	}
479	.editor-timeline-head {
480	  height: 32px;
481	  display: flex; align-items: center;
482	  padding: 0 12px;
483	  gap: 8px;
484	  border-bottom: 1px solid var(--border);
485	}
486	.timeline-ruler {
487	  height: 22px;
488	  display: flex;
489	  padding-left: 40px;
490	  border-bottom: 1px solid var(--border);
491	  background: var(--bg-1);
492	}
493	.ruler-tick {
494	  flex: 1;
495	  border-left: 1px solid var(--border);
496	  font-size: 9.5px;
497	  color: var(--text-4);
498	  padding: 4px 4px 0;
499	}
500	.timeline-track {
501	  display: grid;
502	  grid-template-columns: 40px 1fr;
503	  align-items: stretch;
504	  height: 42px;
505	  border-bottom: 1px solid var(--border);
506	}
507	.timeline-track-label {
508	  background: var(--bg-1);
509	  display: grid; place-items: center;
510	  font-size: 10.5px;
511	  font-family: var(--font-mono);
512	  font-weight: 600;
513	  color: var(--text-3);
514	  border-right: 1px solid var(--border);
515	}
516	.timeline-track-lane {
517	  position: relative;
518	  background:
519	    repeating-linear-gradient(90deg, transparent 0 49px, rgba(255,255,255,0.02) 49px 50px);
520	}
521	.clip {
522	  position: absolute;
523	  top: 4px; bottom: 4px;
524	  border-radius: 3px;
525	  overflow: hidden;
526	  display: flex; align-items: center;
527	  cursor: pointer;
528	  border: 1px solid rgba(0,0,0,0.3);
529	  box-shadow: inset 0 1px 0 rgba(255,255,255,0.15);
530	}
531	.clip-label {
532	  position: absolute;
533	  left: 6px; top: 3px;
534	  font-size: 10px;
535	  font-weight: 600;
536	  color: white;
537	  z-index: 2;
538	  text-shadow: 0 1px 1px rgba(0,0,0,0.4);
539	  overflow: hidden;
540	  text-overflow: ellipsis;
541	  white-space: nowrap;
542	}
543	.clip-film { display: flex; flex: 1; height: 100%; }
544	.clip-film-cell { flex: 1; overflow: hidden; }
545	.clip-film-cell .thumb-svg { width: 100%; height: 100%; }
546	.clip.audio { background: var(--success) !important; }
547	.clip-wave { width: 100%; height: 100%; padding: 4px; opacity: 0.6; }
548	.clip-wave .waveform { width: 100%; height: 100%; }
549	.timeline-playhead {
550	  position: absolute;
551	  top: 32px; bottom: 0;
552	  width: 1.5px;
553	  background: var(--live);
554	  pointer-events: none;
555	  z-index: 5;
556	}
557	.timeline-playhead::before {
558	  content: "";
559	  position: absolute;
560	  top: -4px; left: 50%;
561	  width: 10px; height: 10px;
562	  background: var(--live);
563	  transform: translateX(-50%) rotate(45deg);
564	}
565
566	/* ========== Admin tables ========== */
567	/* ── Row popover menu (Users, etc.) ────────────────────────────────── */
568	.row-menu {
569	  position: absolute;
570	  right: 0;
571	  top: calc(100% + 4px);
572	  z-index: 20;
573	  min-width: 180px;
574	  background: var(--bg-1);
575	  border: 1px solid var(--border-stronger);
576	  border-radius: 6px;
577	  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
578	  padding: 4px;
579	  display: flex;
580	  flex-direction: column;
581	}
582	.row-menu button {
583	  display: flex;
584	  align-items: center;
585	  gap: 8px;
586	  background: transparent;
587	  border: 0;
588	  color: var(--text-1);
589	  font-size: 12.5px;
590	  padding: 7px 10px;
591	  border-radius: 4px;
592	  cursor: pointer;
593	  text-align: left;
594	}
595	.row-menu button:hover { background: var(--bg-3); }
596	.row-menu button.danger:hover { background: var(--danger-soft); color: var(--danger); }
597
598	.user-row, .token-row, .container-row, .schedule-row {
599	  display: grid;
600	  align-items: center;
601	  gap: 12px;
602	  padding: 12px 16px;
603	  border-bottom: 1px solid var(--border);
604	  font-size: 12.5px;
605	}
606	.user-row { grid-template-columns: 1.5fr 100px 1.5fr 120px 40px; }
607	.token-row { grid-template-columns: 1.4fr 1.4fr 110px 110px 100px 40px; }
608	.container-row { grid-template-columns: 1.4fr 1.4fr 140px 140px 100px 1.4fr 110px; }
609	.schedule-row { grid-template-columns: 1.6fr 1.2fr 1.2fr 90px 110px 110px 150px; }
610	.user-row.head, .token-row.head, .container-row.head, .schedule-row.head {
611	  font-size: 10.5px;
612	  font-weight: 600;
613	  text-transform: uppercase;
614	  letter-spacing: 0.06em;
615	  color: var(--text-4);
616	}
617	.user-row:last-child, .token-row:last-child, .container-row:last-child, .schedule-row:last-child { border-bottom: 0; }
618	.token-row.revoked { opacity: 0.5; }
619
620	/* ========== Cluster ========== */
621	.cluster-canvas {
622	  background: var(--bg-1);
623	  border: 1px solid var(--border);
624	  border-radius: var(--r-lg);
625	  overflow: hidden;
626	}
627
628	/* ========== Settings ========== */
629	.settings-nav {
630	  display: flex; flex-direction: column; gap: 2px;
631	  position: sticky; top: 0;
632	}
633	.settings-nav-item {
634	  display: flex; align-items: center; gap: 8px;
635	  padding: 0 10px;
636	  height: 32px;
637	  border-radius: var(--r-sm);
638	  color: var(--text-2);
639	  font-size: 12.5px;
640	  cursor: pointer;
641	}
642	.settings-nav-item:hover { background: var(--hover); color: var(--text-1); }
643	.settings-nav-item.active { background: var(--accent-soft); color: var(--accent-text); }
644	.settings-card {
645	  background: var(--bg-1);
646	  border: 1px solid var(--border);
647	  border-radius: var(--r-lg);
648	  overflow: hidden;
649	}
650	.settings-card-head {
651	  padding: 16px;
652	  display: flex; align-items: flex-start; gap: 12px;
653	  border-bottom: 1px solid var(--border);
654	}
655	.settings-card-icon {
656	  width: 36px; height: 36px;
657	  border-radius: var(--r-sm);
658	  background: var(--accent-soft);
659	  color: var(--accent);
660	  display: grid; place-items: center;
661	  flex-shrink: 0;
662	}
663	.settings-card-body { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
664	.field { display: flex; flex-direction: column; gap: 4px; }
665	.field-input-wrap { display: flex; gap: 6px; align-items: center; }
666	.checkbox-row {
667	  display: flex; align-items: center; gap: 8px;
668	  font-size: 12.5px; color: var(--text-1);
669	  cursor: pointer;
670	}
671	.checkbox-row input { accent-color: var(--accent); }
672
673	/* ========== Tokens parody ========== */
674	.token-hero {
675	  display: grid;
676	  grid-template-columns: 2fr 1fr;
677	  gap: 14px;
678	  margin-bottom: 16px;
679	}
680	.token-burn-card, .token-actual-card {
681	  background: var(--bg-1);
682	  border: 1px solid var(--border);
683	  border-radius: var(--r-lg);
684	  padding: 18px 20px;
685	  position: relative;
686	  overflow: hidden;
687	}
688	.token-burn-card {
689	  background:
690	    radial-gradient(ellipse at top right, rgba(255,91,91,0.12), transparent 60%),
691	    var(--bg-1);
692	  border-color: rgba(255,91,91,0.18);
693	}
694	.token-actual-card {
695	  background:
696	    radial-gradient(ellipse at bottom left, rgba(45,212,168,0.10), transparent 60%),
697	    var(--bg-1);
698	  border-color: rgba(45,212,168,0.18);
699	}
700	.token-card-label {
701	  font-size: 10px;
702	  text-transform: uppercase;
703	  letter-spacing: 0.08em;
704	  font-weight: 700;
705	  color: var(--text-4);
706	  margin-bottom: 8px;
707	}
708	.token-counter {
709	  display: flex; align-items: baseline; gap: 12px;
710	}
711	.token-flame { font-size: 32px; line-height: 1; }
712	.token-big {
713	  font-size: 48px;
714	  font-weight: 700;
715	  letter-spacing: -0.03em;
716	  font-variant-numeric: tabular-nums;
717	  background: linear-gradient(180deg, #FF9D6B, #FF5B5B);
718	  -webkit-background-clip: text;
719	  background-clip: text;
720	  -webkit-text-fill-color: transparent;
721	  line-height: 1.05;
722	}
723	.token-rate {
724	  font-size: 12px;
725	  display: flex; align-items: baseline;
726	  font-family: var(--font-mono);
727	}
728	.token-actual-amount {
729	  display: flex; align-items: baseline; gap: 0;
730	  color: var(--success);
731	  margin-bottom: 8px;
732	}
733
734	.token-comparison {
735	  background: var(--bg-1);
736	  border: 1px solid var(--border);
737	  border-radius: var(--r-lg);
738	  padding: 0 0 12px;
739	  margin-bottom: 16px;
740	  overflow: hidden;
741	}
742	.token-compare-chart { padding: 0 16px; }
743	.token-compare-legend {
744	  display: flex; gap: 24px;
745	  padding: 8px 0 0;
746	  font-size: 12px;
747	  color: var(--text-2);
748	}
749	.token-compare-legend .dot {
750	  display: inline-block;
751	  width: 10px; height: 10px;
752	  border-radius: 50%;
753	  margin-right: 6px;
754	  vertical-align: middle;
755	}
756
757	.token-grid {
758	  display: grid;
759	  grid-template-columns: 1fr 1.4fr;
760	  gap: 14px;
761	}
762	.token-event {
763	  display: flex; align-items: center; gap: 12px;
764	  padding: 10px 14px;
765	  border-bottom: 1px solid var(--border);
766	}
767	.token-event:last-child { border-bottom: 0; }
768	.token-event.fresh { animation: tokenFresh 800ms ease; }
769	@keyframes tokenFresh {
770	  0% { background: rgba(255,91,91,0.15); transform: translateX(-4px); }
771	  100% { background: transparent; transform: none; }
772	}
773	.token-tiers {
774	  display: grid;
775	  grid-template-columns: repeat(3, 1fr);
776	  gap: 10px;
777	}
778	.token-tier {
779	  background: var(--bg-1);
780	  border: 1px solid var(--border);
781	  border-radius: var(--r-lg);
782	  padding: 16px 14px;
783	  display: flex; flex-direction: column;
784	  gap: 6px;
785	  position: relative;
786	}
787	.token-tier.popular {
788	  border-color: var(--accent);
789	  background: linear-gradient(180deg, var(--accent-soft) 0%, var(--bg-1) 40%);
790	  box-shadow: 0 0 24px rgba(91,124,250,0.10);
791	}
792	.token-tier-badge {
793	  position: absolute;
794	  top: -8px; right: 12px;
795	  background: var(--accent);
796	  color: white;
797	  font-size: 9.5px;
798	  font-weight: 700;
799	  letter-spacing: 0.06em;
800	  padding: 3px 8px;
801	  border-radius: 99px;
802	}
803	.token-tier-name { font-size: 13px; font-weight: 700; letter-spacing: -0.01em; }
804	.token-tier-desc { font-size: 11px; color: var(--text-3); min-height: 32px; line-height: 1.4; }
805	.token-tier-price { margin-top: 6px; display: flex; align-items: baseline; }
806	.token-tier-tokens { font-size: 11px; color: var(--text-3); padding-top: 4px; border-top: 1px solid var(--border); }
807
808	.token-footnote {
809	  margin-top: 20px;
810	  padding: 14px 16px;
811	  background: var(--warning-soft);
812	  border: 1px solid rgba(245,166,35,0.2);
813	  border-radius: var(--r-md);
814	  display: flex; gap: 12px;
815	  font-size: 12.5px;
816	  color: var(--text-2);
817	  line-height: 1.55;
818	}
819	.token-footnote svg { color: var(--warning); flex-shrink: 0; margin-top: 2px; }
820	.token-footnote strong { color: var(--warning); }
821
822	/* ========== Global Search ========== */
823	.search-wrap {
824	  position: relative;
825	  flex-shrink: 0;
826	}
827	.search-wrap .search {
828	  width: 280px;
829	}
830	.search-wrap .search.is-open {
831	  border-color: var(--accent);
832	  background: var(--bg-2);
833	}
834	.search-results {
835	  position: absolute;
836	  top: calc(100% + 6px);
837	  right: 0;
838	  width: 440px;
839	  max-width: 90vw;
840	  max-height: 460px;
841	  overflow-y: auto;
842	  background: var(--bg-1);
843	  border: 1px solid var(--border-stronger);
844	  border-radius: var(--r-md);
845	  box-shadow: var(--shadow-pop);
846	  padding: 4px;
847	  z-index: 60;
848	}
849	.search-empty {
850	  padding: 18px 12px;
851	  text-align: center;
852	  font-size: 12px;
853	  color: var(--text-3);
854	}
855	.search-result {
856	  display: grid;
857	  grid-template-columns: 24px 1fr auto;
858	  align-items: center;
859	  gap: 10px;
860	  padding: 8px 10px;
861	  border-radius: var(--r-sm);
862	  cursor: pointer;
863	  color: var(--text-1);
864	}
865	.search-result.active {
866	  background: var(--accent-soft);
867	}
868	.search-result-icon {
869	  width: 24px; height: 24px;
870	  border-radius: 5px;
871	  background: var(--bg-3);
872	  color: var(--text-2);
873	  display: grid; place-items: center;
874	  flex-shrink: 0;
875	}
876	.search-result.active .search-result-icon {
877	  background: var(--accent-soft-2);
878	  color: var(--accent-text);
879	}
880	.search-result-text {
881	  min-width: 0;
882	  display: flex;
883	  flex-direction: column;
884	  gap: 1px;
885	}
886	.search-result-label {
887	  font-size: 12.5px;
888	  font-weight: 500;
889	  overflow: hidden;
890	  text-overflow: ellipsis;
891	  white-space: nowrap;
892	}
893	.search-result-sub {
894	  font-size: 11px;
895	  color: var(--text-3);
896	  overflow: hidden;
897	  text-overflow: ellipsis;
898	  white-space: nowrap;
899	}
900	.search-result-kind {
901	  font-family: var(--font-mono);
902	  font-size: 9.5px;
903	  font-weight: 600;
904	  text-transform: uppercase;
905	  letter-spacing: 0.06em;
906	  color: var(--text-4);
907	  background: var(--bg-2);
908	  border: 1px solid var(--border);
909	  padding: 2px 6px;
910	  border-radius: 99px;
911	  flex-shrink: 0;
912	}
913	.search-result-kind.kind-asset { color: #B4C3FF; border-color: rgba(91,124,250,0.25); }
914	.search-result-kind.kind-project { color: #FFD89B; border-color: rgba(245,166,35,0.25); }
915	.search-result-kind.kind-recorder { color: #FFAFAF; border-color: rgba(255,91,91,0.25); }
916	.search-result-kind.kind-job { color: #9EE7D2; border-color: rgba(45,212,168,0.25); }
917	.search-result-kind.kind-user { color: #D5B8FF; border-color: rgba(181,124,250,0.25); }
918	.search-result-kind.kind-nav { color: var(--text-2); }
919
920	/* ========== Asset right-click menu ========== */
921	.ctx-menu {
922	  position: fixed;
923	  z-index: 80;
924	  min-width: 220px;
925	  max-width: 280px;
926	  background: var(--bg-1);
927	  border: 1px solid var(--border-stronger);
928	  border-radius: var(--r-md);
929	  box-shadow: var(--shadow-pop);
930	  padding: 4px;
931	  display: flex;
932	  flex-direction: column;
933	  font-size: 12.5px;
934	}
935	.ctx-menu .ctx-header {
936	  padding: 8px 10px 6px;
937	  font-size: 11px;
938	  font-weight: 600;
939	  color: var(--text-3);
940	  border-bottom: 1px solid var(--border);
941	  margin-bottom: 4px;
942	  overflow: hidden;
943	  text-overflow: ellipsis;
944	  white-space: nowrap;
945	}
946	.ctx-menu .ctx-divider {
947	  height: 1px;
948	  background: var(--border);
949	  margin: 4px 2px;
950	}
951	.ctx-menu .ctx-section-label {
952	  font-size: 9.5px;
953	  font-weight: 700;
954	  text-transform: uppercase;
955	  letter-spacing: 0.08em;
956	  color: var(--text-4);
957	  padding: 6px 10px 4px;
958	}
959	.ctx-menu .ctx-empty {
960	  padding: 6px 10px 10px;
961	  font-size: 11.5px;
962	  color: var(--text-3);
963	  font-style: italic;
964	}
965	.ctx-menu button {
966	  display: flex;
967	  align-items: center;
968	  gap: 8px;
969	  background: transparent;
970	  border: 0;
971	  color: var(--text-1);
972	  font-size: 12.5px;
973	  padding: 7px 10px;
974	  border-radius: 4px;
975	  cursor: pointer;
976	  text-align: left;
977	  width: 100%;
978	}
979	.ctx-menu button svg { color: var(--text-3); flex-shrink: 0; }
980	.ctx-menu button:hover:not(:disabled) { background: var(--bg-3); }
981	.ctx-menu button:hover:not(:disabled) svg { color: var(--text-1); }
982	.ctx-menu button:disabled {
983	  opacity: 0.5;
984	  cursor: default;
985	}
986	.ctx-menu button.danger { color: var(--danger); }
987	.ctx-menu button.danger svg { color: var(--danger); }
988	.ctx-menu button.danger:hover:not(:disabled) {
989	  background: var(--danger-soft);
990	}
991


Now let me search the specific screen components for potential issues:


1	// screens-library.jsx
2
3	function Library({ navigate, onOpenAsset, openProject }) {
4	  const { BINS, PROJECTS } = window.ZAMPP_DATA;
5	  const [view, setView] = React.useState('grid');
6	  const [filter, setFilter] = React.useState('all');
7	  const [search, setSearch] = React.useState(window._dfPendingSearch || '');
8	  React.useEffect(() => { delete window._dfPendingSearch; }, []);
9	  // Local state lets us re-render after delete / move-to-bin without forcing
10	  // a full app reload — keeps ZAMPP_DATA in sync as the cache of record.
11	  const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []);
12	  const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y }
13
14	  const refreshAssets = React.useCallback(() => {
15	    window.ZAMPP_API.fetch('/assets?limit=500')
16	      .then(r => {
17	        const list = Array.isArray(r) ? r : (r.assets || []);
18	        const proj = {};
19	        (PROJECTS || []).forEach(p => { proj[p.id] = p.name; });
20	        const normalized = list.map(a => window.normalizeAsset ? window.normalizeAsset(a, proj) : a);
21	        window.ZAMPP_DATA.ASSETS = normalized;
22	        setAllAssets(normalized);
23	      })
24	      .catch(() => {});
25	  }, [PROJECTS]);
26
27	  // Dismiss the context menu on any outside click (capture phase so clicking
28	  // a menu item still fires before the menu unmounts).
29	  React.useEffect(() => {
30	    if (!ctxMenu) return;
31	    const close = () => setCtxMenu(null);
32	    window.addEventListener('click', close);
33	    window.addEventListener('contextmenu', close);
34	    window.addEventListener('scroll', close, true);
35	    return () => {
36	      window.removeEventListener('click', close);
37	      window.removeEventListener('contextmenu', close);
38	      window.removeEventListener('scroll', close, true);
39	    };
40	  }, [ctxMenu]);
41
42	  const openCtx = (asset, e) => {
43	    e.preventDefault();
44	    e.stopPropagation();
45	    setCtxMenu({ asset, x: e.clientX, y: e.clientY });
46	  };
47
48	  let assets = openProject
49	    ? allAssets.filter(function(a) { return a.project_id === openProject.id; })
50	    : allAssets;
51	  const ALL_ASSETS = allAssets;
52	  if (filter !== 'all') assets = assets.filter(function(a) { return a.status === filter; });
53	  if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); });
54
55	  const displayTitle = openProject ? openProject.name : 'All Assets';
56	  const errorCount = ALL_ASSETS.filter(function(a) { return a.status === 'error'; }).length;
57	  const recentCount = ALL_ASSETS.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }).length;
58
59	  return (
60

61
62

63
Projects

64

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

70	            {PROJECTS.slice(0, 8).map(function(p) {
71	              return (
72	                <div key={p.id} className={`rail-item ${openProject && openProject.id === p.id ? 'active' : ''}`} style={{ cursor: 'pointer' }}
73	                  onClick={function() { navigate('projects'); }}>
74
75	                  {p.name}
76	                  {p.assets}
77

78	              );
79	            })}
80

81

82	        {BINS.length > 0 && (
83

84
Bins

85

86	              {BINS.map(function(b) {
87	                return (
88

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

93	                );
94	              })}
95

96

97	        )}
98

99
Smart filters

100

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

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

104

105

106
107
108

109

110
{displayTitle}

111	          · {assets.length} assets
112

113

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

117

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

126

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

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

132
133	        {assets.length === 0 ? (
134
No assets match this filter.

135	        ) : view === 'grid' ? (
136

137	            {assets.map(function(a) {
138	              return <AssetCard key={a.id} asset={a}
139	                       onOpen={function() { onOpenAsset(a); }}
140	                       onContextMenu={function(e) { openCtx(a, e); }} />;
141	            })}
142

143	        ) : (
144

145

146

Name

Duration

Resolution

Codec

Size

Updated

147

148	            {assets.map(function(a) {
149	              return (
150

151
<AssetThumb asset={a} />

152

153
{a.name}

154

155	                      <StatusDot status={a.status} />
156	                      {a.status}
157

158

159
{a.duration}

160
{a.res}

161
{a.codec || '—'}

162
{a.size}

163
{a.updated}

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

166	              );
167	            })}
168

169	        )}
170

171	      {ctxMenu && (
172	        <AssetContextMenu
173	          asset={ctxMenu.asset}
174	          x={ctxMenu.x}
175	          y={ctxMenu.y}
176	          bins={BINS}
177	          onClose={function() { setCtxMenu(null); }}
178	          onChanged={refreshAssets}
179	          onOpen={function() { onOpenAsset(ctxMenu.asset); }}
180	        />
181	      )}
182

183	  );
184	}
185
186	function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen }) {
187	  const ref = React.useRef(null);
188	  // Pin the menu inside the viewport even if the user right-clicked near
189	  // the bottom-right edge of the grid.
190	  const [pos, setPos] = React.useState({ left: x, top: y });
191	  React.useLayoutEffect(() => {
192	    if (!ref.current) return;
193	    const r = ref.current.getBoundingClientRect();
194	    const margin = 8;
195	    let nx = x, ny = y;
196	    if (x + r.width  + margin > window.innerWidth)  nx = window.innerWidth  - r.width  - margin;
197	    if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin;
198	    setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) });
199	  }, [x, y]);
200
201	  const rename = function() {
202	    onClose();
203	    const next = prompt('Rename asset', asset.display_name || asset.name || '');
204	    if (next == null) return;
205	    const trimmed = next.trim();
206	    if (!trimmed || trimmed === (asset.display_name || asset.name)) return;
207	    window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ display_name: trimmed }) })
208	      .then(onChanged)
209	      .catch(function(e) { alert('Rename failed: ' + e.message); });
210	  };
211
212	  const moveToBin = function(binId) {
213	    onClose();
214	    window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) })
215	      .then(onChanged)
216	      .catch(function(e) { alert('Move failed: ' + e.message); });
217	  };
218
219	  const copyId = function() {
220	    onClose();
221	    if (navigator.clipboard) navigator.clipboard.writeText(asset.id).catch(function() {});
222	  };
223
224	  const remove = function() {
225	    onClose();
226	    if (!confirm('Delete "' + (asset.display_name || asset.name) + '" permanently?\nThis removes the database row and the S3 objects.\nThis cannot be undone.')) return;
227	    window.ZAMPP_API.fetch('/assets/' + asset.id + '?hard=true', { method: 'DELETE' })
228	      .then(onChanged)
229	      .catch(function(e) { alert('Delete failed: ' + e.message); });
230	  };
231
232	  return (
233

236
{asset.display_name || asset.name}

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

240	      {(bins && bins.length > 0) ? (
241	        <>
242
Move to bin

243	          {bins
244	            .filter(function(b) { return !asset.project_id || b.project_id === asset.project_id; })
245	            .slice(0, 10)
246	            .map(function(b) {
247	              const isCurrent = asset.bin_id === b.id;
248	              return (
249	                <button key={b.id} onClick={function() { moveToBin(b.id); }} disabled={isCurrent}
250	                  title={b.project_name ? 'in ' + b.project_name : ''}>
251	                  <Icon name="folder" size={11} />{b.name}{isCurrent && current}
252	                </button>
253	              );
254	            })}
255	          {asset.bin_id && (
256	            <button onClick={function() { moveToBin(null); }}>
257	              <Icon name="x" size={11} />Remove from bin
258	            </button>
259	          )}
260	        </>
261	      ) : (
262
No bins — create one inside a project

263	      )}
264

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

268	  );
269	}
270
271	function AssetCard({ asset, onOpen, onContextMenu }) {
272	  const [hoverStream, setHoverStream] = React.useState(null);
273	  const [hovered, setHovered] = React.useState(false);
274	  const timerRef = React.useRef(null);
275	  const hlsRef = React.useRef(null);
276	  const videoRef = React.useRef(null);
277
278	  const handleMouseEnter = function() {
279	    timerRef.current = setTimeout(function() {
280	      setHovered(true);
281	      if (!hoverStream) {
282	        window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream')
283	          .then(function(r) { if (r && r.url) setHoverStream(r); })
284	          .catch(function() {});
285	      }
286	    }, 350);
287	  };
288
289	  const handleMouseLeave = function() {
290	    clearTimeout(timerRef.current);
291	    setHovered(false);
292	  };
293
294	  // HLS wiring
295	  React.useEffect(function() {
296	    if (!hovered || !hoverStream || hoverStream.type !== 'hls' || !videoRef.current) return;
297	    if (!window.Hls) return;
298	    hlsRef.current = new window.Hls({ maxBufferLength: 10 });
299	    hlsRef.current.loadSource(hoverStream.url);
300	    hlsRef.current.attachMedia(videoRef.current);
301	    return function() {
302	      if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
303	    };
304	  }, [hovered, hoverStream]);
305
306	  const showVideo = hovered && hoverStream;
307
308	  return (
309

310

311	        <AssetThumb asset={asset} />
312	        {showVideo && (
313	          <video
314	            key={hoverStream.url}
315	            ref={videoRef}
316	            src={hoverStream.type !== 'hls' ? hoverStream.url : undefined}
317	            autoPlay
318	            muted
319	            loop
320	            playsInline
321	            style={{
322	              position: 'absolute',
323	              inset: 0,
324	              width: '100%',
325	              height: '100%',
326	              objectFit: 'cover',
327	              borderRadius: 6,
328	            }}
329	          />
330	        )}
331

332

333	        {asset.status === 'live' && LIVE}
334	        {asset.status === 'processing' && Processing}
335	        {asset.status === 'error' && Error}
336

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

339
{asset.name}

340

341	          {asset.res}
342	          ·
343	          {asset.size}
344

345

346

347	  );
348	}
349
350	function binIcon(name) {
351	  return { grid: 'library', live: 'record', film: 'film', proxy: 'proxy', audio: 'audio', package: 'package' }[name] || 'folder';
352	}
353
354	window.Library = Library;
355	window.AssetCard = AssetCard;
356


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

178

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

181

182	            {asset.name}
183	            <StatusDot status={asset.status} />
184

185

186	            {asset.project}·updated {asset.updated}
187

188

189

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

194	          <button ref={moreBtnRef} className="icon-btn" onClick={function(e) { e.stopPropagation(); setMenuOpen(function(v) { return !v; }); }}>
195	            <Icon name="more" />
196	          </button>
197	          {menuOpen && (
198

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

202	          )}
203

204

205
206

207

208

209

210	              {streamUrl ? (
211	                <video
212	                  key={streamUrl}
213	                  ref={videoRef}
214	                  src={streamType !== 'hls' ? streamUrl : undefined}
215	                  style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block', background: '#000' }}
216	                  onTimeUpdate={function() { if (videoRef.current) setCurrentMs(videoRef.current.currentTime * 1000); }}
217	                  onLoadedMetadata={function() { if (videoRef.current) setVideoDuration(videoRef.current.duration * 1000); }}
218	                  onPlay={function() { setPlaying(true); }}
219	                  onPause={function() { setPlaying(false); }}
220	                  onEnded={function() { setPlaying(false); }}
221	                />
222	              ) : (
223	                <React.Fragment>
224	                  <FauxFrame seed={asset.seed || 1} />
225

226

227

228	                      {streamLoading ? (
229
Loading…

230	                      ) : (
231	                        <React.Fragment>
232
{statusMessage}

233	                          <StatusDot status={asset.status} />
234	                          {asset.status === 'error' && (
235	                            <button
236	                              className="btn ghost sm"
237	                              style={{ marginTop: 12, display: 'block', margin: '12px auto 0' }}
238	                              onClick={retryProcessing}
239	                              disabled={retrying}
240	                            >
241	                              {retrying ? 'Retrying…' : 'Retry processing'}
242	                            </button>
243	                          )}
244	                        </React.Fragment>
245	                      )}
246

247

248	                  {!streamLoading && !playing && totalMs > 0 && (
249	                    <button className="player-play-overlay" onClick={togglePlay}>
250	                      <Icon name="play" size={28} />
251	                    </button>
252	                  )}
253	                </React.Fragment>
254	              )}
255
256

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

262
{c.avatar}

263
{c.text}

264

265	                    );
266	                  })}
267

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

270	                  LIVE · REC
271

272	              )}
273	              {totalMs > 0 && (
274

275	                  {msToTimecode(currentMs)}
276	                   / {asset.duration}
277

278	              )}
279

280
281	            {totalMs > 0 && (
282

283	                <button className="icon-btn" onClick={togglePlay}>
284	                  <Icon name={playing ? "pause" : "play"} size={14} />
285	                </button>
286	                {msToTimecode(currentMs)}
287	                <PlaybackBar current={currentMs} total={totalMs} onSeek={seek} comments={visibleComments} />
288	                {asset.duration}
289	                {streamUrl && (
290	                  <React.Fragment>
291

292	                    <button
293	                      className="icon-btn"
294	                      title="Toggle mute"
295	                      onClick={function() { if (videoRef.current) { videoRef.current.muted = !videoRef.current.muted; } }}>
296	                      <Icon name="audio" size={14} />
297	                    </button>
298	                    <button
299	                      className="icon-btn"
300	                      title="Toggle fullscreen"
301	                      onClick={function() { if (videoRef.current && videoRef.current.requestFullscreen) videoRef.current.requestFullscreen().catch(function() {}); }}>
302	                      <Icon name="layout" size={14} />
303	                    </button>
304	                  </React.Fragment>
305	                )}
306

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

313
314

315	            <button className={tab === "comments" ? "active" : ""} onClick={function() { setTab("comments"); }}>
316	              Comments {comments.length}
317	            </button>
318	            <button className={tab === "versions" ? "active" : ""} onClick={function() { setTab("versions"); }}>
319	              Versions
320	            </button>
321	            <button className={tab === "metadata" ? "active" : ""} onClick={function() { setTab("metadata"); }}>
322	              Metadata
323	            </button>
324	            <button className={tab === "audio" ? "active" : ""} onClick={function() { setTab("audio"); }}>
325	              Audio
326	            </button>
327

328	            {tab === "comments" && (
329
330	                <input type="checkbox" checked={showResolved} onChange={function(e) { setShowResolved(e.target.checked); }} /> Show resolved
331
332	            )}
333

334
335

336	            {tab === "comments" && (
337	              <CommentsList
338	                comments={visibleComments}
339	                onSeek={function(c) { seek(parseDuration(c.time)); }}
340	                onResolve={function(id) {
341	                  setComments(function(cs) { return cs.map(function(c) { return c.id === id ? Object.assign({}, c, { resolved: !c.resolved }) : c; }); });
342	                }}
343	              />
344	            )}
345	            {tab === "versions" && <VersionsTab />}
346	            {tab === "metadata" && <MetadataTab asset={asset} />}
347	            {tab === "audio" && <AudioTab asset={asset} />}
348

349

350
351

352	          <CommentComposer
353	            asset={asset}
354	            currentMs={currentMs}
355	            value={newComment}
356	            onChange={setNewComment}
357	            onSubmit={addComment}
358	          />
359

360

361

362	  );
363	}
364
365	function PlaybackBar({ current, total, onSeek, comments }) {
366	  const ref = React.useRef(null);
367	  const handle = function(e) {
368	    const r = ref.current.getBoundingClientRect();
369	    const p = (e.clientX - r.left) / r.width;
370	    onSeek(Math.max(0, Math.min(1, p)) * total);
371	  };
372	  const pct = total > 0 ? (current / total) * 100 : 0;
373	  return (
374

375

376

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

388	            {c.avatar}
389

390	        );
391	      })}
392

393	  );
394	}
395
396	function FilmStrip({ seed, current, total, onSeek, comments }) {
397	  const ref = React.useRef(null);
398	  const frames = 28;
399	  const handle = function(e) {
400	    if (!ref.current || total <= 0) return;
401	    const r = ref.current.getBoundingClientRect();
402	    onSeek(((e.clientX - r.left) / r.width) * total);
403	  };
404	  const pct = total > 0 ? (current / total) * 100 : 0;
405	  return (
406

407

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

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

413	          );
414	        })}
415

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

422	              {c.avatar}
423

424	          );
425	        })}
426

427

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

432

433	  );
434	}
435
436	function CommentsList({ comments, onSeek, onResolve }) {
437	  if (comments.length === 0) {
438	    return
No comments yet.
;
439	  }
440	  return (
441

442	      {comments.map(function(c) {
443	        return (
444

445
{c.avatar}

446

447

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

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

456
{c.text}

457	              {c.resolved &&
✓ Resolved
}
458

459

460	        );
461	      })}
462

463	  );
464	}
465
466	function CommentComposer({ asset, currentMs, value, onChange, onSubmit }) {
467	  return (
468

469

470	        Reviewers
471

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

477

478

479	        @ {msToTimecode(currentMs)}
480

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

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

493

494	  );
495	}
496
497	function VersionsTab() {
498	  return (
499

500	      Version history not yet available.
501

502	  );
503	}
504
505	function MetadataTab({ asset }) {
506	  const rows = [
507	    { k: "Filename",   v: asset.name },
508	    { k: "Duration",   v: asset.duration || '—' },
509	    { k: "Resolution", v: asset.res || '—' },
510	    { k: "Codec",      v: asset.codec || '—' },
511	    { k: "File size",  v: asset.size || '—' },
512	    { k: "Status",     v: asset.status || '—' },
513	    { k: "Updated",    v: asset.updated || '—' },
514	    { k: "Project",    v: asset.project || '—' },
515	  ];
516	  return (
517

518	      {rows.map(function(r) {
519	        return (
520

521
{r.k}

522
{r.v}

523

524	        );
525	      })}
526

527	  );
528	}
529
530	function AudioTab({ asset }) {
531	  return (
532

533

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

537

538	  );
539	}
540
541	function parseDuration(d) {
542	  if (!d || d === '—' || typeof d !== 'string') return 0;
543	  const parts = d.split(':');
544	  if (parts.length < 2) return 0;
545	  const nums = parts.map(Number);
546	  if (nums.some(isNaN)) return 0;
547	  if (nums.length === 3) return ((nums[0] * 60 + nums[1]) * 60 + nums[2]) * 1000;
548	  return (nums[0] * 60 + nums[1]) * 1000;
549	}
550
551	function msToTimecode(ms) {
552	  const total = Math.floor((ms || 0) / 1000);
553	  const h = Math.floor(total / 3600);
554	  const m = Math.floor((total % 3600) / 60);
555	  const s = total % 60;
556	  return String(h).padStart(2, "0") + ':' + String(m).padStart(2, "0") + ':' + String(s).padStart(2, "0");
557	}
558
559	function avatarColor(initials) {
560	  let h = 0;
561	  for (const c of (initials || '?')) h = (h * 31 + c.charCodeAt(0)) & 0xff;
562	  return 'linear-gradient(135deg, hsl(' + (h % 360) + ' 60% 50%), hsl(' + ((h + 60) % 360) + ' 60% 45%))';
563	}
564
565	Object.assign(window, { AssetDetail, msToTimecode, parseDuration, avatarColor });
566


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