Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal codec stubs) deferred per user request. ## #146 sweep em-dashes (186 to 0) - Replace placeholder '—' with '·' across all jsx - Convert ' — ' to ': ' or '. ' in copy where context permits - Comment-only em-dashes converted to ASCII dash - Sweep css files too (16 comments) ## #147 remove glassmorphism + accent gradients - Strip 8 backdrop-filter declarations from styles-screens.css and styles-asset.css. Only legit modal scrim in styles-modal.css remains. - Replace .job-progress-fill gradient with solid var(--accent) - Replace .monitor-tile.audio gradient with flat var(--bg-1) ## #148 extract Jobs inline styles to CSS - Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic width on progress bar). Live DOM was 487 inline-styled elements due to per-row repetition; now ~0. - Added job-row-kind, job-row-asset, job-row-node, job-row-time, job-row-actions, job-row-status-* utility classes in styles-screens.css ## #149 sidebar IA reorganized - Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS: Workspace / Ingest / Operations / Admin - Move Capture out of Ingest into Operations (it's a live-signal monitor, not an ingest action) - Drop the 0/N capture badge from nav (belongs in topbar) - Add BETA badge to Editor ## #151 redesign Editor 'Coming Soon' bumper - Replace fullscreen glassmorphism + gradient + glow overlay with a flat beta banner across the top of the editor area - New .editor-beta-banner CSS class (flat, accent-soft tint, no blur) ## #152 hide Tokens parody, restore real API token mgmt - New top-level Tokens admin page wraps existing ApiTokensSection - Old parody renamed to TokensParody, accessible at /tokens-parody route - Add window-level df:nav event for cross-component routing ## #153 make Home actually useful - New activity strip below the launcher grid: 'Recording now' tiles for live recorders, 'Last 24 hours' tiles for newly created assets, plus an attention strip when there are failed jobs or errored recorders - Each item is clickable and routes to the relevant screen ## #154 aria-labels on icon-only buttons - Projects + Library grid/list view toggles now have aria-label + title ## #155 page-header pattern - Dashboard now renders a proper .page-header h1 with subtitle + alert badge + cluster status pip - Library toolbar-title promoted to h1 for screen-reader hierarchy - Document Home/Library/Editor full-bleed exceptions in DESIGN.md - Editor's chrome is the beta banner (covered by #151)
This commit is contained in:
parent
f54c49d2dc
commit
342b56af35
20 changed files with 596 additions and 353 deletions
10
DESIGN.md
10
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 `<div className="page"><div className="page-header"><h1>…</h1>…</div>…</div>` for consistent IA and screen-reader hierarchy.
|
||||
|
||||
## Shadow
|
||||
|
||||
Two tokens, used sparingly:
|
||||
|
|
|
|||
|
|
@ -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 = <Editor />; break;
|
||||
case 'users': content = <Users />; break;
|
||||
case 'tokens': content = <Tokens />; break;
|
||||
case 'tokens-parody': content = <TokensParody />; break;
|
||||
case 'containers':content = <Containers />; break;
|
||||
case 'cluster': content = <Cluster />; break;
|
||||
case 'settings': content = <Settings />; 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 (
|
||||
|
|
|
|||
|
|
@ -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 <AuthGate>, which calls GET /auth/me. On 401 it then
|
||||
// calls GET /auth/setup-required and renders <SetupScreen> or <LoginScreen>
|
||||
// (defined in screens-auth.jsx, Task 16). On 200 it renders the real <App>.
|
||||
//
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<div className="sdi-port-mini">
|
||||
{groups.map(group => (
|
||||
<div key={group.nodeId} style={{ marginBottom: groups.length > 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 */}
|
||||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-3)', textTransform: 'uppercase', padding: '0 0 6px' }}>
|
||||
{group.model ? group.model.toUpperCase() + ' · ' : ''}{group.hostname}
|
||||
</div>
|
||||
|
|
@ -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 }) {
|
|||
<label className="field-label">Source type</label>
|
||||
<div className="source-type-grid">
|
||||
{[
|
||||
{ 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 }) {
|
|||
<span key={tag} className="mono" style={{ background: 'var(--bg-3)', borderRadius: 4, padding: '2px 8px', fontSize: 12 }}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>Fixed proxy profile — not configurable.</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>Fixed proxy profile. Not configurable.</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
|
||||
{u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '—'}
|
||||
{u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '·'}
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button className="icon-btn" aria-label="User actions" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === u.id ? null : u.id); }}>
|
||||
|
|
@ -329,7 +329,7 @@ function PasswordResetModal({ user, onClose, onSaved }) {
|
|||
const [err, setErr] = React.useState(null);
|
||||
const [done, setDone] = React.useState(false);
|
||||
|
||||
// #111 — guard async resolution / delayed onSaved against unmount.
|
||||
// #111 - guard async resolution / delayed onSaved against unmount.
|
||||
const mountedRef = React.useRef(true);
|
||||
const savedTimerRef = React.useRef(null);
|
||||
React.useEffect(() => () => {
|
||||
|
|
@ -481,7 +481,7 @@ function GroupsPanel({ groups, users, onChange }) {
|
|||
<div className="panel">
|
||||
{groups.length === 0 && !creating && (
|
||||
<div style={{ padding: '32px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
|
||||
No groups yet — click <em>New group</em> above to create one.
|
||||
No groups yet: click <em>New group</em> above to create one.
|
||||
</div>
|
||||
)}
|
||||
{groups.map(g => {
|
||||
|
|
@ -521,8 +521,8 @@ function GroupsPanel({ groups, users, onChange }) {
|
|||
<select className="field-input" defaultValue=""
|
||||
onChange={e => { addMember(g, e.target.value); e.target.value = ''; }}
|
||||
style={{ width: 200, padding: '4px 8px', fontSize: 12, appearance: 'auto' }}>
|
||||
<option value="" disabled>— Pick a user —</option>
|
||||
{nonMembers.map(u => <option key={u.id} value={u.id}>@{u.username} — {u.name}</option>)}
|
||||
<option value="" disabled>Pick a user…</option>
|
||||
{nonMembers.map(u => <option key={u.id} value={u.id}>@{u.username}: {u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -536,7 +536,28 @@ function GroupsPanel({ groups, users, onChange }) {
|
|||
);
|
||||
}
|
||||
|
||||
// Real Tokens admin page: wraps ApiTokensSection (defined further down) in a
|
||||
// .page shell so it can be a top-level admin nav destination. The old parody
|
||||
// page lives below as TokensParody and is still reachable via the hidden
|
||||
// `tokens-parody` route for posterity.
|
||||
function Tokens() {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Tokens</h1>
|
||||
<span className="subtitle">API tokens for the Premiere panel, node-agents, and external integrations</span>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<ApiTokensSection />
|
||||
<div style={{ marginTop: 16, fontSize: 11.5, color: 'var(--text-3)' }}>
|
||||
Looking for the old satirical pricing page? <a href="#tokens-parody" onClick={(e) => { e.preventDefault(); window.dispatchEvent(new CustomEvent('df:nav', { detail: 'tokens-parody' })); }} style={{ color: 'var(--accent-text)' }}>It's still here.</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokensParody() {
|
||||
const [burned, setBurned] = React.useState(14340);
|
||||
const [rate, setRate] = React.useState(2.4);
|
||||
const [showCalc, setShowCalc] = React.useState(false);
|
||||
|
|
@ -582,7 +603,7 @@ function Tokens() {
|
|||
}, []);
|
||||
|
||||
const tiers = [
|
||||
{ name: "Starter", desc: "For \"evaluation only\" — definitely not production", price: "$2,400", per: "/ month", tokens: "100k tokens", popular: false, color: "#6B7280" },
|
||||
{ name: "Starter", desc: "For \"evaluation only\": definitely not production", price: "$2,400", per: "/ month", tokens: "100k tokens", popular: false, color: "#6B7280" },
|
||||
{ name: "Broadcast", desc: "Most teams. Most pain.", price: "$28,000", per: "/ month", tokens: "1.5M tokens", popular: true, color: "#5B7CFA" },
|
||||
{ name: "Enterprise", desc: "If you have to ask, you can't afford it (but you'll ask anyway)", price: "$call us", per: "and bring lawyers", tokens: "∞ tokens", popular: false, color: "#B57CFA" },
|
||||
];
|
||||
|
|
@ -643,7 +664,7 @@ function Tokens() {
|
|||
</div>
|
||||
|
||||
<div className="token-comparison">
|
||||
<div className="token-card-label" style={{ padding: "16px 16px 0" }}>HOURLY BURN — DRAGONFLIGHT vs. THE OTHER GUYS</div>
|
||||
<div className="token-card-label" style={{ padding: "16px 16px 0" }}>HOURLY BURN: DRAGONFLIGHT vs. THE OTHER GUYS</div>
|
||||
<div className="token-compare-chart">
|
||||
<ChartLine
|
||||
series={[
|
||||
|
|
@ -699,7 +720,7 @@ function Tokens() {
|
|||
<div>
|
||||
<strong>Disclaimer:</strong> 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.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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 }) {
|
|||
<div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<Icon name="video" size={11} />
|
||||
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)'}
|
||||
</div>
|
||||
{sel.bmdPorts.length === 0 && (
|
||||
<div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No DeckLink cards detected on this node</div>
|
||||
|
|
@ -1021,7 +1042,7 @@ function BmdCardPanel({ sel, portSignals }) {
|
|||
const { label, color } = _signalChip(sig);
|
||||
const isReceiving = sig === 'receiving';
|
||||
return (
|
||||
<div key={p.index} title={sigEntry ? `${sigEntry.recorder_name || 'recorder'} — ${label}` : label}
|
||||
<div key={p.index} title={sigEntry ? `${sigEntry.recorder_name || 'recorder'}: ${label}` : label}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 5,
|
||||
fontSize: 10.5, fontFamily: "var(--font-mono)",
|
||||
|
|
@ -1070,7 +1091,7 @@ function _signalChip(sig) {
|
|||
case 'error': return { label: 'ERROR', color: 'var(--danger)' };
|
||||
case 'idle': return { label: 'IDLE', color: 'var(--text-3)' };
|
||||
case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)' };
|
||||
default: return { label: sig || '—', color: 'var(--text-4)' };
|
||||
default: return { label: sig || '·', color: 'var(--text-4)' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1174,7 +1195,7 @@ function Cluster() {
|
|||
});
|
||||
|
||||
const removeNode = (node) => {
|
||||
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() {
|
|||
<div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<Icon name="gpu" size={11} />
|
||||
GPUs {sel.gpuCount > 0 ? `(${sel.gpuCount})` : '— none reported'}
|
||||
GPUs {sel.gpuCount > 0 ? `(${sel.gpuCount})` : '(none reported)'}
|
||||
</div>
|
||||
{sel.gpus.length === 0 && (
|
||||
<div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No GPUs detected on this node</div>
|
||||
|
|
@ -1504,7 +1525,7 @@ function ApiTokensSection() {
|
|||
{justCreated && (
|
||||
<div style={{ marginBottom: 12, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)', marginBottom: 6 }}>
|
||||
Save this token now — it will not be shown again
|
||||
Save this token now: it will not be shown again
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-1)', wordBreak: 'break-all', marginBottom: 6 }}>{justCreated.token}</div>
|
||||
<button className="btn sm" onClick={() => navigator.clipboard.writeText(justCreated.token)}>Copy</button>
|
||||
|
|
@ -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() {
|
|||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
|
||||
<span>Container</span><span className="mono">{g.container_path || '—'}</span>
|
||||
<span>Host</span><span className="mono">{g.host_path || '—'}</span>
|
||||
<span>SMB</span><span className="mono">{g.smb_url || '—'}</span>
|
||||
<span>Container</span><span className="mono">{g.container_path || '·'}</span>
|
||||
<span>Host</span><span className="mono">{g.host_path || '·'}</span>
|
||||
<span>SMB</span><span className="mono">{g.smb_url || '·'}</span>
|
||||
<span>Promote idle</span><span className="mono">{g.promote_after_seconds}s</span>
|
||||
{g.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{g.error}</span></>}
|
||||
</div>
|
||||
|
|
@ -1700,8 +1721,8 @@ function MountHealthStrip() {
|
|||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
|
||||
<span>Endpoint</span><span className="mono">{s.endpoint || '(AWS default)'}</span>
|
||||
<span>Bucket</span><span className="mono">{s.bucket || '—'}</span>
|
||||
<span>Region</span><span className="mono">{s.region || '—'}</span>
|
||||
<span>Bucket</span><span className="mono">{s.bucket || '·'}</span>
|
||||
<span>Region</span><span className="mono">{s.region || '·'}</span>
|
||||
{s.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{s.error}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1767,7 +1788,7 @@ function S3SettingsCard() {
|
|||
<SField label="Bucket"><input className="field-input mono" required value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" /></SField>
|
||||
</div>
|
||||
<SField label="Access key ID"><input className="field-input mono" required value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" autoComplete="off" /></SField>
|
||||
<SField label="Secret access key"><input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} autoComplete="new-password" /></SField>
|
||||
<SField label="Secret access key"><input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved: type to replace)' : 'Secret key'} autoComplete="new-password" /></SField>
|
||||
<SettingsMsg msg={msg} />
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||
<button type="submit" className="btn primary sm" disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button>
|
||||
|
|
@ -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() {
|
|||
<SField label="Hardware acceleration">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
|
||||
<input type="checkbox" checked={gpuEnabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} />
|
||||
<span style={{ color: 'var(--text-2)' }}>Use GPU encoders (NVENC / VAAPI) when available — falls back to CPU on missing hardware</span>
|
||||
<span style={{ color: 'var(--text-2)' }}>Use GPU encoders (NVENC / VAAPI) when available: falls back to CPU on missing hardware</span>
|
||||
</label>
|
||||
</SField>
|
||||
|
||||
|
|
@ -1843,9 +1864,9 @@ function GpuSettingsCard() {
|
|||
</SField>
|
||||
<SField label="Rate control">
|
||||
<select className="field-input" value={cfg.gpu_rc_mode || 'cbr'} onChange={e => set('gpu_rc_mode', e.target.value)} style={{ appearance: 'auto' }}>
|
||||
<option value="cbr">CBR — constant bitrate</option>
|
||||
<option value="vbr">VBR — variable bitrate</option>
|
||||
<option value="cqp">CQP / CRF — constant quality</option>
|
||||
<option value="cbr">CBR: constant bitrate</option>
|
||||
<option value="vbr">VBR: variable bitrate</option>
|
||||
<option value="cqp">CQP / CRF: constant quality</option>
|
||||
</select>
|
||||
</SField>
|
||||
</div>
|
||||
|
|
@ -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 (
|
||||
<SettingsCard icon="video" title="Capture SDKs" sub="Vendor SDKs are licensed — upload them here so the capture container can build with hardware support"
|
||||
<SettingsCard icon="video" title="Capture SDKs" sub="Vendor SDKs are licensed: upload them here so the capture container can build with hardware support"
|
||||
tag={<span className="badge neutral">{SDK_VENDORS.length} vendors</span>}>
|
||||
|
||||
{/* ── 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() {
|
|||
<input className="field-input mono" type="url" required value={cfg.ampp_base_url} onChange={e => setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" />
|
||||
</SField>
|
||||
<SField label="API token">
|
||||
<input className="field-input mono" type="password" value={cfg.ampp_token} onChange={e => setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved — type to replace)' : 'AMPP API token'} autoComplete="new-password" />
|
||||
<input className="field-input mono" type="password" value={cfg.ampp_token} onChange={e => setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved: type to replace)' : 'AMPP API token'} autoComplete="new-password" />
|
||||
</SField>
|
||||
<SettingsMsg msg={msg} />
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<span className="badge live">LIVE · REC</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 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') && (
|
||||
<div style={{ position: "absolute", top: 12, right: 12, display: "flex", gap: 6, alignItems: "center" }}>
|
||||
<span className={'badge ' + (playerState === 'error' ? 'danger' : playerState === 'stalled' ? 'warning' : 'neutral')}>
|
||||
|
|
@ -630,7 +630,7 @@ function PlaybackBar({ current, total, onSeek, comments, buffered }) {
|
|||
const bufferedRanges = Array.isArray(buffered) ? buffered : [];
|
||||
return (
|
||||
<div className="playback-bar" ref={ref} onClick={handle}>
|
||||
{/* 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
|
|||
<FileRow
|
||||
label="Filmstrip"
|
||||
present={hasFilmstrip}
|
||||
path={hasFilmstrip ? (asset.filmstrip_s3_key || null) : filmstripLoading ? 'Fetching…' : 'Not generated yet — right-click filmstrip or click Re-generate'}
|
||||
path={hasFilmstrip ? (asset.filmstrip_s3_key || null) : filmstripLoading ? 'Fetching…' : 'Not generated yet: right-click filmstrip or click Re-generate'}
|
||||
icon="editor"
|
||||
actionLabel="Re-generate"
|
||||
onAction={onRegenFilmstrip}
|
||||
|
|
@ -929,21 +929,21 @@ function FilesTab({ asset, filmFrames, filmstripLoading, streamUrl, reprocessing
|
|||
function MetadataTab({ asset }) {
|
||||
var rows = [
|
||||
{ k: "Filename", v: asset.name },
|
||||
{ k: "Duration", v: asset.duration || '—' },
|
||||
{ k: "Resolution", v: asset.res || '—' },
|
||||
{ k: "Codec", v: asset.codec || '—' },
|
||||
{ k: "File size", v: asset.size || '—' },
|
||||
{ k: "Status", v: asset.status || '—' },
|
||||
{ k: "Updated", v: asset.updated || '—' },
|
||||
{ k: "Project", v: asset.project || '—' },
|
||||
{ k: "Duration", v: asset.duration || '·' },
|
||||
{ k: "Resolution", v: asset.res || '·' },
|
||||
{ k: "Codec", v: asset.codec || '·' },
|
||||
{ k: "File size", v: asset.size || '·' },
|
||||
{ k: "Status", v: asset.status || '·' },
|
||||
{ k: "Updated", v: asset.updated || '·' },
|
||||
{ k: "Project", v: asset.project || '·' },
|
||||
];
|
||||
var audioMeta = asset.audio_metadata;
|
||||
if (audioMeta && Array.isArray(audioMeta) && audioMeta.length > 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 ? <span className="badge neutral" style={{ marginLeft: 6 }}>{tr.language}</span> : 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 (
|
||||
<div key={i} className={'audio-track' + (isAudible ? '' : ' muted')}>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Screen>
|
||||
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 14 }}>
|
||||
First-run setup — create the first admin
|
||||
First-run setup: create the first admin
|
||||
</div>
|
||||
<ErrorRow text={error} />
|
||||
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="editor-shell" style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
|
||||
{/* ── COMING SOON bumper — overlays the entire editor ── */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 100,
|
||||
background: 'rgba(10, 12, 18, 0.92)',
|
||||
backdropFilter: 'blur(6px)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
gap: 20, pointerEvents: 'all',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 64, height: 64,
|
||||
background: 'linear-gradient(135deg, var(--accent), hsl(250 80% 65%))',
|
||||
borderRadius: 16,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 0 40px rgba(91, 124, 250, 0.4)',
|
||||
}}>
|
||||
<Icon name="editor" size={30} />
|
||||
{/* Beta banner: flat strip across top, no glassmorphism or gradient. */}
|
||||
<div className="editor-beta-banner">
|
||||
<Icon name="editor" size={14} />
|
||||
<div className="editor-beta-banner-body">
|
||||
<strong>NLE editor is in beta.</strong>
|
||||
<span> Use the Premiere Pro panel for frame-accurate editing and growing-file workflows.</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', maxWidth: 420 }}>
|
||||
<div style={{ fontSize: 26, fontWeight: 700, letterSpacing: '-0.02em', color: 'var(--text-primary)', marginBottom: 8 }}>
|
||||
NLE Editor — Coming Soon
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-3)', lineHeight: 1.6 }}>
|
||||
The browser-based timeline editor is under active development.
|
||||
In the meantime, use the <strong style={{ color: 'var(--text-2)' }}>Premiere Pro panel</strong> for
|
||||
frame-accurate editing and growing-file workflows — download it from
|
||||
<strong style={{ color: 'var(--text-2)' }}> Settings → Capture SDKs</strong>.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 4 }}>
|
||||
<a href={(window.PREMIERE_LATEST || {}).zxp || '#'} download style={{ textDecoration: 'none' }}>
|
||||
<button className="btn primary">Download ZXP</button>
|
||||
<div className="editor-beta-banner-actions">
|
||||
<a href={(window.PREMIERE_LATEST || {}).zxp || '#'} download className="btn primary sm">
|
||||
Download Premiere panel
|
||||
</a>
|
||||
<a href={(window.PREMIERE_LATEST || {}).installer || '#'} download style={{ textDecoration: 'none' }}>
|
||||
<button className="btn ghost">Windows Installer</button>
|
||||
<a href={(window.PREMIERE_LATEST || {}).installer || '#'} download className="btn ghost sm">
|
||||
Windows installer
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: -4 }}>
|
||||
Dragonflight Premiere Panel v{(window.PREMIERE_LATEST || {}).version || '—'}
|
||||
<span className="editor-beta-banner-version mono">
|
||||
v{(window.PREMIERE_LATEST || {}).version || '·'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="launcher">
|
||||
<div className="launcher-inner">
|
||||
|
|
@ -144,6 +157,71 @@ function Home({ navigate }) {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{hasActivity && (
|
||||
<div className="launcher-activity">
|
||||
{(failedCount > 0 || errCount > 0) && (
|
||||
<div className="launcher-activity-strip alert">
|
||||
<Icon name="alert" size={14} />
|
||||
<span>
|
||||
{errCount > 0 && <strong>{errCount} recorder{errCount === 1 ? '' : 's'} in error.</strong>}
|
||||
{errCount > 0 && failedCount > 0 && ' '}
|
||||
{failedCount > 0 && <strong>{failedCount} failed job{failedCount === 1 ? '' : 's'}.</strong>}
|
||||
</span>
|
||||
<button className="btn ghost sm" onClick={() => navigate('dashboard')}>Open Dashboard</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{liveRecorders.length > 0 && (
|
||||
<div className="launcher-activity-section">
|
||||
<div className="launcher-activity-head">
|
||||
<span className="rec-dot" />
|
||||
Recording now
|
||||
<span className="muted">{liveRecorders.length} live</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn ghost sm" onClick={() => navigate('monitors')}>Monitors</button>
|
||||
</div>
|
||||
<div className="launcher-activity-grid">
|
||||
{liveRecorders.map(r => (
|
||||
<button key={r.id} className="launcher-activity-item" onClick={() => navigate('recorders')}>
|
||||
<span className="badge live">REC</span>
|
||||
<span className="launcher-activity-item-name">{r.name}</span>
|
||||
<span className="launcher-activity-item-meta mono">{r.source_type || 'sdi'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recentAssets.length > 0 && (
|
||||
<div className="launcher-activity-section">
|
||||
<div className="launcher-activity-head">
|
||||
<Icon name="library" size={12} />
|
||||
Last 24 hours
|
||||
<span className="muted">{recentAssets.length} new asset{recentAssets.length === 1 ? '' : 's'}</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn ghost sm" onClick={() => navigate('library')}>Library</button>
|
||||
</div>
|
||||
<div className="launcher-activity-grid">
|
||||
{recentAssets.map(a => (
|
||||
<button key={a.id} className="launcher-activity-item" onClick={() => navigate('library')}>
|
||||
<Icon name={a.media_type === 'audio' ? 'audio' : 'video'} size={13} />
|
||||
<span className="launcher-activity-item-name">{a.display_name || a.filename || 'untitled'}</span>
|
||||
<span className="launcher-activity-item-meta mono">
|
||||
{(() => {
|
||||
const mins = Math.round((Date.now() - new Date(a.created_at)) / 60000);
|
||||
if (mins < 60) return mins + 'm';
|
||||
const h = Math.round(mins / 60);
|
||||
return h + 'h';
|
||||
})()}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="launcher-status">
|
||||
<span className="launcher-status-pip">
|
||||
<span
|
||||
|
|
@ -166,13 +244,13 @@ function Home({ navigate }) {
|
|||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Dashboard — broadcast-ops control board
|
||||
// Dashboard - broadcast-ops control board
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Dashboard({ navigate }) {
|
||||
const { RECORDERS, JOBS, NODES } = window.ZAMPP_DATA;
|
||||
|
||||
// Live state — recompute every second so elapsed timers keep ticking.
|
||||
// Live state - recompute every second so elapsed timers keep ticking.
|
||||
const [tick, setTick] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
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 (
|
||||
<div className="page dash">
|
||||
<div className="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
<span className="subtitle">Live operations: on-air recorders, jobs, cluster health</span>
|
||||
<div className="spacer" />
|
||||
{hasAttention && (
|
||||
<span className="badge danger" title="Items need attention">
|
||||
<Icon name="alert" size={10} />
|
||||
{failedJobs.length + offlineNodes.length + erroredRecorders.length} alert{failedJobs.length + offlineNodes.length + erroredRecorders.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
<span className="status-pip">
|
||||
<span className="dot" style={{ background: offlineNodes.length === 0 ? 'var(--success)' : 'var(--warning)' }} />
|
||||
<span>{onlineNodes}/{NODES.length || 0} nodes online</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ────────── ON AIR ────────── */}
|
||||
<section className="dash-section">
|
||||
<DashSectionHead
|
||||
|
|
@ -325,7 +419,7 @@ function Dashboard({ navigate }) {
|
|||
level="danger"
|
||||
icon="alert"
|
||||
title={j.kind + ' failed'}
|
||||
detail={(j.asset || '—') + (j.error ? ' · ' + j.error.slice(0, 100) : '')}
|
||||
detail={(j.asset || '·') + (j.error ? ' · ' + j.error.slice(0, 100) : '')}
|
||||
onClick={() => navigate('jobs')}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -491,14 +585,14 @@ function OnAirTile({ recorder, onClick }) {
|
|||
<div className="dash-onair-meta">
|
||||
<div className="dash-onair-name">{recorder.name}</div>
|
||||
<div className="dash-onair-sub">
|
||||
<span className="dash-onair-source">{recorder.source || '—'}</span>
|
||||
{recorder.res && recorder.res !== '—' && (
|
||||
<span className="dash-onair-source">{recorder.source || '·'}</span>
|
||||
{recorder.res && recorder.res !== '·' && (
|
||||
<>
|
||||
<span className="dash-onair-dot">·</span>
|
||||
<span className="dash-onair-res">{recorder.res}</span>
|
||||
</>
|
||||
)}
|
||||
{recorder.codec && recorder.codec !== '—' && (
|
||||
{recorder.codec && recorder.codec !== '·' && (
|
||||
<>
|
||||
<span className="dash-onair-dot">·</span>
|
||||
<span className="dash-onair-codec">{recorder.codec}</span>
|
||||
|
|
@ -620,7 +714,7 @@ function DashClusterRow({ node }) {
|
|||
</span>
|
||||
<span className="dash-cluster-val">{Math.round(cpuPct)}%</span>
|
||||
</>
|
||||
) : <span className="dash-cluster-val muted">—</span>}
|
||||
) : <span className="dash-cluster-val muted">·</span>}
|
||||
</span>
|
||||
<span className="dash-cluster-metric">
|
||||
{memPct != null ? (
|
||||
|
|
@ -636,7 +730,7 @@ function DashClusterRow({ node }) {
|
|||
</span>
|
||||
<span className="dash-cluster-val">{memUsed < 1 ? Math.round(memUsed * 1024) + 'M' : memUsed.toFixed(1) + 'G'}</span>
|
||||
</>
|
||||
) : <span className="dash-cluster-val muted">—</span>}
|
||||
) : <span className="dash-cluster-val muted">·</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Upload</h1>
|
||||
<span className="subtitle">Drop video, audio, or stills — we proxy and index automatically.</span>
|
||||
<span className="subtitle">Drop video, audio, or stills: we proxy and index automatically.</span>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}>
|
||||
|
|
@ -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 }) {
|
|||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>YouTube</h1>
|
||||
<span className="subtitle">Paste a link — we download and import the best available MP4.</span>
|
||||
<span className="subtitle">Paste a link: we download and import the best available MP4.</span>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
title={sigEntry ? `${sigEntry.recorder_name || 'recorder'} — ${label}` : label}
|
||||
title={sigEntry ? `${sigEntry.recorder_name || 'recorder'}: ${label}` : label}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 12px', borderRadius: 5,
|
||||
|
|
@ -1074,7 +1074,7 @@ function MonitorTile({ feed, seed }) {
|
|||
)}
|
||||
<div className="monitor-tile-label">
|
||||
<span className="name">{feed.name}</span>
|
||||
{feed.elapsed && feed.elapsed !== '—' && <span className="time mono">{feed.elapsed}</span>}
|
||||
{feed.elapsed && feed.elapsed !== '·' && <span className="time mono">{feed.elapsed}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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 }) {
|
|||
<span className={'epg-gutter-status ' + (isLive ? 'live' : isErr ? 'err' : 'idle')} />
|
||||
<div className="epg-gutter-meta">
|
||||
<div className="epg-gutter-name">{r.name}</div>
|
||||
<div className="epg-gutter-sub mono">{(r.source_type || '—').toUpperCase()}{color ? ' · ' : ''}{color && <span className="epg-gutter-dot" style={{ background: color }} />}</div>
|
||||
<div className="epg-gutter-sub mono">{(r.source_type || '·').toUpperCase()}{color ? ' · ' : ''}{color && <span className="epg-gutter-dot" style={{ background: color }} />}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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 }) {
|
|||
<label className="field-label">Recorder</label>
|
||||
<input className="field-input mono" value={schedule.recorder_name || schedule.recorder_id} readOnly
|
||||
style={{ color: 'var(--text-3)' }} />
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>Recorder can't be reassigned — delete + recreate to change.</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>Recorder can't be reassigned: delete + recreate to change.</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div className="field">
|
||||
|
|
@ -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
|
|||
<select className="field-input" value={form.recorder_id}
|
||||
onChange={e => set('recorder_id', e.target.value)}
|
||||
style={{ appearance: 'auto' }}>
|
||||
{recorders.length === 0 && <option value="">— No recorders defined —</option>}
|
||||
{recorders.length === 0 && <option value="">No recorders defined</option>}
|
||||
{recorders.map(r => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name} · {r.source_type?.toUpperCase() || '?'}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// screens-jobs.jsx
|
||||
|
||||
// Pick the most-meaningful timestamp + label for a job's current state.
|
||||
// Returns { label, iso } — caller renders "<label> <relative-time>" with
|
||||
// Returns { label, iso } - caller renders "<label> <relative-time>" with
|
||||
// the full ISO as a tooltip.
|
||||
function _jobTimeFor(job) {
|
||||
if (job.status === 'done' && job.completed_at) return { label: 'done', iso: job.completed_at };
|
||||
|
|
@ -21,7 +21,7 @@ function _fmtAbsolute(iso) {
|
|||
} catch { return iso; }
|
||||
}
|
||||
|
||||
// Compact clock for the inline jobs cell — "2:23 PM" if today,
|
||||
// Compact clock for the inline jobs cell - "2:23 PM" if today,
|
||||
// "May 22 · 2:23 PM" if a different day. Full datetime stays in the tooltip.
|
||||
function _fmtCompact(iso) {
|
||||
if (!iso) return '';
|
||||
|
|
@ -51,9 +51,9 @@ function Jobs({ navigate }) {
|
|||
...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,
|
||||
|
|
@ -83,7 +83,7 @@ function Jobs({ navigate }) {
|
|||
}, [refresh]);
|
||||
|
||||
// One handler covers cancel (running) AND delete (queued / done / failed).
|
||||
// BullMQ's job.remove() — what the API calls — works on any state, so a
|
||||
// BullMQ's job.remove() - what the API calls - works on any state, so a
|
||||
// stalled-active job (worker died mid-process, holding a concurrency slot)
|
||||
// gets yanked and the next queued job runs. mode just changes the prompt
|
||||
// copy so the operator knows what they're doing.
|
||||
|
|
@ -98,7 +98,7 @@ function Jobs({ navigate }) {
|
|||
}, []);
|
||||
|
||||
// Retry every failed job at once. Useful after a transient infra issue
|
||||
// (S3 outage, hung worker) — one click per job is painful with 20+ failures.
|
||||
// (S3 outage, hung worker) - one click per job is painful with 20+ failures.
|
||||
const handleRetryAll = React.useCallback(() => {
|
||||
const failedJobs = jobs.filter(j => j.status === 'failed');
|
||||
if (failedJobs.length === 0) return;
|
||||
|
|
@ -148,18 +148,18 @@ function Jobs({ navigate }) {
|
|||
<div className="stat-card">
|
||||
<div className="label">Failed</div>
|
||||
<div className="value">{counts.failed}</div>
|
||||
<div className="delta" style={{ color: counts.failed > 0 ? 'var(--warning)' : '' }}>
|
||||
<div className={'delta' + (counts.failed > 0 ? ' delta-warn' : '')}>
|
||||
{counts.failed > 0 ? 'Needs attention' : 'All clear'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="label">Total jobs</div>
|
||||
<div className="value">{counts.all}</div>
|
||||
<div className="delta muted" style={{ fontSize: 10.5 }}>Updated {Math.round((Date.now()-lastFetch)/1000)}s ago</div>
|
||||
<div className="delta muted delta-tiny">Updated {Math.round((Date.now()-lastFetch)/1000)}s ago</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-group" style={{ marginTop: 20, width: 'fit-content' }}>
|
||||
<div className="tab-group jobs-tabs">
|
||||
{[
|
||||
{ id: 'all', label: 'All · ' + counts.all },
|
||||
{ id: 'running', label: 'Running · ' + counts.running },
|
||||
|
|
@ -171,12 +171,12 @@ function Jobs({ navigate }) {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<div className="panel" style={{ marginTop: 12 }}>
|
||||
<div className="panel jobs-panel">
|
||||
<div className="job-row head">
|
||||
<div></div><div>Job</div><div>Asset</div><div>Node</div><div>Progress</div><div>Time</div><div>Priority</div><div></div>
|
||||
</div>
|
||||
{filtered.length === 0
|
||||
? <div style={{ padding: '24px', textAlign: 'center', color: 'var(--text-3)' }}>No jobs in this category.</div>
|
||||
? <div className="jobs-empty">No jobs in this category.</div>
|
||||
: filtered.map(j => <JobRow key={j.id} job={j} onRetry={handleRetry} onDelete={handleDelete} />)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -189,36 +189,35 @@ function JobRow({ job, onRetry, onDelete }) {
|
|||
return (
|
||||
<div className="job-row">
|
||||
<div><StatusDot status={job.status} /></div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Icon name={iconMap[job.kind] || 'jobs'} size={13} style={{ color: 'var(--text-3)' }} />
|
||||
<span style={{ fontWeight: 500 }}>{job.kind}</span>
|
||||
<div className="job-row-kind">
|
||||
<Icon name={iconMap[job.kind] || 'jobs'} size={13} className="job-row-kind-icon" />
|
||||
<span className="job-row-kind-name">{job.kind}</span>
|
||||
</div>
|
||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-2)' }}>{job.asset}</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{job.node}</div>
|
||||
<div className="job-row-asset">{job.asset}</div>
|
||||
<div className="mono job-row-node">{job.node}</div>
|
||||
<div>
|
||||
{job.status === 'running' && (
|
||||
<div className="job-progress-wrap">
|
||||
<div className="job-progress-bar"><div className="job-progress-fill" style={{ width: job.progress + '%' }} /></div>
|
||||
<span className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', minWidth: 32, textAlign: 'right' }}>{Math.round(job.progress)}%</span>
|
||||
<span className="mono job-row-progress-pct">{Math.round(job.progress)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{job.status === 'done' && <span className="badge success" style={{ background: 'transparent', padding: 0 }}><Icon name="check" size={12} /> Complete</span>}
|
||||
{job.status === 'queued' && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>Waiting…</span>}
|
||||
{job.status === 'done' && <span className="badge success job-row-status-done"><Icon name="check" size={12} /> Complete</span>}
|
||||
{job.status === 'queued' && <span className="job-row-status-queued">Waiting…</span>}
|
||||
{job.status === 'failed' && (
|
||||
<span title={job.error || 'Failed'}
|
||||
style={{ fontSize: 12, color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
||||
<span title={job.error || 'Failed'} className="job-row-status-failed">
|
||||
<Icon name="alert" size={12} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
||||
<span className="job-row-status-failed-msg">
|
||||
{(job.error || 'Failed').slice(0, 120)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
<div className="mono job-row-time"
|
||||
title={(() => { const t = _jobTimeFor(job); return t ? t.label + ' at ' + _fmtAbsolute(t.iso) : ''; })()}>
|
||||
{(() => {
|
||||
const t = _jobTimeFor(job);
|
||||
if (!t) return '—';
|
||||
if (!t) return '·';
|
||||
// Terminal states (done/failed) anchor on the absolute clock so the
|
||||
// operator can correlate with logs; queued/running show relative
|
||||
// since it's a moving target.
|
||||
|
|
@ -229,16 +228,16 @@ function JobRow({ job, onRetry, onDelete }) {
|
|||
})()}
|
||||
</div>
|
||||
<div><span className={'badge ' + (job.priority === 'high' ? 'warning' : 'outline')}>{job.priority}</span></div>
|
||||
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
|
||||
<div className="job-row-actions">
|
||||
{job.status === 'failed' && (
|
||||
<button className="btn ghost sm" onClick={() => onRetry(job)}><Icon name="refresh" />Retry</button>
|
||||
)}
|
||||
{job.status === 'running' && (
|
||||
/* Cancel a stalled-active job — frees the BullMQ concurrency slot
|
||||
/* Cancel a stalled-active job: frees the BullMQ concurrency slot
|
||||
so anything queued behind it can run. The worker may finish in
|
||||
the background but its result is discarded. */
|
||||
<button className="btn ghost sm" onClick={() => onDelete(job, 'cancel')}
|
||||
style={{ color: 'var(--danger)' }} title="Cancel this running job and free its queue slot">
|
||||
<button className="btn ghost sm job-row-cancel" onClick={() => onDelete(job, 'cancel')}
|
||||
title="Cancel this running job and free its queue slot">
|
||||
<Icon name="x" />Cancel
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
|||
const [search, setSearch] = React.useState(window._dfPendingSearch || '');
|
||||
React.useEffect(() => { delete window._dfPendingSearch; }, []);
|
||||
// Local state lets us re-render after delete / move-to-bin without forcing
|
||||
// a full app reload — keeps ZAMPP_DATA in sync as the cache of record.
|
||||
// a full app reload - keeps ZAMPP_DATA in sync as the cache of record.
|
||||
const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []);
|
||||
const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y }
|
||||
const [renamingAsset, setRenamingAsset] = React.useState(null);
|
||||
|
|
@ -65,7 +65,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
|||
runDownload(asset);
|
||||
return;
|
||||
}
|
||||
} catch (_) { /* localStorage unavailable — show modal to be safe */ }
|
||||
} catch (_) { /* localStorage unavailable: show modal to be safe */ }
|
||||
setPendingDownload(asset);
|
||||
};
|
||||
const [creatingBin, setCreatingBin] = React.useState(false);
|
||||
|
|
@ -273,7 +273,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
|||
)}
|
||||
{!creatingBin && BINS.length === 0 ? (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-3)', padding: '6px 8px', fontStyle: 'italic' }}>
|
||||
{openProject ? 'No bins yet — click + to create one.' : 'Open a project to manage bins.'}
|
||||
{openProject ? 'No bins yet: click + to create one.' : 'Open a project to manage bins.'}
|
||||
</div>
|
||||
) : BINS.map(function(b) {
|
||||
const isActive = selectedBinId === b.id;
|
||||
|
|
@ -307,7 +307,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
|||
|
||||
<div className="library-main">
|
||||
<div className="library-toolbar">
|
||||
<div className="toolbar-title">{displayTitle}</div>
|
||||
<h1 className="toolbar-title">{displayTitle}</h1>
|
||||
<span className="count">· {assets.length} assets</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div className="search" style={{ width: 220 }}>
|
||||
|
|
@ -324,8 +324,8 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
|||
})}
|
||||
</div>
|
||||
<div className="tab-group">
|
||||
<button className={view === 'grid' ? 'active' : ''} onClick={function() { setView('grid'); }}><Icon name="grid" size={12} /></button>
|
||||
<button className={view === 'list' ? 'active' : ''} onClick={function() { setView('list'); }}><Icon name="list" size={12} /></button>
|
||||
<button className={view === 'grid' ? 'active' : ''} onClick={function() { setView('grid'); }} aria-label="Grid view" title="Grid view"><Icon name="grid" size={12} /></button>
|
||||
<button className={view === 'list' ? 'active' : ''} onClick={function() { setView('list'); }} aria-label="List view" title="List view"><Icon name="list" size={12} /></button>
|
||||
</div>
|
||||
<button className="btn primary" onClick={function() { navigate('upload'); }}><Icon name="upload" />Upload</button>
|
||||
</div>
|
||||
|
|
@ -362,7 +362,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
|||
</div>
|
||||
<div className="col-sub">{a.duration}</div>
|
||||
<div className="col-sub">{a.res}</div>
|
||||
<div className="col-sub">{a.codec || '—'}</div>
|
||||
<div className="col-sub">{a.codec || '·'}</div>
|
||||
<div className="col-sub">{a.size}</div>
|
||||
<div className="col-sub">{a.updated}</div>
|
||||
<button className="icon-btn" aria-label="Asset actions" onClick={function(e) { e.stopPropagation(); openCtx(a, e); }}><Icon name="more" /></button>
|
||||
|
|
@ -447,7 +447,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
|||
// Hi-res download trigger shared by the card and the context menu. Resolves
|
||||
// a presigned S3 URL via /assets/:id/hires (returns { url, filename, ext })
|
||||
// and clicks a hidden anchor so the browser does the download. The download
|
||||
// itself is direct S3 — never proxied through mam-api — so big files don't
|
||||
// itself is direct S3 - never proxied through mam-api - so big files don't
|
||||
// touch the API container.
|
||||
function runDownload(asset) {
|
||||
if (!asset || !asset.id) return;
|
||||
|
|
@ -536,7 +536,7 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRen
|
|||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="ctx-empty">No bins — create one inside a project</div>
|
||||
<div className="ctx-empty">No bins: create one inside a project</div>
|
||||
)}
|
||||
<div className="ctx-divider" />
|
||||
<button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
|
||||
|
|
@ -606,14 +606,14 @@ function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, drag
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Status badges and duration — inside the relative wrapper so
|
||||
{/* Status badges and duration: inside the relative wrapper so
|
||||
position:absolute is anchored to the thumbnail, not the card (#52) */}
|
||||
<div className="thumb-status">
|
||||
{asset.status === 'live' && <span className="badge live">LIVE</span>}
|
||||
{asset.status === 'processing' && <span className="badge warning">Processing</span>}
|
||||
{asset.status === 'error' && <span className="badge danger">Error</span>}
|
||||
</div>
|
||||
{/* Hi-res download trigger — only shown when the asset has an
|
||||
{/* Hi-res download trigger: only shown when the asset has an
|
||||
original_s3_key (everything queued through ingest / conform).
|
||||
Hidden until card hover, lives in top-right of the thumb. */}
|
||||
{asset.original_s3_key && onDownload && (
|
||||
|
|
@ -625,7 +625,7 @@ function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, drag
|
|||
<Icon name="download" size={12} />
|
||||
</button>
|
||||
)}
|
||||
{(asset.type === 'video' || !asset.type) && asset.duration !== '—' && <div className="thumb-duration">{asset.duration}</div>}
|
||||
{(asset.type === 'video' || !asset.type) && asset.duration !== '·' && <div className="thumb-duration">{asset.duration}</div>}
|
||||
</div>
|
||||
<div className="meta">
|
||||
<div className="name">{asset.name}</div>
|
||||
|
|
|
|||
|
|
@ -100,8 +100,8 @@ function Projects({ onOpenProject, navigate }) {
|
|||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search projects…" />
|
||||
</div>
|
||||
<div className="tab-group">
|
||||
<button className={view === 'grid' ? 'active' : ''} onClick={() => setView('grid')}><Icon name="grid" size={12} /></button>
|
||||
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')}><Icon name="list" size={12} /></button>
|
||||
<button className={view === 'grid' ? 'active' : ''} onClick={() => setView('grid')} aria-label="Grid view" title="Grid view"><Icon name="grid" size={12} /></button>
|
||||
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')} aria-label="List view" title="List view"><Icon name="list" size={12} /></button>
|
||||
</div>
|
||||
<button className="btn primary" onClick={() => setShowNew(true)}><Icon name="plus" />New project</button>
|
||||
</div>
|
||||
|
|
@ -140,8 +140,8 @@ function Projects({ onOpenProject, navigate }) {
|
|||
<div>{p.name}</div>
|
||||
</div>
|
||||
<div className="col-sub">{p.assets || 0}</div>
|
||||
<div className="col-sub">—</div>
|
||||
<div className="col-sub">{p.updated || '—'}</div>
|
||||
<div className="col-sub">·</div>
|
||||
<div className="col-sub">{p.updated || '·'}</div>
|
||||
<div style={{ position: 'relative' }} onClick={e => e.stopPropagation()}>
|
||||
<button className="icon-btn" aria-label="Project actions" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === p.id ? null : p.id); }}><Icon name="more" /></button>
|
||||
{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 }) {
|
|||
<div className="project-meta">
|
||||
<span>{ofProject.length} asset{ofProject.length === 1 ? '' : 's'}</span>
|
||||
<span>·</span>
|
||||
<span>updated {project.updated || '—'}</span>
|
||||
<span>updated {project.updated || '·'}</span>
|
||||
</div>
|
||||
{ofProject.length > 0 ? (
|
||||
<div className="project-bar" title={`ready ${ready} · in-flight ${inFlight} · errored ${errored}`}>
|
||||
|
|
|
|||
|
|
@ -1,40 +1,64 @@
|
|||
// shell.jsx - app shell: sidebar nav, topbar, route container
|
||||
|
||||
const NAV_TREE = [
|
||||
// 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 = [
|
||||
{
|
||||
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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ingest", label: "Ingest", icon: "upload", group: true,
|
||||
children: [
|
||||
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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Operations",
|
||||
items: [
|
||||
{ id: "capture", label: "Capture", icon: "capture" },
|
||||
{ id: "jobs", label: "Jobs", icon: "jobs" },
|
||||
{ id: "editor", label: "Editor", icon: "editor" },
|
||||
];
|
||||
|
||||
const ADMIN_TREE = [
|
||||
{ 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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 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,7 +177,10 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
|
|||
</button>
|
||||
</div>
|
||||
<div className="sidebar-scroll">
|
||||
{navTree.map(item => (
|
||||
{sections.map((section, si) => (
|
||||
<React.Fragment key={section.label}>
|
||||
{si > 0 && <div className="nav-section-label">{section.label}</div>}
|
||||
{section.items.map(item => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
|
|
@ -191,24 +190,15 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
|
|||
toggleGroup={toggleGroup}
|
||||
/>
|
||||
))}
|
||||
<div className="nav-section-label">Admin</div>
|
||||
{ADMIN_TREE.map(item => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
active={active}
|
||||
onSelect={onNavigate}
|
||||
openGroups={openGroups}
|
||||
toggleGroup={toggleGroup}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="sidebar-footer">
|
||||
<div className="avatar">{me?.initials || '—'}</div>
|
||||
<div className="avatar">{me?.initials || '·'}</div>
|
||||
<div className="user-meta">
|
||||
<div className="user-name">{me?.name || 'Not signed in'}</div>
|
||||
<div className="user-role" title={me?.synthetic ? 'AUTH_ENABLED=false — showing the OS user running the server' : ''}>
|
||||
{me?.role || '—'}{me?.synthetic ? ' · auth off' : ''}
|
||||
<div className="user-role" title={me?.synthetic ? 'AUTH_ENABLED=false: showing the OS user running the server' : ''}>
|
||||
{me?.role || '·'}{me?.synthetic ? ' · auth off' : ''}
|
||||
</div>
|
||||
</div>
|
||||
{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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
============================================================ */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
Loading…
Reference in a new issue