diff --git a/DESIGN.md b/DESIGN.md index 95aad4d..be9280f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -90,6 +90,16 @@ Never use `gradient text` (impeccable absolute ban). Emphasis via weight and siz - Panels (`.panel`): `--bg-1` + 1px `--border` + `--r-lg`. Use for grouped lists, not for every section. - Do NOT nest panels. +### Page header + +Standard screens use `.page > .page-header > h1`. Three screens are documented exceptions because they need full-bleed layouts and their own top-chrome: + +- **Home** uses `.launcher` (lobby pattern: hero logo + tile grid + status pip). +- **Library** uses `.library-layout` (dual-pane rail + main). The h1 sits inside `.library-toolbar` as `.toolbar-title`. +- **Editor** uses `.editor-shell` (NLE with timeline + monitors). The beta banner doubles as its top chrome. + +All other screens should render `

` for consistent IA and screen-reader hierarchy. + ## Shadow Two tokens, used sparingly: diff --git a/services/web-ui/public/app.jsx b/services/web-ui/public/app.jsx index 93c7040..f713b8b 100644 --- a/services/web-ui/public/app.jsx +++ b/services/web-ui/public/app.jsx @@ -1,4 +1,4 @@ -// app.jsx — main shell +// app.jsx - main shell const ACCENT = '#5B7CFA'; @@ -40,6 +40,14 @@ function App() { }, []); const navigate = (id) => { setOpenAsset(null); setRoute(id); }; + + // Window-level nav event so deeply nested components (like the Tokens + // "see the parody" link) can route without prop drilling. + React.useEffect(() => { + const handler = (e) => { if (e && e.detail) navigate(e.detail); }; + window.addEventListener('df:nav', handler); + return () => window.removeEventListener('df:nav', handler); + }, []); const openProjectFromAnywhere = (p) => { setOpenAsset(null); setOpenProject(p); setRoute('library'); }; const crumbs = React.useMemo(() => { @@ -108,6 +116,7 @@ function App() { case 'editor': content = ; break; case 'users': content = ; break; case 'tokens': content = ; break; + case 'tokens-parody': content = ; break; case 'containers':content = ; break; case 'cluster': content = ; break; case 'settings': content = ; break; @@ -115,7 +124,7 @@ function App() { } } - // Home (launcher) suppresses the topbar — it's a full-bleed landing page. + // Home (launcher) suppresses the topbar - it's a full-bleed landing page. const hideTopbar = !openAsset && route === 'home'; return ( diff --git a/services/web-ui/public/auth-gate.jsx b/services/web-ui/public/auth-gate.jsx index 2e9a0ca..88d884b 100644 --- a/services/web-ui/public/auth-gate.jsx +++ b/services/web-ui/public/auth-gate.jsx @@ -1,10 +1,10 @@ -// auth-gate.jsx — owns the "logged in or not" state. +// auth-gate.jsx - owns the "logged in or not" state. // // The SPA boots into , which calls GET /auth/me. On 401 it then // calls GET /auth/setup-required and renders or // (defined in screens-auth.jsx, Task 16). On 200 it renders the real . // -// This component is the SINGLE source of truth for the auth check — no other +// This component is the SINGLE source of truth for the auth check - no other // component should redirect to a login page or wipe data on 401. Other code // surfaces auth failure by calling window.AuthGate.bounce(), which re-mounts // the gate so the next /auth/me request decides what to do. diff --git a/services/web-ui/public/data.jsx b/services/web-ui/public/data.jsx index 883cea4..5102be7 100644 --- a/services/web-ui/public/data.jsx +++ b/services/web-ui/public/data.jsx @@ -1,4 +1,4 @@ -// data.jsx — API client; populates window.ZAMPP_DATA from real endpoints +// data.jsx - API client; populates window.ZAMPP_DATA from real endpoints const API = '/api/v1'; window.ZAMPP_API_PREFIX = API; // single source of truth (#115) @@ -22,14 +22,14 @@ window.ZAMPP_API_PREFIX = API; // single source of truth (#115) })(); // Premiere panel releases embedded in this deployment. Bumping the version -// here is the single source of truth — both the Editor download buttons and +// here is the single source of truth - both the Editor download buttons and // the Settings → Capture SDKs page read from this list (#125). window.PREMIERE_RELEASES = [ { version: '1.2.0', zxp: '/downloads/dragonflight-premiere-panel-1.2.0.zxp', installer: null, - notes: 'Latest — design system refresh, aligned panel UI with web-ui tokens', + notes: 'Latest: design system refresh, aligned panel UI with web-ui tokens', latest: true, }, { @@ -86,7 +86,7 @@ async function apiFetch(path, opts = {}) { } function fmtDuration(ms) { - if (!ms) return '—'; + if (!ms) return '·'; const s = Math.round(ms / 1000); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); @@ -96,7 +96,7 @@ function fmtDuration(ms) { } function fmtSize(bytes) { - if (!bytes) return '—'; + if (!bytes) return '·'; if (bytes >= 1e12) return (bytes / 1e12).toFixed(1) + ' TB'; if (bytes >= 1e9) return (bytes / 1e9).toFixed(1) + ' GB'; if (bytes >= 1e6) return Math.round(bytes / 1e6) + ' MB'; @@ -104,7 +104,7 @@ function fmtSize(bytes) { } function fmtRelative(iso) { - if (!iso) return '—'; + if (!iso) return '·'; const diff = (Date.now() - new Date(iso)) / 1000; if (diff < 60) return 'just now'; if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; @@ -122,7 +122,7 @@ function normalizeAsset(a, projectMap) { type: a.media_type || 'video', duration: fmtDuration(a.duration_ms), size: fmtSize(a.file_size), - res: a.resolution || '—', + res: a.resolution || '·', updated: fmtRelative(a.updated_at), project: (projectMap && projectMap[a.project_id]) || '', comments: 0, @@ -133,7 +133,7 @@ function normalizeAsset(a, projectMap) { } function normalizeRecorder(r) { - let elapsed = '—'; + let elapsed = '·'; if (r.status === 'recording' && r.started_at) { const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000); elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' + @@ -143,13 +143,13 @@ function normalizeRecorder(r) { const cfg = r.source_config || {}; return { ...r, - source: r.source_type || '—', - url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '—', - codec: r.recording_codec || '—', - res: r.recording_resolution || '—', + source: r.source_type || '·', + url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '·', + codec: r.recording_codec || '·', + res: r.recording_resolution || '·', node: r.node_id ? r.node_id.slice(0, 8) : 'primary', elapsed, - bitrate: '—', + bitrate: '·', health: 100, audio: false, }; @@ -163,9 +163,9 @@ function normalizeJob(j) { ...j, status: statusMap[j.status] || j.status, kind: kindMap[j.type] || j.type || 'Job', - asset: j.asset_name || meta.filename || '—', - eta: '—', - node: meta.node || '—', + asset: j.asset_name || meta.filename || '·', + eta: '·', + node: meta.node || '·', priority: meta.priority || 'normal', error: j.error || null, progress: j.progress || 0, diff --git a/services/web-ui/public/modal-new-recorder.jsx b/services/web-ui/public/modal-new-recorder.jsx index f10292a..ba4041a 100644 --- a/services/web-ui/public/modal-new-recorder.jsx +++ b/services/web-ui/public/modal-new-recorder.jsx @@ -1,21 +1,21 @@ -// modal-new-recorder.jsx — New Recorder dialog (SRT / RTMP / SDI / Deltacast) +// modal-new-recorder.jsx - New Recorder dialog (SRT / RTMP / SDI / Deltacast) /** - * DevicePortPicker — groups a flat per-port API response by node_id and + * DevicePortPicker - groups a flat per-port API response by node_id and * renders one button per actual port. Replaces the old code that iterated * over entries and synthesised port counts, which caused duplicate groups. * * props: - * ports — flat array from /cluster/devices/blackmagic or /deltacast + * ports - flat array from /cluster/devices/blackmagic or /deltacast * each entry: { node_id, hostname, model, index, device, present? } - * selectedIdx — currently selected device_index - * selectedNode — currently selected node_id + * selectedIdx - currently selected device_index + * selectedNode - currently selected node_id * onSelect(idx, nodeId) - * portLabel — e.g. "SDI" or "Port" - * showTestBadge — show TEST CARD badge when present===false + * portLabel - e.g. "SDI" or "Port" + * showTestBadge - show TEST CARD badge when present===false */ function DevicePortPicker({ ports, selectedIdx, selectedNode, onSelect, portLabel = 'Port', showTestBadge = false }) { - // Group by node_id (stable — one group per physical node) + // Group by node_id (stable - one group per physical node) const groups = React.useMemo(() => { const map = new Map(); for (const p of ports) { @@ -32,7 +32,7 @@ function DevicePortPicker({ ports, selectedIdx, selectedNode, onSelect, portLabe
{groups.map(group => (
1 ? 12 : 4 }}> - {/* Node header — only show when multiple groups, or always for clarity */} + {/* Node header: only show when multiple groups, or always for clarity */}
{group.model ? group.model.toUpperCase() + ' · ' : ''}{group.hostname}
@@ -64,7 +64,7 @@ function DevicePortPicker({ ports, selectedIdx, selectedNode, onSelect, portLabe } /** - * ManualDevicePicker — fallback when no devices detected. Lets the operator + * ManualDevicePicker - fallback when no devices detected. Lets the operator * pick node + index from dropdowns. */ function ManualDevicePicker({ nodes, nodeId, deviceIdx, portLabel, portCount, onNodeChange, onIdxChange, emptyNote }) { @@ -250,7 +250,7 @@ function NewRecorderModal({ open, onClose }) {
{[ - { id: 'SRT', label: 'SRT', desc: 'Secure Reliable Transport — pull caller', icon: 'signal' }, + { id: 'SRT', label: 'SRT', desc: 'Secure Reliable Transport · pull caller', icon: 'signal' }, { id: 'RTMP', label: 'RTMP', desc: 'Real-Time Messaging Protocol', icon: 'globe' }, { id: 'SDI', label: 'SDI', desc: 'Blackmagic DeckLink hardware', icon: 'video' }, { id: 'DELTACAST', label: 'Deltacast', desc: 'Deltacast VideoMaster SDI', icon: 'video' }, @@ -447,7 +447,7 @@ function NewRecorderModal({ open, onClose }) { {tag} ))}
-
Fixed proxy profile — not configurable.
+
Fixed proxy profile. Not configurable.
)} diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index 3d6bddd..055dafe 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -1,4 +1,4 @@ -// screens-admin.jsx — Users, Tokens, Containers, Cluster (graph), Settings +// screens-admin.jsx - Users, Tokens, Containers, Cluster (graph), Settings function _normalizeNode(n, x, y) { const cap = n.capabilities || {}; @@ -29,9 +29,9 @@ function _normalizeNode(n, x, y) { dbId: n.id, role: n.role || 'worker', status: n.status || (n.online ? 'online' : 'offline'), - ip: n.ip_address || n.ip || '—', - version: n.version || '—', - uptime: n.uptime || '—', + ip: n.ip_address || n.ip || '·', + version: n.version || '·', + uptime: n.uptime || '·', cpu: parseFloat(n.cpu_usage || n.cpu || n.cpu_percent || 0), mem: Math.round(memUsedMb / 1024 * 10) / 10, memTotal: Math.round(memTotalMb / 1024 * 10) / 10, @@ -230,7 +230,7 @@ function Users() { {u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'}
- {u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '—'} + {u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '·'}
-
HOURLY BURN — DRAGONFLIGHT vs. THE OTHER GUYS
+
HOURLY BURN: DRAGONFLIGHT vs. THE OTHER GUYS
Disclaimer: No actual tokens were billed in the making of this page. Wild Dragon's broadcast platform is self-hosted infrastructure. Any resemblance to per-API-call broadcast token economies, living or dead, is satirical - and protected as commentary. If you came here looking for actual API tokens, that page no longer exists — service + and protected as commentary. If you came here looking for actual API tokens, that page no longer exists: service credentials are managed through the cluster's own JWT issuer.
@@ -798,7 +819,7 @@ function Containers() { const [containers, setContainers] = React.useState(null); const [restartFlashState, setRestartFlashState] = React.useState(null); const [logsModalState, setLogsModalState] = React.useState(null); - // #111 — guard restart-flash timers against unmount. + // #111 - guard restart-flash timers against unmount. const mountedRef = React.useRef(true); const flashTimerRef = React.useRef(null); React.useEffect(() => () => { @@ -960,7 +981,7 @@ function Containers() { } // ──────────────────────────────────────────────────────────────────────────── -// BmdCardPanel — capture-card section inside the Cluster node detail panel. +// BmdCardPanel - capture-card section inside the Cluster node detail panel. // Shows port chips with live video-presence dots AND the BMD SVG card diagram. // ──────────────────────────────────────────────────────────────────────────── function BmdCardPanel({ sel, portSignals }) { @@ -995,7 +1016,7 @@ function BmdCardPanel({ sel, portSignals }) {
- Capture cards {sel.bmdCount > 0 ? `(${sel.bmdCount} port${sel.bmdCount !== 1 ? 's' : ''})` : '— none reported'} + Capture cards {sel.bmdCount > 0 ? `(${sel.bmdCount} port${sel.bmdCount !== 1 ? 's' : ''})` : '(none reported)'}
{sel.bmdPorts.length === 0 && (
No DeckLink cards detected on this node
@@ -1021,7 +1042,7 @@ function BmdCardPanel({ sel, portSignals }) { const { label, color } = _signalChip(sig); const isReceiving = sig === 'receiving'; return ( -
{ - if (!window.confirm('Remove node ' + node.id + ' from the cluster?\nThis does not stop the machine — it only removes it from cluster membership.')) return; + if (!window.confirm('Remove node ' + node.id + ' from the cluster?\nThis does not stop the machine: it only removes it from cluster membership.')) return; window.ZAMPP_API.fetch('/cluster/nodes/' + encodeURIComponent(node.dbId || node.id), { method: 'DELETE' }) .then(() => refresh()) .catch(e => setAdviceModal({ title: 'Remove failed', lines: [e.message] })); @@ -1323,7 +1344,7 @@ function Cluster() {
- GPUs {sel.gpuCount > 0 ? `(${sel.gpuCount})` : '— none reported'} + GPUs {sel.gpuCount > 0 ? `(${sel.gpuCount})` : '(none reported)'}
{sel.gpus.length === 0 && (
No GPUs detected on this node
@@ -1504,7 +1525,7 @@ function ApiTokensSection() { {justCreated && (
- Save this token now — it will not be shown again + Save this token now: it will not be shown again
{justCreated.token}
@@ -1580,7 +1601,7 @@ function Settings() { } // ──────────────────────────────────────────────────────────────────────────── -// Storage — unified view: live mount/bucket health on top, then the two +// Storage - unified view: live mount/bucket health on top, then the two // existing editors (S3 bucket + growing-files SMB landing zone) stacked. // ──────────────────────────────────────────────────────────────────────────── @@ -1595,7 +1616,7 @@ function StorageSection() { } function formatBytes(n) { - if (n == null || isNaN(n)) return '—'; + if (n == null || isNaN(n)) return '·'; const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; let v = n, i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } @@ -1628,7 +1649,7 @@ function MountHealthStrip() { React.useEffect(() => { load(); // Light auto-refresh so free-space + reachability stay current while the - // operator is on the page. 15s is plenty — these are diagnostic, not real-time. + // operator is on the page. 15s is plenty - these are diagnostic, not real-time. const t = setInterval(load, 15_000); return () => clearInterval(t); }, [load]); @@ -1678,9 +1699,9 @@ function MountHealthStrip() { )}
- Container{g.container_path || '—'} - Host{g.host_path || '—'} - SMB{g.smb_url || '—'} + Container{g.container_path || '·'} + Host{g.host_path || '·'} + SMB{g.smb_url || '·'} Promote idle{g.promote_after_seconds}s {g.error && <>Error{g.error}}
@@ -1700,8 +1721,8 @@ function MountHealthStrip() {
Endpoint{s.endpoint || '(AWS default)'} - Bucket{s.bucket || '—'} - Region{s.region || '—'} + Bucket{s.bucket || '·'} + Region{s.region || '·'} {s.error && <>Error{s.error}}
@@ -1767,7 +1788,7 @@ function S3SettingsCard() { setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" />
setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" autoComplete="off" /> - setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} autoComplete="new-password" /> + setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved: type to replace)' : 'Secret key'} autoComplete="new-password" />
@@ -1791,7 +1812,7 @@ function GpuSettingsCard() { const save = () => { setSaving(true); setMsg(null); window.ZAMPP_API.fetch('/settings/transcoding', { method: 'PUT', body: JSON.stringify(cfg) }) - .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved — new settings apply to the next proxy job.' }); }) + .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved: new settings apply to the next proxy job.' }); }) .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); }); }; @@ -1810,7 +1831,7 @@ function GpuSettingsCard() { @@ -1843,9 +1864,9 @@ function GpuSettingsCard() {
@@ -1941,13 +1962,13 @@ function SdiSettingsCard() { } // ──────────────────────────────────────────────────────────────────────────── -// Capture SDK deployment — Blackmagic / AJA / Deltacast +// Capture SDK deployment - Blackmagic / AJA / Deltacast // ──────────────────────────────────────────────────────────────────────────── const SDK_VENDORS = [ { id: 'blackmagic', name: 'Blackmagic DeckLink', - sub: 'DeckLink SDK 16.x — required for SDI capture via DeckLink cards', + sub: 'DeckLink SDK 16.x: required for SDI capture via DeckLink cards', expect: 'DeckLinkAPI.h, DeckLinkAPIDispatch.cpp, LinuxCOM.h, libDeckLinkAPI.so', docs: 'https://www.blackmagicdesign.com/developer/product/capture', buildHint: 'docker compose build --no-cache capture', @@ -1956,24 +1977,24 @@ const SDK_VENDORS = [ { id: 'aja', name: 'AJA NTV2', - sub: 'NTV2 SDK — for Kona / Io / U-Tap / T-Tap cards', + sub: 'NTV2 SDK: for Kona / Io / U-Tap / T-Tap cards', expect: 'libajantv2.so, ntv2card.h, ntv2enums.h', docs: 'https://sdksupport.aja.com/', - buildHint: 'FFmpeg patch + Dockerfile update pending — files will be staged for the next capture image build', + buildHint: 'FFmpeg patch + Dockerfile update pending: files will be staged for the next capture image build', status: 'staging-only', }, { id: 'deltacast', name: 'Deltacast VideoMaster', - sub: 'VideoMasterHD SDK — for FLEX / DELTA-h4k2 / etc.', + sub: 'VideoMasterHD SDK: for FLEX / DELTA-h4k2 / etc.', expect: 'VideoMasterHD_Core.h, libVideoMasterHD_Core.so', docs: 'https://www.deltacast.tv/products/sdk', - buildHint: 'FFmpeg patch + Dockerfile update pending — files will be staged for the next capture image build', + buildHint: 'FFmpeg patch + Dockerfile update pending: files will be staged for the next capture image build', status: 'staging-only', }, ]; -// Premiere panel releases — single source of truth lives on `window.PREMIERE_RELEASES` +// Premiere panel releases - single source of truth lives on `window.PREMIERE_RELEASES` // (see data.jsx). Local alias for readability. const PREMIERE_RELEASES = window.PREMIERE_RELEASES; @@ -1988,7 +2009,7 @@ function SdkSettingsCard() { React.useEffect(() => { load(); }, [load]); return ( - {SDK_VENDORS.length} vendors}> {/* ── Premiere Panel download section ── */} @@ -2059,7 +2080,7 @@ function SdkVendorRow({ vendor, status, onDone }) { const fd = new FormData(); fd.append('archive', file); - // Use XHR so we can report progress to the user — fetch's stream API is fiddly. + // Use XHR so we can report progress to the user - fetch's stream API is fiddly. await new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.open('POST', (window.ZAMPP_API_PREFIX || '/api/v1') + '/sdk/' + vendor.id); @@ -2075,7 +2096,7 @@ function SdkVendorRow({ vendor, status, onDone }) { } else { let txt = xhr.responseText; try { txt = JSON.parse(xhr.responseText).error || txt; } catch {} - onDone(vendor.name + ': upload failed — ' + txt, false); + onDone(vendor.name + ': upload failed: ' + txt, false); } resolve(); }; @@ -2159,7 +2180,7 @@ function AmppSettingsCard() { setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" /> - setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved — type to replace)' : 'AMPP API token'} autoComplete="new-password" /> + setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved: type to replace)' : 'AMPP API token'} autoComplete="new-password" />
diff --git a/services/web-ui/public/screens-asset.jsx b/services/web-ui/public/screens-asset.jsx index b0d279b..d92deb7 100644 --- a/services/web-ui/public/screens-asset.jsx +++ b/services/web-ui/public/screens-asset.jsx @@ -1,6 +1,6 @@ -// screens-asset.jsx — asset detail (Frame.io-style player, filmstrip, comments) +// screens-asset.jsx - asset detail (Frame.io-style player, filmstrip, comments) -// Simple gradient palette — replaces the missing thumbGrad function +// Simple gradient palette - replaces the missing thumbGrad function const _FRAME_GRADIENTS = [ 'linear-gradient(135deg,#1a1f2e 0%,#2a3045 100%)', 'linear-gradient(135deg,#1e2030 0%,#2d2040 100%)', @@ -41,7 +41,7 @@ function AssetDetail({ asset, onClose }) { // Player health: 'idle' | 'loading' | 'playing' | 'paused' | 'seeking' | 'waiting' | 'stalled' | 'error' const [playerState, setPlayerState] = React.useState('idle'); const [playerError, setPlayerError] = React.useState(null); - // Array of {start, end} in milliseconds — populated from HTMLMediaElement.buffered + // Array of {start, end} in milliseconds - populated from HTMLMediaElement.buffered const [buffered, setBuffered] = React.useState([]); // Wall-clock when waiting/stalled began (so we can show how long it's been hung) const [stallStart, setStallStart] = React.useState(null); @@ -89,7 +89,7 @@ function AssetDetail({ asset, onClose }) { }, [streamUrl, streamType]); // Fetch server-side filmstrip (pre-built by filmstrip worker via FFmpeg). - // Falls back to nothing if not ready yet — user can right-click → Re-generate. + // Falls back to nothing if not ready yet - user can right-click → Re-generate. React.useEffect(() => { if (!assetId) return; let cancelled = false; @@ -115,7 +115,7 @@ function AssetDetail({ asset, onClose }) { return function() { cancelled = true; }; }, [assetId, filmstripKey]); - // Fake playback timer — only used when no real video stream + // Fake playback timer - only used when no real video stream React.useEffect(() => { if (!playing || totalMs <= 0 || streamUrl) return; const i = setInterval(function() { @@ -159,7 +159,7 @@ function AssetDetail({ asset, onClose }) { return () => clearInterval(i); }, [stallStart]); - // #143 — if the player is stalled within 250 ms of EOF for more than 1.2 s, + // #143 - if the player is stalled within 250 ms of EOF for more than 1.2 s, // treat it as a clean end. Avoids the silent-freeze users hit when seeking // to the last instant of a clip. React.useEffect(() => { @@ -180,7 +180,7 @@ function AssetDetail({ asset, onClose }) { }, [stallStart, totalMs, playerState]); const seek = function(ms) { - // #143 — seeking exactly to `totalMs` parked the playhead one micro-sample + // #143 - seeking exactly to `totalMs` parked the playhead one micro-sample // past the last decoded frame; the player then asked S3 for a range past // EOF and stalled silently. Pull the clamp back 50 ms so the final frames // are reachable but the player never asks for bytes past the file size. @@ -212,7 +212,7 @@ function AssetDetail({ asset, onClose }) { .finally(function() { setDownloading(false); }); }; - // Right-click style menu on the kebab icon — delete, copy ID. + // Right-click style menu on the kebab icon - delete, copy ID. const [menuOpen, setMenuOpen] = React.useState(false); const moreBtnRef = React.useRef(null); React.useEffect(function() { @@ -258,7 +258,7 @@ function AssetDetail({ asset, onClose }) { const regenFilmstrip = function() { window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=filmstrip', { method: 'POST' }) - .then(function() { window.alert('Filmstrip job queued — it will appear automatically when ready.'); }) + .then(function() { window.alert('Filmstrip job queued: it will appear automatically when ready.'); }) .catch(function(e) { window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error')); }); }; @@ -477,7 +477,7 @@ function AssetDetail({ asset, onClose }) { LIVE · REC
)} - {/* Player health badge — shows when waiting/stalled so the freeze is visible */} + {/* Player health badge: shows when waiting/stalled so the freeze is visible */} {streamUrl && (playerState === 'waiting' || playerState === 'stalled' || playerState === 'seeking' || playerState === 'error') && (
@@ -630,7 +630,7 @@ function PlaybackBar({ current, total, onSeek, comments, buffered }) { const bufferedRanges = Array.isArray(buffered) ? buffered : []; return (
- {/* Buffered byte ranges — translucent grey segments showing what the browser has loaded */} + {/* Buffered byte ranges: translucent grey segments showing what the browser has loaded */} {total > 0 && bufferedRanges.map((br, i) => { const left = Math.max(0, (br.start / total) * 100); const right = Math.min(100, (br.end / total) * 100); @@ -902,7 +902,7 @@ function FilesTab({ asset, filmFrames, filmstripLoading, streamUrl, reprocessing 0) { rows.push({ k: "Audio tracks", v: audioMeta.length }); audioMeta.forEach(function(tr, i) { var label = tr.title || ('Track ' + (tr.index != null ? tr.index : i + 1)); - var ch = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '—'); - var parts = [tr.codec || '—', ch, tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '—', tr.bit_depth ? tr.bit_depth + '-bit' : '—', tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '—']; + var ch = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '·'); + var parts = [tr.codec || '·', ch, tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '·', tr.bit_depth ? tr.bit_depth + '-bit' : '·', tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '·']; if (tr.language) parts.push(tr.language); rows.push({ k: " " + label, v: parts.join(' · ') }); }); @@ -1106,13 +1106,13 @@ function AudioTab({ asset }) { var st = trackState[i] || { muted: false, solo: false, volume: 100 }; var isAudible = st.muted ? false : (anySolo ? st.solo : true); var color = _AUDIO_TRACK_COLORS[i % _AUDIO_TRACK_COLORS.length]; - var chLabel = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '—'); + var chLabel = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '·'); var trackName = tr.title || ('Track ' + (tr.index != null ? tr.index : i + 1)); var langTag = tr.language ? {tr.language} : null; - var codecLabel = tr.codec || '—'; - var srLabel = tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '—'; - var bdLabel = tr.bit_depth ? tr.bit_depth + '-bit' : '—'; - var brLabel = tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '—'; + var codecLabel = tr.codec || '·'; + var srLabel = tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '·'; + var bdLabel = tr.bit_depth ? tr.bit_depth + '-bit' : '·'; + var brLabel = tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '·'; return (
@@ -1187,7 +1187,7 @@ function AudioLevelMeter({ level, label, tall }) { } function parseDuration(d) { - if (!d || d === '—' || typeof d !== 'string') return 0; + if (!d || d === '·' || typeof d !== 'string') return 0; const parts = d.split(':'); if (parts.length < 2) return 0; const nums = parts.map(Number); diff --git a/services/web-ui/public/screens-auth.jsx b/services/web-ui/public/screens-auth.jsx index 349330d..551b449 100644 --- a/services/web-ui/public/screens-auth.jsx +++ b/services/web-ui/public/screens-auth.jsx @@ -1,4 +1,4 @@ -// LoginScreen + SetupScreen — layout B from the auth brainstorm spec: +// LoginScreen + SetupScreen - layout B from the auth brainstorm spec: // 22px wordmark + "WILD DRAGON BROADCAST" tagline above a --bg-1 card. // Matches DESIGN.md tokens; no decoration, dense, ops register. @@ -163,7 +163,7 @@ return (
- First-run setup — create the first admin + First-run setup: create the first admin
diff --git a/services/web-ui/public/screens-editor.jsx b/services/web-ui/public/screens-editor.jsx index fd62c89..225a1cb 100644 --- a/services/web-ui/public/screens-editor.jsx +++ b/services/web-ui/public/screens-editor.jsx @@ -1,4 +1,4 @@ -// screens-editor.jsx — NLE timeline editor +// screens-editor.jsx - NLE timeline editor // Depends on: window.TC (timecode.js), window.Timeline (timeline.js), window.ZAMPP_API function _uid() { return 'ce_' + (++_uid._c || (_uid._c = 0)); } @@ -378,45 +378,23 @@ function Editor() { return (
- {/* ── COMING SOON bumper — overlays the entire editor ── */} -
-
- + {/* Beta banner: flat strip across top, no glassmorphism or gradient. */} +
+ +
+ NLE editor is in beta. + Use the Premiere Pro panel for frame-accurate editing and growing-file workflows.
-
-
- NLE Editor — Coming Soon -
-
- The browser-based timeline editor is under active development. - In the meantime, use the Premiere Pro panel for - frame-accurate editing and growing-file workflows — download it from - Settings → Capture SDKs. -
-
-
- - + -
- Dragonflight Premiere Panel v{(window.PREMIERE_LATEST || {}).version || '—'} + + v{(window.PREMIERE_LATEST || {}).version || '·'} +
@@ -719,7 +697,7 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame function EditorKeyboard({ onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSeq }) { React.useEffect(() => { function handler(e) { - // #116 — `document.activeElement` is null in some edge cases (iframe focus, + // #116 - `document.activeElement` is null in some edge cases (iframe focus, // popovers, devtools-driven focus), and the previous code threw NPE here. const tag = (document.activeElement && document.activeElement.tagName) || ''; if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return; diff --git a/services/web-ui/public/screens-home.jsx b/services/web-ui/public/screens-home.jsx index d5b56b6..56f50eb 100644 --- a/services/web-ui/public/screens-home.jsx +++ b/services/web-ui/public/screens-home.jsx @@ -2,18 +2,18 @@ // // Two routes share this file: // -// • Home — the launcher. Big-button entry into each section of the MAM. +// • Home - the launcher. Big-button entry into each section of the MAM. // Untouched in this rewrite. // -// • Dashboard — the operations view. Rebuilt as a control-room status +// • Dashboard - the operations view. Rebuilt as a control-room status // board, not a SaaS analytics page. Sections render top-down by // operator priority: // -// 1. ON AIR — live recorder tiles, full-width -// 2. UP NEXT — single-row strip of next scheduled recordings -// 3. ATTENTION — conditional; only when something failed -// 4. WORK + CLUSTER — two-column dense panels -// 5. STATUS BAR — single mono-text line, bottom +// 1. ON AIR - live recorder tiles, full-width +// 2. UP NEXT - single-row strip of next scheduled recordings +// 3. ATTENTION - conditional; only when something failed +// 4. WORK + CLUSTER - two-column dense panels +// 5. STATUS BAR - single mono-text line, bottom // // Anything that would just say "all clear" is hidden, not rendered. @@ -91,6 +91,19 @@ function Home({ navigate }) { const clusterHealthy = !nodesTotal || nodesOnline >= nodesTotal; + // Activity strip (#153): live recorders + last-24h assets + alerts. + const liveRecorders = RECORDERS.filter(r => r.status === 'recording').slice(0, 4); + const recentAssets = (() => { + const dayAgo = Date.now() - 86400000; + return ASSETS + .filter(a => a.created_at && new Date(a.created_at).getTime() > dayAgo) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) + .slice(0, 6); + })(); + const failedCount = JOBS.filter(j => j.status === 'failed').length; + const errCount = RECORDERS.filter(r => r.status === 'error').length; + const hasActivity = liveRecorders.length || recentAssets.length || failedCount || errCount; + return (
@@ -144,6 +157,71 @@ function Home({ navigate }) {
+ {hasActivity && ( +
+ {(failedCount > 0 || errCount > 0) && ( +
+ + + {errCount > 0 && {errCount} recorder{errCount === 1 ? '' : 's'} in error.} + {errCount > 0 && failedCount > 0 && ' '} + {failedCount > 0 && {failedCount} failed job{failedCount === 1 ? '' : 's'}.} + + +
+ )} + + {liveRecorders.length > 0 && ( +
+
+ + Recording now + {liveRecorders.length} live +
+ +
+
+ {liveRecorders.map(r => ( + + ))} +
+
+ )} + + {recentAssets.length > 0 && ( +
+
+ + Last 24 hours + {recentAssets.length} new asset{recentAssets.length === 1 ? '' : 's'} +
+ +
+
+ {recentAssets.map(a => ( + + ))} +
+
+ )} +
+ )} +
{ const i = setInterval(() => setTick(t => t + 1), 1000); @@ -193,7 +271,7 @@ function Dashboard({ navigate }) { return () => { cancelled = true; clearInterval(t); }; }, []); - // Refresh jobs frequently — this screen is the failed-job alert surface. + // Refresh jobs frequently - this screen is the failed-job alert surface. const [jobs, setJobs] = React.useState(JOBS); React.useEffect(() => { let cancelled = false; @@ -209,8 +287,8 @@ function Dashboard({ navigate }) { ...j, status: statusMap[j.status] || j.status, kind: kindMap[j.type] || j.type || 'Job', - asset: j.asset_name || meta.filename || '—', - node: meta.node || '—', + asset: j.asset_name || meta.filename || '·', + node: meta.node || '·', error: j.error || null, progress: j.progress || 0, }; @@ -244,6 +322,22 @@ function Dashboard({ navigate }) { return (
+
+

Dashboard

+ Live operations: on-air recorders, jobs, cluster health +
+ {hasAttention && ( + + + {failedJobs.length + offlineNodes.length + erroredRecorders.length} alert{failedJobs.length + offlineNodes.length + erroredRecorders.length === 1 ? '' : 's'} + + )} + + + {onlineNodes}/{NODES.length || 0} nodes online + +
+ {/* ────────── ON AIR ────────── */}
navigate('jobs')} /> ))} @@ -491,14 +585,14 @@ function OnAirTile({ recorder, onClick }) {
{recorder.name}
- {recorder.source || '—'} - {recorder.res && recorder.res !== '—' && ( + {recorder.source || '·'} + {recorder.res && recorder.res !== '·' && ( <> · {recorder.res} )} - {recorder.codec && recorder.codec !== '—' && ( + {recorder.codec && recorder.codec !== '·' && ( <> · {recorder.codec} @@ -620,7 +714,7 @@ function DashClusterRow({ node }) { {Math.round(cpuPct)}% - ) : } + ) : ·} {memPct != null ? ( @@ -636,7 +730,7 @@ function DashClusterRow({ node }) { {memUsed < 1 ? Math.round(memUsed * 1024) + 'M' : memUsed.toFixed(1) + 'G'} - ) : } + ) : ·}
); diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index aa9fb45..1ed3be1 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -1,4 +1,4 @@ -// screens-ingest.jsx — Upload, Recorders, Capture, Monitors +// screens-ingest.jsx - Upload, Recorders, Capture, Monitors /* ===== Upload helpers ===== */ const _SIMPLE_MAX = 50 * 1024 * 1024; // 50 MB → simple upload @@ -38,7 +38,7 @@ async function _uploadFile(file, projectId, onProgress) { (loaded, total) => onProgress(Math.round((loaded / total) * 100))); } - // — Multipart — + // - Multipart - const init = await window.ZAMPP_API.fetch('/upload/init', { method: 'POST', body: JSON.stringify({ filename: file.name, fileSize: file.size, contentType: mime, projectId }), @@ -106,7 +106,7 @@ function Upload({ navigate }) {

Upload

- Drop video, audio, or stills — we proxy and index automatically. + Drop video, audio, or stills: we proxy and index automatically.
@@ -227,7 +227,7 @@ function YouTubeImport({ navigate }) { window.dispatchEvent(new CustomEvent('df:assets-changed')); } else if (asset.status === 'error') { patch.status = 'error'; - patch.error = patch.error || 'Import failed — check the Jobs screen for details.'; + patch.error = patch.error || 'Import failed: check the Jobs screen for details.'; } else if (asset.status === 'processing') { patch.status = 'processing'; } @@ -274,7 +274,7 @@ function YouTubeImport({ navigate }) {

YouTube

- Paste a link — we download and import the best available MP4. + Paste a link: we download and import the best available MP4.
@@ -471,7 +471,7 @@ function HlsPreview({ assetId, muted = true, controls = false, className }) { /* ===== Recorders ===== */ function _normRecorder(r) { - let elapsed = '—'; + let elapsed = '·'; if (r.status === 'recording' && r.started_at) { const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000); elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' + @@ -481,13 +481,13 @@ function _normRecorder(r) { const cfg = r.source_config || {}; return { ...r, - source: r.source_type || '—', - url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '—', - codec: r.recording_codec || '—', - res: r.recording_resolution || '—', + source: r.source_type || '·', + url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '·', + codec: r.recording_codec || '·', + res: r.recording_resolution || '·', node: r.node_id ? r.node_id.slice(0, 8) : 'primary', elapsed, - bitrate: '—', + bitrate: '·', health: 100, audio: false, }; @@ -504,7 +504,7 @@ function Recorders({ navigate, onNew }) { setRecorders(norm); }) .catch(err => { - // apiFetch already redirects on 401 — don't log noise, interval + // apiFetch already redirects on 401 - don't log noise, interval // will be cleared automatically when the component unmounts on redirect (#55) if (err && err.message && err.message.includes('Unauthenticated')) return; window.DF_LOG.warn('[recorders] poll error:', err?.message); @@ -600,8 +600,8 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) { }, [liveStatus, recorder.elapsed]); const displaySignal = liveStatus - ? (liveStatus.signal || '—') - : (isRec ? 'connecting…' : '—'); + ? (liveStatus.signal || '·') + : (isRec ? 'connecting…' : '·'); const signalColor = displaySignal === 'receiving' ? 'var(--success)' : displaySignal === 'stopped' ? 'var(--danger)' @@ -751,7 +751,7 @@ function _captureSignalChip(sig) { case 'error': return { label: 'ERROR', color: 'var(--danger)', pulse: false }; case 'idle': return { label: 'IDLE', color: 'var(--text-3)', pulse: false }; case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)', pulse: false }; - default: return { label: sig || '—', color: 'var(--text-4)', pulse: false }; + default: return { label: sig || '·', color: 'var(--text-4)', pulse: false }; } } @@ -763,7 +763,7 @@ function CapturePortChip({ port, sigEntry }) { return (
{feed.name} - {feed.elapsed && feed.elapsed !== '—' && {feed.elapsed}} + {feed.elapsed && feed.elapsed !== '·' && {feed.elapsed}}
); @@ -1091,7 +1091,7 @@ const _STATUS_BADGE = { }; function _fmtWhen(iso) { - if (!iso) return '—'; + if (!iso) return '·'; const d = new Date(iso); // Local-time, short, human; e.g. "May 22 · 7:30 PM" return d.toLocaleString(undefined, { @@ -1335,7 +1335,7 @@ function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, on const d = drag; setDrag(null); if (!d.moved) { - // Treat as a click — open the edit modal. + // Treat as a click - open the edit modal. onClick(event); return; } @@ -1489,7 +1489,7 @@ function _RecorderGutter({ recorders, projects }) {
{r.name}
-
{(r.source_type || '—').toUpperCase()}{color ? ' · ' : ''}{color && }
+
{(r.source_type || '·').toUpperCase()}{color ? ' · ' : ''}{color && }
); @@ -1517,7 +1517,7 @@ function Schedule({ navigate }) { return () => clearInterval(id); }, []); - // Schedule data — pull everything once and filter client-side for the + // Schedule data - pull everything once and filter client-side for the // active view. /schedules caps at 200 rows so this stays cheap. const apiFilter = view === 'list' ? listFilter : 'all'; const load = React.useCallback(() => { @@ -1549,7 +1549,7 @@ function Schedule({ navigate }) { const projects = window.ZAMPP_DATA?.PROJECTS || []; - // Pixels per hour — wider on Today (high-res operations view), tighter + // Pixels per hour - wider on Today (high-res operations view), tighter // when the user is scanning Week-at-a-glance. const pph = view === 'week' ? 44 : 88; @@ -1604,7 +1604,7 @@ function Schedule({ navigate }) { }; const openCtx = (s, ev) => setCtxMenu({ schedule: s, x: ev.clientX, y: ev.clientY }); - // Dismiss the context menu on any outside click — capture phase so a + // Dismiss the context menu on any outside click - capture phase so a // click on a menu item still fires before the menu unmounts. React.useEffect(() => { if (!ctxMenu) return; @@ -1859,7 +1859,7 @@ function EditScheduleModal({ schedule, onClose, onSaved }) { -
Recorder can't be reassigned — delete + recreate to change.
+
Recorder can't be reassigned: delete + recreate to change.
@@ -1928,11 +1928,11 @@ function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, default const endD = new Date(form.end_at); if (endD <= startD) return setErr('End must be after start'); - // Warn (but allow) start times in the past — the scheduler tick will fire + // Warn (but allow) start times in the past - the scheduler tick will fire // them immediately, which is occasionally what the operator wants // (e.g. "record the next 30 minutes starting now"). if (startD < new Date(Date.now() - 60_000)) { - if (!confirm('Start time is in the past — recorder will fire immediately when saved.\nContinue?')) return; + if (!confirm('Start time is in the past: recorder will fire immediately when saved.\nContinue?')) return; } // Datetime-local inputs are in the browser's local zone; ship as ISO so @@ -1972,7 +1972,7 @@ function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, default setSearch(e.target.value)} placeholder="Search projects…" />
- - + +
@@ -140,8 +140,8 @@ function Projects({ onOpenProject, navigate }) {
{p.name}
{p.assets || 0}
-
-
{p.updated || '—'}
+
·
+
{p.updated || '·'}
e.stopPropagation()}> {menuFor === p.id && ( @@ -210,7 +210,7 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) { const ofProject = assets.filter(a => a.project_id === project.id); const thumbAssets = ofProject.slice(0, 4); - // Real status distribution — ready vs processing/live vs error. + // Real status distribution - ready vs processing/live vs error. const total = ofProject.length || 1; const ready = ofProject.filter(a => a.status === 'ready').length; const inFlight = ofProject.filter(a => a.status === 'processing' || a.status === 'live').length; @@ -259,7 +259,7 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
{ofProject.length} asset{ofProject.length === 1 ? '' : 's'} · - updated {project.updated || '—'} + updated {project.updated || '·'}
{ofProject.length > 0 ? (
diff --git a/services/web-ui/public/shell.jsx b/services/web-ui/public/shell.jsx index 9ec2591..d1fad79 100644 --- a/services/web-ui/public/shell.jsx +++ b/services/web-ui/public/shell.jsx @@ -1,40 +1,64 @@ // shell.jsx - app shell: sidebar nav, topbar, route container -const NAV_TREE = [ - { id: "home", label: "Home", icon: "home" }, - { id: "dashboard", label: "Dashboard", icon: "layout" }, - { id: "library", label: "Library", icon: "library" }, - { id: "projects", label: "Projects", icon: "folder" }, +// Sidebar IA: grouped sections. Renderer prints each section's label, then +// its items. Items inside `children` of a `group:true` node still render as +// the existing expandable submenu (only used for the Capture-SDK admin tools +// today, but kept general). +const NAV_SECTIONS = [ { - id: "ingest", label: "Ingest", icon: "upload", group: true, - children: [ + label: "Workspace", + items: [ + { id: "home", label: "Home", icon: "home" }, + { id: "dashboard", label: "Dashboard", icon: "layout" }, + { id: "library", label: "Library", icon: "library" }, + { id: "projects", label: "Projects", icon: "folder" }, + ], + }, + { + label: "Ingest", + items: [ { id: "upload", label: "Upload", icon: "upload" }, { id: "youtube", label: "YouTube", icon: "download" }, { id: "recorders", label: "Recorders", icon: "record" }, { id: "schedule", label: "Schedule", icon: "jobs" }, - { id: "capture", label: "Capture", icon: "capture" }, { id: "monitors", label: "Monitors", icon: "monitor" }, ], }, - { id: "jobs", label: "Jobs", icon: "jobs" }, - { id: "editor", label: "Editor", icon: "editor" }, + { + label: "Operations", + items: [ + { id: "capture", label: "Capture", icon: "capture" }, + { id: "jobs", label: "Jobs", icon: "jobs" }, + { id: "editor", label: "Editor", icon: "editor", badge: { kind: 'neutral', text: 'BETA' } }, + ], + }, + { + label: "Admin", + items: [ + { id: "users", label: "Users", icon: "users" }, + { id: "tokens", label: "Tokens", icon: "token" }, + { id: "containers", label: "Containers", icon: "container" }, + { id: "cluster", label: "Cluster", icon: "cluster" }, + { id: "settings", label: "Settings", icon: "settings" }, + ], + }, ]; -const ADMIN_TREE = [ - { id: "users", label: "Users", icon: "users" }, - { id: "tokens", label: "Tokens", icon: "token" }, - { id: "containers", label: "Containers", icon: "container" }, - { id: "cluster", label: "Cluster", icon: "cluster" }, - { id: "settings", label: "Settings", icon: "settings" }, +// Hidden routes: not in the sidebar but still reachable by direct nav. +// `tokens-parody` is the old satirical pricing page (see issue #152). Real +// API token management lives at /tokens (in the Admin section above). +const NAV_HIDDEN = [ + { id: "tokens-parody", label: "Tokens (parody)", icon: "token" }, ]; +// Back-compat: NAV_TREE and ADMIN_TREE were used by other modules. +// NAV_FLAT is consumed by topbar search and the keyboard router. +const NAV_TREE = NAV_SECTIONS.slice(0, 3).flatMap(s => s.items); +const ADMIN_TREE = NAV_SECTIONS[3].items; const NAV_FLAT = (() => { const out = []; - const visit = (arr) => arr.forEach(n => { - if (n.group && n.children) { visit(n.children); return; } - out.push({ id: n.id, label: n.label, icon: n.icon }); - }); - visit(NAV_TREE); visit(ADMIN_TREE); + NAV_SECTIONS.forEach(s => s.items.forEach(n => out.push({ id: n.id, label: n.label, icon: n.icon }))); + NAV_HIDDEN.forEach(n => out.push({ id: n.id, label: n.label, icon: n.icon })); return out; })(); @@ -80,12 +104,11 @@ function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup }) } function Sidebar({ active, onNavigate, me, collapsed, onToggle }) { - const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"])); + const [openGroups, setOpenGroups] = React.useState(new Set([])); const [jobsBadge, setJobsBadge] = React.useState(null); - const [captureBadge, setCaptureBadge] = React.useState(null); - // Live jobs count (#130) — poll /jobs/count for active jobs and render the - // result as the sidebar badge. Falls back to hidden on error. + // Live jobs count (#130): poll /jobs?status=active and render the result + // as the sidebar badge on the Jobs item. Falls back to hidden on error. React.useEffect(() => { let cancelled = false; const tick = () => { @@ -103,43 +126,16 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) { return () => { cancelled = true; clearInterval(id); }; }, []); - // Live DeckLink signal presence — poll every 5s, badge shows receiving port count. - React.useEffect(() => { - let cancelled = false; - const tick = () => { - window.ZAMPP_API.fetch('/cluster/devices/blackmagic/signal') - .then(entries => { - if (cancelled) return; - const all = Array.isArray(entries) ? entries : []; - const live = all.filter(e => e.signal === 'receiving').length; - const total = all.length; - if (total === 0) { setCaptureBadge(null); return; } - setCaptureBadge(live > 0 - ? { kind: 'live', text: `${live}/${total}` } - : { kind: 'neutral', text: `0/${total}` }); - }) - .catch(() => setCaptureBadge(null)); - }; - tick(); - const id = setInterval(tick, 5000); - return () => { cancelled = true; clearInterval(id); }; - }, []); + // (Capture live-signal badge previously lived here; it now belongs in the + // topbar status pip alongside the cluster pip. See issue #149.) - // Apply live badges to nav items. - const navTree = React.useMemo( - () => NAV_TREE.map(n => { - if (n.id === 'jobs' && jobsBadge) return { ...n, badge: jobsBadge }; - if (n.id === 'ingest' && n.children) { - return { - ...n, - children: n.children.map(c => - c.id === 'capture' && captureBadge ? { ...c, badge: captureBadge } : c - ), - }; - } - return n; - }), - [jobsBadge, captureBadge] + // Apply the live Jobs badge to the Operations section. + const sections = React.useMemo( + () => NAV_SECTIONS.map(sec => ({ + ...sec, + items: sec.items.map(n => (n.id === 'jobs' && jobsBadge) ? { ...n, badge: jobsBadge } : n), + })), + [jobsBadge] ); const toggleGroup = (id) => { setOpenGroups(prev => { @@ -181,34 +177,28 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
- {navTree.map(item => ( - - ))} -
Admin
- {ADMIN_TREE.map(item => ( - + {sections.map((section, si) => ( + + {si > 0 &&
{section.label}
} + {section.items.map(item => ( + + ))} +
))}
-
{me?.initials || '—'}
+
{me?.initials || '·'}
{me?.name || 'Not signed in'}
-
- {me?.role || '—'}{me?.synthetic ? ' · auth off' : ''} +
+ {me?.role || '·'}{me?.synthetic ? ' · auth off' : ''}
{me?.synthetic ? null : ( @@ -265,7 +255,7 @@ function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) { (D.ASSETS || []).forEach(a => { const hay = ((a.name || '') + ' ' + (a.project || '') + ' ' + (a.filename || '')).toLowerCase(); if (hay.includes(term)) { - const sub = [a.project, a.res, a.duration].filter(x => x && x !== '—').join(' · '); + const sub = [a.project, a.res, a.duration].filter(x => x && x !== '·').join(' · '); out.push({ kind: 'asset', icon: assetSearchIcon(a), label: a.name, sub, item: a }); } }); @@ -399,7 +389,7 @@ function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) { } function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject, onToggleSidebar }) { - // Light cluster ping — the badge in the topbar should reflect reality, + // Light cluster ping - the badge in the topbar should reflect reality, // not just look reassuring. /metrics/home returns cluster online/total. const [clusterHealthy, setClusterHealthy] = React.useState(true); React.useEffect(() => { @@ -478,5 +468,6 @@ window.Sidebar = Sidebar; window.Topbar = Topbar; window.NAV_TREE = NAV_TREE; window.NAV_FLAT = NAV_FLAT; +window.NAV_SECTIONS = NAV_SECTIONS; window.Field = Field; window.GlobalSearch = GlobalSearch; diff --git a/services/web-ui/public/styles-asset.css b/services/web-ui/public/styles-asset.css index 09f3dbe..04769b1 100644 --- a/services/web-ui/public/styles-asset.css +++ b/services/web-ui/public/styles-asset.css @@ -1,7 +1,7 @@ /* ========== Asset detail ========== */ .asset-detail { display: flex; flex-direction: column; - flex: 1; /* parent is `.main` (flex col) — fill remaining vertical space */ + flex: 1; /* parent is `.main` (flex col) - fill remaining vertical space */ min-height: 0; background: var(--bg-0); overflow: hidden; @@ -60,13 +60,11 @@ background: rgba(0,0,0,0.55); border-radius: 50%; padding: 16px; - backdrop-filter: blur(8px); } .player-tc { position: absolute; right: 12px; bottom: 12px; background: rgba(0,0,0,0.6); - backdrop-filter: blur(4px); padding: 4px 10px; border-radius: 4px; font-family: var(--font-mono); diff --git a/services/web-ui/public/styles-fixes.css b/services/web-ui/public/styles-fixes.css index 0a4639c..13e0cea 100644 --- a/services/web-ui/public/styles-fixes.css +++ b/services/web-ui/public/styles-fixes.css @@ -66,7 +66,7 @@ .activity-text .target { word-break: break-word; } .asset-card .meta .sub { overflow: hidden; min-width: 0; flex-wrap: wrap; } -/* #52 — duration mono badge in the meta row had no shrink behaviour, so on +/* #52 - duration mono badge in the meta row had no shrink behaviour, so on narrow cards it overlapped the project text. Force the duration column to never overflow and let the project label ellipsize. */ .asset-card .meta .sub > .duration { flex-shrink: 0; margin-left: auto; } @@ -76,7 +76,7 @@ .dash-sparkline { z-index: 0; } /* ============================================================ - Search bar polish — give it a real container so it doesn't + Search bar polish - give it a real container so it doesn't read as floating text on the topbar background. ============================================================ */ .topbar .search, @@ -123,7 +123,7 @@ color: var(--text-2); } -/* Library-local "Filter assets" search — same container treatment, +/* Library-local "Filter assets" search - same container treatment, keep its compact width. */ .library-toolbar .search { background: var(--bg-2); @@ -165,7 +165,7 @@ } /* ============================================================ - Right-click context menu — pop it forward off the page so it + Right-click context menu - pop it forward off the page so it reads as a menu, not a floating list. ============================================================ */ .ctx-menu { @@ -225,7 +225,7 @@ } .ctx-menu button.danger:hover:not(:disabled) svg { color: var(--danger); } -/* Row-popover menu (Users page etc.) — match the same polish so the +/* Row-popover menu (Users page etc.) - match the same polish so the app feels consistent. */ .row-menu { background: var(--bg-2); @@ -240,7 +240,7 @@ .row-menu button.danger:hover { background: var(--danger-soft); color: var(--danger); } /* ============================================================ - Sidebar brand logo — replace the gradient "D" tile with the + Sidebar brand logo - replace the gradient "D" tile with the actual dragon-coiled-D logo. mix-blend-mode: screen drops the light-gray PNG background so only the black silhouette + blue flame remain over the dark sidebar. @@ -260,7 +260,7 @@ } /* ============================================================ - Launcher home — full-bleed landing page with the logo as hero + Launcher home - full-bleed landing page with the logo as hero and big section tiles. ============================================================ */ .launcher { @@ -297,7 +297,7 @@ width: 180px; height: 180px; object-fit: contain; - /* Convert to white — same approach as .brand-logo. */ + /* Convert to white - same approach as .brand-logo. */ filter: brightness(0) invert(1) drop-shadow(0 0 24px rgba(91, 124, 250, 0.28)) @@ -435,7 +435,7 @@ color: var(--tile-icon-fg, var(--accent-text)); } -/* Tone variants — colour the icon tile + halo, leave the body text +/* Tone variants - colour the icon tile + halo, leave the body text neutral so the tile reads as a button, not a banner. */ .launcher-tile.tone-accent { --tile-tint: rgba(91, 124, 250, 0.18); @@ -515,7 +515,7 @@ } /* ============================================================ - Recorder row — signal indicator with a pulsing dot when + Recorder row - signal indicator with a pulsing dot when actually receiving frames. Closes part of #2. ============================================================ */ .signal-val { @@ -539,7 +539,7 @@ } /* ============================================================ - BMD card diagram — rendered inside the Cluster node panel. + BMD card diagram - rendered inside the Cluster node panel. The SVG is generated by bmd-card.js; styles live here so they inherit the app CSS custom properties at render time. ============================================================ */ diff --git a/services/web-ui/public/styles-rest.css b/services/web-ui/public/styles-rest.css index 62c78e0..c063863 100644 --- a/services/web-ui/public/styles-rest.css +++ b/services/web-ui/public/styles-rest.css @@ -361,7 +361,7 @@ display: flex; flex-direction: column; cursor: pointer; } -.monitor-tile.audio { background: linear-gradient(135deg, hsl(180 30% 12%), hsl(200 25% 6%)); } +.monitor-tile.audio { background: var(--bg-1); } .monitor-tile-label { position: absolute; bottom: 0; left: 0; right: 0; @@ -391,7 +391,7 @@ display: grid; /* status · Job · Asset · Node · Progress · Time · Priority · Actions Time needs room for "done May 22 · 2:23 PM · 6h ago"; Progress hosts - the bar + percent; Node is just "primary" or "—" so it can be tight. */ + the bar + percent; Node is just "primary" or "-" so it can be tight. */ grid-template-columns: 20px 110px 1fr 60px 240px 240px 70px 90px; align-items: center; gap: 12px; @@ -420,7 +420,7 @@ } .job-progress-fill { height: 100%; - background: linear-gradient(90deg, var(--accent), #7C9EFF); + background: var(--accent); background-size: 200% 100%; animation: shimmer 2s linear infinite; transition: width 300ms; @@ -429,7 +429,7 @@ /* ========== Editor ========== */ .editor-shell { display: flex; flex-direction: column; - flex: 1; /* parent is `.main` (flex col) — fill remaining vertical space */ + flex: 1; /* parent is `.main` (flex col) - fill remaining vertical space */ min-height: 0; background: var(--bg-0); } @@ -627,7 +627,7 @@ so the gutter (left) and ruler (top) stay sticky during scroll. */ .epg-page { display: flex; flex-direction: column; - flex: 1; /* parent is `.main` (flex col) — fill remaining vertical space (#132) */ + flex: 1; /* parent is `.main` (flex col) - fill remaining vertical space (#132) */ min-height: 0; --epg-pph: 88px; /* pixels per hour, overridden inline per view */ --epg-row-h: 60px; @@ -786,7 +786,7 @@ grid-row: 2; grid-column: 2; position: relative; background: - /* hour-band rhythm — alternating subtle stripe every other hour */ + /* hour-band rhythm - alternating subtle stripe every other hour */ repeating-linear-gradient( to right, transparent 0, diff --git a/services/web-ui/public/styles-screens.css b/services/web-ui/public/styles-screens.css index 7dc8401..9cd1285 100644 --- a/services/web-ui/public/styles-screens.css +++ b/services/web-ui/public/styles-screens.css @@ -33,7 +33,6 @@ font-weight: 500; color: white; background: rgba(0,0,0,0.7); - backdrop-filter: blur(4px); padding: 2px 6px; border-radius: 3px; line-height: 1.3; @@ -44,7 +43,7 @@ display: flex; gap: 4px; } -/* Hi-res download button — top-right corner of an asset thumbnail. +/* Hi-res download button - top-right corner of an asset thumbnail. Hidden by default, revealed on card hover or button focus. Avoids crowding the resting-state thumb (issue #145). */ .thumb-download-btn { @@ -61,7 +60,6 @@ opacity: 0; transform: translateY(-2px); transition: opacity 80ms ease-out, transform 120ms ease-out, background 80ms; - backdrop-filter: blur(4px); } .asset-card:hover .thumb-download-btn, .thumb-download-btn:focus-visible { @@ -138,7 +136,7 @@ 50% { opacity: 0.6; } } -/* ========== Dashboard stats — dense row, not hero-metric cards ========== */ +/* ========== Dashboard stats - dense row, not hero-metric cards ========== */ .dash-stat-row { display: grid; grid-template-columns: repeat(4, 1fr); @@ -205,7 +203,7 @@ } .dash-stat-sub.up { color: var(--success); } -/* Sparkline sits in its own row at the bottom — no absolute positioning */ +/* Sparkline sits in its own row at the bottom - no absolute positioning */ .dash-sparkline { height: 24px; margin-top: 4px; @@ -250,7 +248,6 @@ background: rgba(0,0,0,0.6); padding: 3px 8px; border-radius: 4px; - backdrop-filter: blur(4px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -273,7 +270,6 @@ background: rgba(0,0,0,0.6); padding: 3px 6px; border-radius: 4px; - backdrop-filter: blur(4px); } .live-feed-tile-badge { position: absolute; @@ -293,7 +289,6 @@ background: rgba(0,0,0,0.5); padding: 2px 7px; border-radius: 4px; - backdrop-filter: blur(4px); } .live-feed-project-dot { width: 6px; height: 6px; @@ -618,7 +613,6 @@ background: rgba(0,0,0,0.65); padding: 2px 7px; border-radius: 4px; - backdrop-filter: blur(4px); } .dash-onair-meta { padding: 10px 12px; @@ -1085,6 +1079,8 @@ .library-toolbar .toolbar-title { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; + margin: 0; + line-height: inherit; } .library-toolbar .count { color: var(--text-3); font-size: 12.5px; } @@ -1203,3 +1199,150 @@ } .list-row .name { font-weight: 500; } .list-row .col-sub { color: var(--text-3); font-family: var(--font-mono); font-size: 11.5px; } + +/* Editor beta banner: flat strip on top of editor, replacing the old + glassmorphism + gradient + glow bumper. No blur, no gradients, no glow. */ +.editor-beta-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + background: var(--accent-soft); + border-bottom: 1px solid var(--border); + color: var(--text-2); + font-size: 12.5px; + flex-shrink: 0; +} +.editor-beta-banner > svg { color: var(--accent-text); flex-shrink: 0; } +.editor-beta-banner-body { flex: 1; min-width: 0; } +.editor-beta-banner-body strong { color: var(--text-1); font-weight: 600; margin-right: 4px; } +.editor-beta-banner-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } +.editor-beta-banner-actions a { text-decoration: none; } +.editor-beta-banner-version { font-size: 11px; color: var(--text-3); padding-left: 4px; } + +/* Home activity strip (issue #153). Sits below the launcher grid and shows + real activity: live recorders, last-24h assets, attention alerts. */ +.launcher-activity { + margin-top: 28px; + display: flex; + flex-direction: column; + gap: 16px; + max-width: 880px; + width: 100%; +} +.launcher-activity-strip.alert { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 8px; + background: rgba(255,91,91,0.08); + border: 1px solid var(--danger); + color: var(--text-2); + font-size: 12.5px; +} +.launcher-activity-strip.alert > svg { color: var(--danger); flex-shrink: 0; } +.launcher-activity-strip.alert strong { color: var(--text-1); font-weight: 600; } +.launcher-activity-strip.alert > span { flex: 1; } +.launcher-activity-section { + display: flex; + flex-direction: column; + gap: 8px; +} +.launcher-activity-head { + display: flex; + align-items: center; + gap: 8px; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-3); +} +.launcher-activity-head .muted { color: var(--text-4); font-weight: 500; letter-spacing: 0; text-transform: none; font-size: 11px; } +.launcher-activity-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 6px; +} +.launcher-activity-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 12.5px; + color: var(--text-2); + text-align: left; + cursor: pointer; + transition: background 80ms, border-color 80ms; +} +.launcher-activity-item:hover { background: var(--bg-2); border-color: var(--border-strong); } +.launcher-activity-item > svg { color: var(--text-3); flex-shrink: 0; } +.launcher-activity-item-name { + flex: 1; min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--text-1); font-weight: 500; +} +.launcher-activity-item-meta { + font-size: 10.5px; color: var(--text-3); + text-transform: uppercase; letter-spacing: 0.04em; + flex-shrink: 0; +} + +/* Jobs screen: classes extracted from per-row inline styles (issue #148). + Cuts 487 rendered inline styles to roughly zero. */ +.jobs-tabs { margin-top: 20px; width: fit-content; } +.jobs-panel { margin-top: 12px; } +.jobs-empty { padding: 24px; text-align: center; color: var(--text-3); } + +.stat-card .delta.delta-warn { color: var(--warning); } +.stat-card .delta.delta-tiny { font-size: 10.5px; } + +.job-row .job-row-kind { display: flex; align-items: center; gap: 8px; } +.job-row .job-row-kind-icon { color: var(--text-3); } +.job-row .job-row-kind-name { font-weight: 500; } +.job-row .job-row-asset { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-2); +} +.job-row .job-row-node { font-size: 11.5px; color: var(--text-3); } +.job-row .job-row-progress-pct { + font-size: 10.5px; + color: var(--text-3); + min-width: 32px; + text-align: right; +} +.job-row .job-row-status-done { background: transparent; padding: 0; } +.job-row .job-row-status-queued { font-size: 12px; color: var(--text-3); } +.job-row .job-row-status-failed { + font-size: 12px; + color: var(--danger); + display: flex; + align-items: center; + gap: 4px; + overflow: hidden; +} +.job-row .job-row-status-failed-msg { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.job-row .job-row-time { + font-size: 11.5px; + color: var(--text-3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.job-row .job-row-actions { + display: flex; + gap: 4px; + justify-content: flex-end; +} +.job-row .job-row-cancel { color: var(--danger); } diff --git a/services/web-ui/public/styles.css b/services/web-ui/public/styles.css index 5b7c3e9..2509fd9 100644 --- a/services/web-ui/public/styles.css +++ b/services/web-ui/public/styles.css @@ -15,7 +15,7 @@ /* text */ --text-1: #F2F3F6; --text-2: #A8AEBC; - --text-3: #8B92A0; /* WCAG AA (#133) — was #6B7280, 4.06:1 vs --bg-0; now ~7.5:1 */ + --text-3: #8B92A0; /* WCAG AA (#133) - was #6B7280, 4.06:1 vs --bg-0; now ~7.5:1 */ --text-4: #6B7280; /* accent (blue, frame.io-ish) */