dragonflight/services/web-ui/public/screens-ingest.jsx

1781 lines
75 KiB
React
Raw Normal View History

// screens-ingest.jsx — Upload, Recorders, Capture, Monitors
/* ===== Upload helpers ===== */
const _SIMPLE_MAX = 50 * 1024 * 1024; // 50 MB → simple upload
const _PART_SIZE = 10 * 1024 * 1024; // 10 MB chunks for multipart
function _xhrPost(url, formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded, e.total); };
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); }
} else {
let msg = xhr.status + ' ' + xhr.statusText;
try { const j = JSON.parse(xhr.responseText); msg = j.error || j.message || msg; } catch {}
reject(new Error(msg));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.open('POST', url);
xhr.send(formData);
});
}
async function _uploadFile(file, projectId, onProgress) {
const mime = file.type || 'application/octet-stream';
if (file.size <= _SIMPLE_MAX) {
const fd = new FormData();
fd.append('file', file, file.name);
fd.append('filename', file.name);
fd.append('projectId', projectId);
fd.append('contentType', mime);
return _xhrPost('/api/v1/upload/simple', fd,
(loaded, total) => onProgress(Math.round((loaded / total) * 100)));
}
// — Multipart —
const init = await window.ZAMPP_API.fetch('/upload/init', {
method: 'POST',
body: JSON.stringify({ filename: file.name, fileSize: file.size, contentType: mime, projectId }),
});
const { assetId, uploadId, key } = init;
const totalParts = Math.ceil(file.size / _PART_SIZE);
const parts = [];
for (let i = 0; i < totalParts; i++) {
const chunk = file.slice(i * _PART_SIZE, Math.min((i + 1) * _PART_SIZE, file.size));
const fd = new FormData();
fd.append('file', chunk, file.name);
fd.append('uploadId', uploadId);
fd.append('key', key);
fd.append('partNumber', String(i + 1));
const partRes = await _xhrPost('/api/v1/upload/part', fd,
(loaded, total) => onProgress(Math.round(((i + loaded / total) / totalParts) * 100)));
parts.push({ PartNumber: i + 1, ETag: partRes.etag || partRes.ETag });
}
await window.ZAMPP_API.fetch('/upload/complete', {
method: 'POST',
body: JSON.stringify({ uploadId, key, assetId, parts }),
});
return { id: assetId };
}
/* ===== Upload ===== */
function Upload({ navigate }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const [files, setFiles] = React.useState([]);
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const updateFile = React.useCallback((id, patch) => {
setFiles(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f));
}, []);
const startUpload = React.useCallback((entry, pid) => {
_uploadFile(entry.file, pid, (pct) => updateFile(entry.id, { progress: pct }))
.then(() => updateFile(entry.id, { status: 'done', progress: 100 }))
.catch(e => updateFile(entry.id, { status: 'error', progress: 0, error: e.message }));
}, [updateFile]);
const handleDrop = React.useCallback((e) => {
e.preventDefault();
const dropped = Array.from(e.dataTransfer ? e.dataTransfer.files : e.target.files);
const pid = projectId || PROJECTS[0]?.id || '';
const newEntries = dropped.map((f, i) => ({
id: Date.now() + i,
name: f.name,
size: window.ZAMPP_API.fmtSize(f.size),
file: f,
progress: 0,
status: 'uploading',
error: null,
}));
setFiles(prev => [...prev, ...newEntries]);
newEntries.forEach(entry => startUpload(entry, pid));
}, [projectId, startUpload]);
return (
<div className="page">
<div className="page-header">
<h1>Upload</h1>
<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 }}>
<div>
<label className="field-label">Project</label>
<select className="field-input" value={projectId} onChange={e => setProjectId(e.target.value)}
style={{ appearance: 'auto' }}>
{PROJECTS.length === 0
? <option value="">No projects</option>
: PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
</div>
<div className="dropzone"
onDrop={handleDrop}
onDragOver={e => e.preventDefault()}
onClick={() => {
const inp = document.createElement('input');
inp.type = 'file'; inp.multiple = true;
inp.onchange = handleDrop;
inp.click();
}}>
<Icon name="upload" size={32} style={{ color: 'var(--text-3)' }} />
<div style={{ fontSize: 15, fontWeight: 500 }}>Drop files here or click to browse</div>
<div className="muted" style={{ fontSize: 12.5 }}>Video, audio, and image files</div>
<div className="dropzone-formats">
{['MOV', 'MP4', 'MXF', 'ProRes', 'DNxHR', 'WAV', 'AIFF'].map(f =>
<span key={f} className="badge outline">{f}</span>)}
</div>
</div>
{files.length > 0 && (
<div style={{ marginTop: 24 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
Queue <span className="badge neutral">{files.length}</span>
<span style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={() => setFiles(f => f.filter(x => x.status === 'uploading'))}>Clear done</button>
</div>
<div className="panel">
{files.map(f => (
<div key={f.id} className="upload-row">
<Icon name={f.name.match(/\.(wav|aif|mp3|aiff)$/i) ? 'audio' : 'video'} size={16}
style={{ color: 'var(--text-3)' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ fontWeight: 500, fontSize: 12.5 }}>{f.name}</span>
<span className="muted" style={{ fontSize: 11, fontFamily: 'var(--font-mono)' }}>{f.size}</span>
</div>
<div style={{ marginTop: 6, height: 4, background: 'var(--bg-3)', borderRadius: 99, overflow: 'hidden' }}>
<div style={{
width: f.progress + '%', height: '100%',
background: f.status === 'done' ? 'var(--success)' : f.status === 'error' ? 'var(--danger)' : 'var(--accent)',
transition: 'width 200ms',
}} />
</div>
{f.status === 'error' && (
<div style={{ marginTop: 3, fontSize: 11, color: 'var(--danger)' }}>{f.error}</div>
)}
</div>
<span className="mono" style={{
fontSize: 11.5, minWidth: 60, textAlign: 'right',
color: f.status === 'done' ? 'var(--success)' : f.status === 'error' ? 'var(--danger)' : 'var(--text-3)',
}}>
{f.status === 'done' ? '✓ done'
: f.status === 'error' ? '✗ failed'
: f.progress + '%'}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
/* ===== YouTube importer ===== */
// Accept the same three URL shapes the API validates against.
const _YT_PATTERNS = [
/^https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?[^ ]*v=[A-Za-z0-9_-]{11}/i,
/^https?:\/\/youtu\.be\/[A-Za-z0-9_-]{11}/i,
/^https?:\/\/(?:www\.)?youtube\.com\/shorts\/[A-Za-z0-9_-]{11}/i,
];
function _looksLikeYouTube(s) {
return typeof s === 'string' && _YT_PATTERNS.some(re => re.test(s.trim()));
}
function YouTubeImport({ navigate }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const [url, setUrl] = React.useState('');
const [queue, setQueue] = React.useState([]); // { id, url, status, progress, title, error, assetId, jobId }
const [submitting, setSubmitting] = React.useState(false);
const valid = _looksLikeYouTube(url);
const updateRow = React.useCallback((id, patch) => {
setQueue(prev => prev.map(r => r.id === id ? { ...r, ...patch } : r));
}, []);
// Poll the asset row to pick up the title once yt-dlp resolves it, and the
// proxy job's progress so the queue row reflects the full lifecycle, not
// just the import step.
const pollRow = React.useCallback((row) => {
if (!row.assetId) return;
let stopped = false;
const tick = async () => {
if (stopped) return;
try {
const asset = await window.ZAMPP_API.fetch('/assets/' + row.assetId);
const patch = {};
if (asset.display_name && asset.display_name !== row.url) patch.title = asset.display_name;
if (asset.status === 'ready') {
patch.status = 'done';
patch.progress = 100;
} else if (asset.status === 'error') {
patch.status = 'error';
patch.error = patch.error || 'Import failed — check the Jobs screen for details.';
} else if (asset.status === 'processing') {
patch.status = 'processing';
}
if (Object.keys(patch).length) updateRow(row.id, patch);
if (asset.status === 'ready' || asset.status === 'error') return;
} catch { /* ignore */ }
setTimeout(tick, 3000);
};
tick();
return () => { stopped = true; };
}, [updateRow]);
const submit = React.useCallback(async () => {
if (!valid || !projectId || submitting) return;
setSubmitting(true);
const rowId = Date.now();
const row = {
id: rowId,
url: url.trim(),
status: 'queued',
progress: 0,
title: '',
error: null,
assetId: null,
jobId: null,
};
setQueue(prev => [row, ...prev]);
try {
const res = await window.ZAMPP_API.fetch('/imports/youtube', {
method: 'POST',
body: JSON.stringify({ url: row.url, projectId }),
});
updateRow(rowId, { assetId: res.assetId, jobId: res.jobId, status: 'downloading' });
pollRow({ ...row, assetId: res.assetId, jobId: res.jobId });
setUrl('');
} catch (e) {
updateRow(rowId, { status: 'error', error: e.message || 'Failed to start import' });
} finally {
setSubmitting(false);
}
}, [valid, projectId, submitting, url, updateRow, pollRow]);
return (
<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>
</div>
<div className="page-body">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
<div>
<label className="field-label">Project</label>
<select className="field-input" value={projectId} onChange={e => setProjectId(e.target.value)}
style={{ appearance: 'auto' }}>
{PROJECTS.length === 0
? <option value="">No projects</option>
: PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
</div>
<div className="field" style={{ marginBottom: 8 }}>
<label className="field-label">YouTube URL</label>
<div style={{ display: 'flex', gap: 8 }}>
<input
className="field-input mono"
value={url}
onChange={e => setUrl(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && valid) submit(); }}
placeholder="https://www.youtube.com/watch?v=… or https://youtu.be/…"
style={{ flex: 1 }}
autoFocus
/>
<button
className="btn primary"
onClick={submit}
disabled={!valid || !projectId || submitting}
title={!valid ? 'Paste a YouTube URL (youtube.com/watch, youtu.be, or shorts)' : ''}
>
<Icon name="download" />Import
</button>
</div>
{url && !valid && (
<div style={{ fontSize: 11.5, color: 'var(--danger)', marginTop: 4 }}>
That doesn't look like a YouTube URL.
</div>
)}
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 6 }}>
Only import videos you have rights to use. Private, age-gated, and members-only videos are not supported.
</div>
</div>
{queue.length > 0 && (
<div style={{ marginTop: 20 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
Queue <span className="badge neutral">{queue.length}</span>
<span style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={() => setQueue(q => q.filter(r => r.status !== 'done' && r.status !== 'error'))}>
Clear finished
</button>
</div>
<div className="panel">
{queue.map(r => {
const statusColor =
r.status === 'done' ? 'var(--success)' :
r.status === 'error' ? 'var(--danger)' : 'var(--text-3)';
return (
<div key={r.id} className="upload-row">
<Icon name="link" size={16} style={{ color: 'var(--text-3)' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ fontWeight: 500, fontSize: 12.5, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{r.title || r.url}
</span>
</div>
{r.title && (
<div className="muted mono" style={{ fontSize: 10.5, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={r.url}>
{r.url}
</div>
)}
<div style={{ marginTop: 6, height: 4, background: 'var(--bg-3)', borderRadius: 99, overflow: 'hidden' }}>
<div style={{
width: (r.status === 'done' ? 100 : r.progress) + '%',
height: '100%',
background: r.status === 'done' ? 'var(--success)' : r.status === 'error' ? 'var(--danger)' : 'var(--accent)',
transition: 'width 200ms',
}} />
</div>
{r.error && (
<div style={{ marginTop: 3, fontSize: 11, color: 'var(--danger)' }}>{r.error}</div>
)}
</div>
<span className="mono" style={{ fontSize: 11.5, minWidth: 88, textAlign: 'right', color: statusColor }}>
{r.status === 'done' ? '✓ done'
: r.status === 'error' ? '✗ failed'
: r.status === 'processing'? 'processing'
: r.status === 'downloading' ? 'downloading'
: 'queued'}
</span>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
}
/* ===== Live preview (HLS) ====================================
Shared by RecorderRow + MonitorTile. The capture container writes
HLS segments to /live/{assetId}/index.m3u8 (see capture-manager.js
and nginx.conf); we attach hls.js to a <video> when a recorder is
actively recording and has a live asset.
============================================================ */
function HlsPreview({ assetId, muted = true, controls = false, className }) {
const videoRef = React.useRef(null);
const [err, setErr] = React.useState(null);
React.useEffect(() => {
if (!assetId || !videoRef.current) return;
const url = '/live/' + assetId + '/index.m3u8';
const v = videoRef.current;
// Safari can play HLS natively; everything else needs hls.js.
if (v.canPlayType('application/vnd.apple.mpegurl')) {
v.src = url;
const onErr = () => setErr('playback failed');
v.addEventListener('error', onErr);
return () => v.removeEventListener('error', onErr);
}
if (!window.Hls) { setErr('hls.js missing'); return; }
const hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true });
hls.loadSource(url);
hls.attachMedia(v);
hls.on(window.Hls.Events.ERROR, (_e, data) => {
if (data.fatal) setErr(data.details || 'hls error');
});
return () => { try { hls.destroy(); } catch (_) {} };
}, [assetId]);
return (
<div className={className} style={{ position: 'relative', width: '100%', height: '100%', background: '#000', overflow: 'hidden' }}>
<video
ref={videoRef}
autoPlay
playsInline
muted={muted}
controls={controls}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
{err && (
<div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center',
color: 'var(--text-3)', fontSize: 11, background: 'rgba(0,0,0,0.5)' }}>
{err}
</div>
)}
</div>
);
}
/* ===== Recorders ===== */
function _normRecorder(r) {
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') + ':' +
String(Math.floor((s % 3600) / 60)).padStart(2, '00') + ':' +
String(s % 60).padStart(2, '0');
}
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 || '—',
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
elapsed,
bitrate: '—',
health: 100,
audio: false,
};
}
function Recorders({ navigate, onNew }) {
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
const refresh = React.useCallback(() => {
window.ZAMPP_API.fetch('/recorders')
.then(raw => {
const norm = (raw || []).map(_normRecorder);
window.ZAMPP_DATA.RECORDERS = norm;
setRecorders(norm);
})
.catch(() => {});
}, []);
React.useEffect(() => {
refresh();
const id = setInterval(refresh, 10000);
// Any screen that creates/starts/stops/deletes a recorder dispatches
// df:recorders-changed; refresh immediately instead of waiting for the tick.
const onChange = () => refresh();
window.addEventListener('df:recorders-changed', onChange);
return () => {
clearInterval(id);
window.removeEventListener('df:recorders-changed', onChange);
};
}, [refresh]);
const liveCount = recorders.filter(r => r.status === 'recording').length;
const errCount = recorders.filter(r => r.status === 'error').length;
return (
<div className="page">
<div className="page-header">
<h1>Recorders</h1>
<span className="subtitle">Live ingest from SRT, RTMP, and SDI sources</span>
<div className="spacer" />
{(liveCount > 0 || errCount > 0) && (
<div className="status-pip">
<span className="dot" />
<span>{liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}</span>
</div>
)}
<button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
<button className="btn primary" onClick={onNew}><Icon name="plus" />New recorder</button>
</div>
<div className="page-body">
{recorders.length === 0 ? (
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
No recorders configured.
<div style={{ marginTop: 12 }}><button className="btn primary" onClick={onNew}><Icon name="plus" />Add recorder</button></div>
</div>
) : (
<div className="recorders-list">
{recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={refresh} />)}
</div>
)}
</div>
</div>
);
}
function RecorderRow({ recorder: initialRecorder, onRefresh }) {
const [recorder, setRecorder] = React.useState(initialRecorder);
const [pending, setPending] = React.useState(false);
const [err, setErr] = React.useState(null);
const [liveStatus, setLiveStatus] = React.useState(null);
const [clipName, setClipName] = React.useState('');
const isRec = recorder.status === 'recording';
React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);
// Poll the status endpoint every 3s while recording for live feedback.
React.useEffect(() => {
if (!isRec) { setLiveStatus(null); return; }
const poll = () => {
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/status')
.then(s => setLiveStatus(s))
.catch(() => {});
};
poll();
const id = setInterval(poll, 3000);
return () => clearInterval(id);
}, [isRec, recorder.id]);
const displayElapsed = React.useMemo(() => {
if (liveStatus && liveStatus.duration != null) {
const d = Math.max(0, liveStatus.duration);
return String(Math.floor(d / 3600)).padStart(2, '0') + ':' +
String(Math.floor((d % 3600) / 60)).padStart(2, '0') + ':' +
String(d % 60).padStart(2, '0');
}
return recorder.elapsed;
}, [liveStatus, recorder.elapsed]);
const displaySignal = liveStatus
? (liveStatus.signal || '—')
: (isRec ? 'connecting…' : '—');
const signalColor = displaySignal === 'receiving' ? 'var(--success)'
: displaySignal === 'stopped' ? 'var(--danger)'
: 'var(--text-3)';
const toggle = () => {
if (pending) return;
const action = isRec ? 'stop' : 'start';
setPending(true);
setErr(null);
setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
// Ship the operator-typed clip name on start; stop has no body.
const body = (action === 'start' && clipName.trim())
? JSON.stringify({ clipName: clipName.trim() })
: undefined;
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST', body })
.then(() => {
setPending(false);
// Clear the input on a successful stop so the next take starts fresh.
if (action === 'stop') setClipName('');
onRefresh();
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
// Stopping a recorder flips its asset from 'live' to 'ready' on the
// server side; tell the library/dashboard to re-pull.
if (action === 'stop') {
window.dispatchEvent(new CustomEvent('df:assets-changed'));
}
})
.catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
};
const handleDelete = () => {
if (!window.confirm('Delete recorder "' + recorder.name + '"?\nThis will stop any active recording and cannot be undone.')) return;
window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'DELETE' })
.then(() => {
onRefresh();
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
window.dispatchEvent(new CustomEvent('df:assets-changed'));
})
.catch(e => setErr(e.message || 'Delete failed'));
};
return (
<div className={'recorder-row ' + recorder.status}>
<div className="recorder-preview">
{isRec && recorder.live_asset_id
? <HlsPreview assetId={recorder.live_asset_id} />
: isRec
? <LiveStrip seed={recorder.id.length * 3} count={6} />
: <div className="recorder-empty"><Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} /></div>}
</div>
<div className="recorder-info">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>{recorder.name}</span>
<span className={'badge ' + badgeForStatus(recorder.status)}>
<StatusDot status={recorder.status} /> {recorder.status.toUpperCase()}
</span>
<span className="badge outline">{recorder.source}</span>
</div>
<div className="recorder-sub mono">{recorder.url}</div>
<div className="recorder-sub">
<span>{recorder.codec}</span><span>·</span>
<span>{recorder.res}</span>
</div>
{err && <div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{err}</div>}
{liveStatus?.lastError && isRec && (
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{liveStatus.lastError}</div>
)}
</div>
<div className="recorder-stats">
<div className="recorder-stat">
<div className="stat-label">Elapsed</div>
<div className="stat-val mono">{displayElapsed}</div>
</div>
<div className="recorder-stat">
<div className="stat-label">Signal</div>
<div className="stat-val signal-val" style={{ fontSize: 11, color: signalColor }}>
<span className={'signal-dot ' + displaySignal} style={{ background: signalColor }} />
{displaySignal}
</div>
</div>
{liveStatus?.currentFps != null && (
<div className="recorder-stat">
<div className="stat-label">FPS</div>
<div className="stat-val mono">{Number(liveStatus.currentFps).toFixed(1)}</div>
</div>
)}
</div>
<div className="recorder-actions">
{!isRec && (
<input
className="field-input"
value={clipName}
onChange={e => setClipName(e.target.value)}
placeholder="Clip name (optional)"
disabled={pending}
maxLength={80}
onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
style={{ width: 180, padding: '5px 8px', fontSize: 12 }}
title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
/>
)}
{isRec
? <button className="btn danger sm" onClick={toggle} disabled={pending}>
{pending ? '…' : <><span className="rec-dot" />Stop</>}
</button>
: <button className="btn subtle sm" onClick={toggle} disabled={pending}>
{pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>}
</button>}
<button className="icon-btn" onClick={handleDelete} title="Delete recorder" style={{ color: 'var(--text-3)' }}>
<Icon name="x" />
</button>
</div>
</div>
);
}
function badgeForStatus(s) {
return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
}
/* ===== Capture ===== */
function Capture({ navigate }) {
const [devices, setDevices] = React.useState([]);
const [activeIdx, setActiveIdx] = React.useState(0);
const loadDevices = () => {
window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
.then(devs => setDevices(Array.isArray(devs) ? devs : []))
.catch(() => setDevices([]));
};
React.useEffect(() => { loadDevices(); }, []);
if (devices.length === 0) {
return (
<div className="page">
<div className="page-header">
<h1>Capture</h1>
<span className="subtitle">DeckLink SDI ingest</span>
<div className="spacer" />
<button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
No DeckLink devices found in cluster.
</div>
</div>
</div>
);
}
const active = devices[activeIdx] || devices[0];
return (
<div className="page">
<div className="page-header">
<h1>Capture</h1>
<span className="subtitle">DeckLink SDI ingest {devices.length} device{devices.length > 1 ? 's' : ''} in cluster</span>
<div className="spacer" />
<button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}>
{devices.map((d, i) => (
<button key={i} className={'btn ' + (activeIdx === i ? 'primary' : 'ghost') + ' sm'} onClick={() => setActiveIdx(i)}>
{d.model || d.device || 'DeckLink'} {d.hostname}
</button>
))}
</div>
<div className="panel" style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<StatusDot status={active.online ? 'online' : 'offline'} />
<div>
<div style={{ fontWeight: 600 }}>{active.model || active.device || 'DeckLink'}</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{active.hostname} · {active.ip_address}</div>
</div>
</div>
<div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Connect a source and click Refresh to see port status.</div>
</div>
</div>
</div>
);
}
/* ===== Monitors ===== */
function Monitors({ navigate }) {
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
const [grid, setGrid] = React.useState(4);
React.useEffect(() => {
const refresh = () => {
window.ZAMPP_API.fetch('/recorders')
.then(raw => {
const norm = (raw || []).map(_normRecorder);
window.ZAMPP_DATA.RECORDERS = norm;
setRecorders(norm);
})
.catch(() => {});
};
refresh();
const id = setInterval(refresh, 5000);
return () => clearInterval(id);
}, []);
const videoFeeds = recorders.filter(r => !r.audio);
const audioFeeds = recorders.filter(r => r.audio).map(r => ({ ...r, kind: 'audio' }));
const allFeeds = [...videoFeeds.map(r => ({ ...r, kind: 'video' })), ...audioFeeds];
const feeds = allFeeds.slice(0, grid * grid);
return (
<div className="page">
<div className="page-header">
<h1>Monitors</h1>
<span className="subtitle">Multi-cam live monitoring</span>
<div className="spacer" />
<div className="tab-group">
{[2, 3, 4].map(n => (
<button key={n} className={grid === n ? 'active' : ''} onClick={() => setGrid(n)}>{n}×{n}</button>
))}
</div>
</div>
<div className="page-body">
{feeds.length === 0 ? (
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No active feeds. Start a recorder to see live video here.</div>
) : (
<div className="monitors-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(' + grid + ', 1fr)', gap: 8 }}>
{feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}
</div>
)}
</div>
</div>
);
}
function MonitorTile({ feed, seed }) {
const [levels, setLevels] = React.useState([0.65, 0.78]);
const isLive = feed.status === 'recording';
React.useEffect(() => {
if (!isLive) return;
const id = setInterval(() => {
setLevels([0.3 + Math.random() * 0.55, 0.3 + Math.random() * 0.55]);
}, 180);
return () => clearInterval(id);
}, [isLive]);
if (feed.kind === 'audio') {
return (
<div className="monitor-tile audio">
<div style={{ flex: 1, display: 'grid', placeItems: 'center', padding: 24 }}>
<Waveform seed={seed * 7} color="var(--accent)" />
</div>
<div style={{ display: 'flex', gap: 4, padding: '0 16px 16px', justifyContent: 'center' }}>
<AudioMeter level={levels[0]} vertical />
<AudioMeter level={levels[1]} vertical />
</div>
<div className="monitor-tile-label">
<span className="badge live">LIVE</span>
<span className="name">{feed.name}</span>
</div>
</div>
);
}
return (
<div className="monitor-tile">
{isLive && feed.live_asset_id
? <HlsPreview assetId={feed.live_asset_id} />
: <FauxFrame />}
{isLive && <div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />}
<div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6 }}>
{isLive && <span className="badge live">REC</span>}
{feed.status === 'stopped' && <span className="badge neutral">IDLE</span>}
{feed.status === 'idle' && <span className="badge neutral">IDLE</span>}
{feed.status === 'error' && <span className="badge danger">ERR</span>}
</div>
{isLive && (
<div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4 }}>
<AudioMeter level={levels[0]} vertical />
<AudioMeter level={levels[1]} vertical />
</div>
)}
<div className="monitor-tile-label">
<span className="name">{feed.name}</span>
{feed.elapsed && feed.elapsed !== '—' && <span className="time mono">{feed.elapsed}</span>}
</div>
</div>
);
}
/* ===== Schedule ===== */
const _STATUS_BADGE = {
pending: { cls: 'neutral', label: 'pending' },
running: { cls: 'success', label: 'recording' },
completed: { cls: 'accent', label: 'completed' },
cancelled: { cls: 'warning', label: 'cancelled' },
failed: { cls: 'danger', label: 'failed' },
};
function _fmtWhen(iso) {
if (!iso) return '—';
const d = new Date(iso);
// Local-time, short, human; e.g. "May 22 · 7:30 PM"
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit',
});
}
function _durationMin(startISO, endISO) {
return Math.round((new Date(endISO) - new Date(startISO)) / 60000);
}
// ── EPG (timeline) helpers ───────────────────────────────────────────────────
//
// The Schedule screen is a broadcast-control-room timeline: recorders are
// rows, time is the horizontal axis. Helpers below convert between Date and
// "minutes into local day" so we can position absolute-positioned event
// blocks against a fixed --epg-pph (pixels-per-hour) CSS variable.
//
function _dayStart(d) {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function _dayEnd(d) {
const x = _dayStart(d);
x.setDate(x.getDate() + 1);
return x;
}
function _addDays(d, n) {
const x = new Date(d);
x.setDate(x.getDate() + n);
return x;
}
function _minutesIntoDay(date, dayStart) {
return Math.max(0, Math.min(24 * 60, (date - dayStart) / 60000));
}
function _eventOverlapsDay(ev, dayStart, dayEnd) {
const s = new Date(ev.start_at);
const e = new Date(ev.end_at);
return s < dayEnd && e > dayStart;
}
function _sameDay(a, b) {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
function _fmtDay(d) {
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
}
function _fmtTime(d) {
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
function _fmtHour(h) {
// 0 → "12 AM", 12 → "12 PM", 18 → "6 PM"
const ampm = h < 12 ? 'AM' : 'PM';
const hr = ((h + 11) % 12) + 1;
return hr + ' ' + ampm;
}
function _fmtCountdown(ms) {
if (ms <= 0) return 'now';
const s = Math.floor(ms / 1000);
if (s < 60) return 'in ' + s + 's';
const m = Math.floor(s / 60);
if (m < 60) return 'in ' + m + 'm';
const h = Math.floor(m / 60);
return 'in ' + h + 'h ' + (m % 60) + 'm';
}
function _fmtElapsed(ms) {
if (ms < 0) ms = 0;
const s = Math.floor(ms / 1000);
return String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
String(Math.floor((s % 3600) / 60)).padStart(2, '0') + ':' +
String(s % 60).padStart(2, '0');
}
// Pick a stable color for a project_id given the global PROJECTS list.
function _projectColor(projectId, projects) {
if (!projectId) return null;
const p = (projects || []).find(p => p.id === projectId);
return p?.color || null;
}
// ── EPG components ───────────────────────────────────────────────────────────
function _StatusStrip({ schedules, recorders, now, projects }) {
// What's recording: any schedule whose window contains `now` AND whose
// status is 'running'. (Manually-started recorders without a schedule
// surface on the Recorders screen; the schedule strip stays focused on
// planned events so the operator can trust it.)
const active = (schedules || []).filter(s => {
const start = new Date(s.start_at);
const end = new Date(s.end_at);
return start <= now && now < end && (s.status === 'running' || s.status === 'pending');
});
// Next up: earliest pending schedule strictly in the future.
const upcoming = (schedules || [])
.filter(s => s.status === 'pending' && new Date(s.start_at) > now)
.sort((a, b) => new Date(a.start_at) - new Date(b.start_at));
const next = upcoming[0];
const recMap = {};
(recorders || []).forEach(r => { recMap[r.id] = r; });
return (
<div className="epg-status">
<div className="epg-status-row">
{active.length === 0 ? (
<>
<span className="epg-status-dot idle" />
<span className="epg-status-label">Nothing scheduled right now</span>
</>
) : (
<>
<span className="epg-status-dot live" />
<span className="epg-status-label">On air</span>
<div className="epg-status-active">
{active.map(s => {
const rec = recMap[s.recorder_id];
const elapsed = _fmtElapsed(now - new Date(s.start_at));
const endsAt = _fmtTime(new Date(s.end_at));
const color = _projectColor(rec?.project_id, projects);
return (
<span key={s.id} className="epg-status-pill">
{color && <span className="epg-status-pill-bar" style={{ background: color }} />}
<span className="epg-status-pill-name">{s.name}</span>
<span className="epg-status-pill-rec mono">{rec?.name || s.recorder_id.slice(0, 8)}</span>
<span className="epg-status-pill-time mono">{elapsed} · ends {endsAt}</span>
</span>
);
})}
</div>
</>
)}
</div>
<div className="epg-status-row sub">
{next ? (
<>
<span className="epg-status-label muted">Next up</span>
<span className="epg-status-next">{next.name}</span>
<span className="epg-status-next-rec mono">{recMap[next.recorder_id]?.name || next.recorder_id.slice(0, 8)}</span>
<span className="epg-status-next-time mono">{_fmtCountdown(new Date(next.start_at) - now)} · {_fmtTime(new Date(next.start_at))}</span>
</>
) : (
<span className="epg-status-label muted">No upcoming schedules</span>
)}
</div>
</div>
);
}
function _EpgRuler({ pph }) {
// 25 ticks so the last one (24:00) labels the right edge. The 24h column
// ends at 24*pph; the 25th tick is purely a label and has zero width.
const hours = [];
for (let h = 0; h <= 24; h++) hours.push(h);
return (
<div className="epg-ruler" style={{ width: 24 * pph }}>
{hours.map(h => (
<div key={h} className={'epg-ruler-tick ' + (h === 24 ? 'end' : '')}
style={{ left: h * pph }}>
<span>{h === 24 ? '' : _fmtHour(h)}</span>
</div>
))}
</div>
);
}
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
// Minimum schedule length the UI permits while dragging. Anything shorter
// rarely reflects a real plan and would let the operator accidentally
// dismiss a block to zero width.
const _EPG_MIN_MS = 5 * 60 * 1000;
// Drag snap quantum. Mirrors the new-schedule click snap so a resized
// block lines up to the same grid an operator just placed a new one on.
const _EPG_SNAP_MIN = 15;
// Pointer travel (in px) before we treat the gesture as a drag rather
// than a click. Below this, pointerup fires the click handler.
const _EPG_DRAG_THRESHOLD = 4;
function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, onClick, onContextMenu, onResize }) {
// Drag state: null when idle, otherwise the in-flight resize/move
// describing the original times and the current (snapped) preview times.
// We render from this preview while dragging so the block follows the
// cursor without round-tripping through props.
const [drag, setDrag] = React.useState(null);
const blockRef = React.useRef(null);
const eventStartMs = new Date(event.start_at).getTime();
const eventEndMs = new Date(event.end_at).getTime();
const isLive = event.status === 'running' || (event.status === 'pending' && eventStartMs <= now.getTime() && now.getTime() < eventEndMs);
const isFailed = event.status === 'failed';
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
const isPast = (event.status === 'completed' || event.status === 'cancelled') || eventEndMs < now.getTime();
const color = _projectColor(recorder?.project_id, projects);
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
// Only pending schedules can be resized. The API rejects PUTs against
// running schedules outright; cancelling them is what an operator
// actually wants there. Terminal statuses are read-only.
const canDrag = event.status === 'pending';
const startDrag = (e, type) => {
if (!canDrag) return;
if (e.button !== 0) return; // ignore right-click
e.stopPropagation();
try { blockRef.current.setPointerCapture(e.pointerId); } catch (_) {}
setDrag({
type, pointerId: e.pointerId,
startX: e.clientX,
origStart: eventStartMs, origEnd: eventEndMs,
currStart: eventStartMs, currEnd: eventEndMs,
moved: false,
});
};
const onPointerMove = (ev) => {
if (!drag) return;
const dx = ev.clientX - drag.startX;
if (!drag.moved && Math.abs(dx) < _EPG_DRAG_THRESHOLD) return;
const snapMs = _EPG_SNAP_MIN * 60 * 1000;
const dMs = Math.round((dx / pph) * 3600 * 1000 / snapMs) * snapMs;
const dayStartMs = dayStart.getTime();
const dayEndMs = dayEnd.getTime();
let cs = drag.origStart, ce = drag.origEnd;
if (drag.type === 'left') {
cs = Math.max(dayStartMs, Math.min(drag.origEnd - _EPG_MIN_MS, drag.origStart + dMs));
} else if (drag.type === 'right') {
ce = Math.min(dayEndMs, Math.max(drag.origStart + _EPG_MIN_MS, drag.origEnd + dMs));
} else if (drag.type === 'body') {
cs = drag.origStart + dMs;
ce = drag.origEnd + dMs;
// Clamp to the day; preserve duration when bumping against an edge.
if (cs < dayStartMs) { ce += (dayStartMs - cs); cs = dayStartMs; }
if (ce > dayEndMs) { cs -= (ce - dayEndMs); ce = dayEndMs; }
}
setDrag({ ...drag, currStart: cs, currEnd: ce, moved: true });
};
const endDrag = (ev) => {
if (!drag) return;
try { blockRef.current.releasePointerCapture(drag.pointerId); } catch (_) {}
const d = drag;
setDrag(null);
if (!d.moved) {
// Treat as a click — open the edit modal.
onClick(event);
return;
}
if (d.currStart === d.origStart && d.currEnd === d.origEnd) return;
onResize(event, new Date(d.currStart).toISOString(), new Date(d.currEnd).toISOString());
};
// Render from drag preview while a gesture is in flight so the block
// tracks the pointer; otherwise from the canonical event prop.
const dispStartMs = drag ? drag.currStart : eventStartMs;
const dispEndMs = drag ? drag.currEnd : eventEndMs;
const dispStart = new Date(dispStartMs);
const dispEnd = new Date(dispEndMs);
const startMin = _minutesIntoDay(dispStart, dayStart);
const endMin = _minutesIntoDay(dispEnd, dayStart);
const left = (startMin / 60) * pph;
const width = Math.max(40, ((endMin - startMin) / 60) * pph);
const classes = ['epg-block'];
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
if (isLive) classes.push('live');
if (isFailed) classes.push('failed');
else if (isPast) classes.push('past');
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
if (drag && drag.moved) classes.push('dragging');
if (canDrag) classes.push('resizable');
return (
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
<div
ref={blockRef}
className={classes.join(' ')}
style={{ left, width, '--epg-block-color': color || 'var(--text-3)' }}
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
onContextMenu={(ev) => { ev.preventDefault(); ev.stopPropagation(); onContextMenu(event, ev); }}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
title={event.name + ' · ' + _fmtTime(dispStart) + ' → ' + _fmtTime(dispEnd) + (event.error_message ? ' · ' + event.error_message : '')}>
<span className="epg-block-bar" />
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
{/* Body click edit, body drag move. We hang the click on pointerup
so the threshold check above can demote a drag back to a click. */}
<div className="epg-block-body" onPointerDown={(ev) => startDrag(ev, 'body')}>
<span className="epg-block-name">{event.name}</span>
<span className="epg-block-time mono">{_fmtTime(dispStart)}{drag && drag.moved ? ' → ' + _fmtTime(dispEnd) : ''}</span>
{isLive && <span className="epg-block-glyph live" title="on air"></span>}
{isFailed && <span className="epg-block-glyph failed" title={event.error_message || 'failed'}>!</span>}
</div>
{canDrag && (
<>
<span className="epg-block-handle left"
onPointerDown={(ev) => startDrag(ev, 'left')}
title="Drag to change start time" />
<span className="epg-block-handle right"
onPointerDown={(ev) => startDrag(ev, 'right')}
title="Drag to change end time" />
</>
)}
</div>
);
}
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
function _EpgRow({ recorder, schedules, dayStart, dayEnd, pph, now, projects, onEventClick, onEmptyClick, onEventContextMenu, onEventResize }) {
const dayEvents = (schedules || []).filter(s => s.recorder_id === recorder.id && _eventOverlapsDay(s, dayStart, dayEnd));
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
const handleRowPointerUp = (e) => {
// Open the new-schedule modal only on a real click in the empty
// gutter. Clicks on event blocks stopPropagation themselves; we also
// guard against the tail of a block-drag bubbling up here.
if (e.target !== e.currentTarget) return;
if (e.button !== 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
const minutes = Math.max(0, Math.min(24 * 60 - 30, Math.round((x / pph) * 60 / _EPG_SNAP_MIN) * _EPG_SNAP_MIN));
const start = new Date(dayStart);
start.setMinutes(minutes);
onEmptyClick(recorder, start);
};
return (
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
<div className="epg-row" style={{ width: 24 * pph }} onPointerUp={handleRowPointerUp}>
{dayEvents.map(s => (
<_EventBlock
key={s.id}
event={s}
recorder={recorder}
dayStart={dayStart}
dayEnd={dayEnd}
pph={pph}
now={now}
projects={projects}
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
onClick={onEventClick}
onContextMenu={onEventContextMenu}
onResize={onEventResize} />
))}
</div>
);
}
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
// ── Right-click menu for an EPG event block ─────────────────────────────────
// Same pattern as AssetContextMenu (screens-library.jsx): viewport-clamped,
// dismissed on outside click. Per-status action filtering mirrors the
// buttons rendered in the List view so the two surfaces stay consistent.
function _ScheduleContextMenu({ schedule, x, y, onClose, onEdit, onCancel, onDelete, onCopyId }) {
const ref = React.useRef(null);
const [pos, setPos] = React.useState({ left: x, top: y });
React.useLayoutEffect(() => {
if (!ref.current) return;
const r = ref.current.getBoundingClientRect();
const margin = 8;
let nx = x, ny = y;
if (x + r.width + margin > window.innerWidth) nx = window.innerWidth - r.width - margin;
if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin;
setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) });
}, [x, y]);
const canEdit = schedule.status === 'pending' || schedule.status === 'failed';
const canCancel = schedule.status === 'pending' || schedule.status === 'running';
const canDelete = schedule.status !== 'running';
return (
<div ref={ref} className="ctx-menu" style={{ left: pos.left, top: pos.top }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<div className="ctx-header">{schedule.name}</div>
{canEdit && <button onClick={onEdit}><Icon name="edit" size={11} />Edit</button>}
{canCancel && <button onClick={onCancel}><Icon name="x" size={11} />Cancel run</button>}
<button onClick={onCopyId}><Icon name="library" size={11} />Copy schedule ID</button>
{canDelete && <div className="ctx-divider" />}
{canDelete && <button className="danger" onClick={onDelete}><Icon name="trash" size={11} />Delete schedule</button>}
</div>
);
}
function _NowLine({ now, dayStart, pph }) {
if (!_sameDay(now, dayStart)) return null;
const min = _minutesIntoDay(now, dayStart);
const x = (min / 60) * pph;
return (
<div className="epg-now" style={{ left: x }}>
<span className="epg-now-pip" />
</div>
);
}
function _RecorderGutter({ recorders, projects }) {
return (
<div className="epg-gutter-rows">
{recorders.map(r => {
const color = _projectColor(r.project_id, projects);
const isLive = r.status === 'recording';
const isErr = r.status === 'error';
return (
<div key={r.id} className="epg-gutter-row">
<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>
</div>
);
})}
</div>
);
}
function Schedule({ navigate }) {
const [schedules, setSchedules] = React.useState(null);
const [recorders, setRecorders] = React.useState([]);
const [showNew, setShowNew] = React.useState(false);
const [newDefaults, setNewDefaults] = React.useState(null);
const [editing, setEditing] = React.useState(null);
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
const [ctxMenu, setCtxMenu] = React.useState(null); // { schedule, x, y }
const [view, setView] = React.useState('today'); // 'today' | 'week' | 'list'
const [day, setDay] = React.useState(() => _dayStart(new Date()));
const [listFilter, setListFilter] = React.useState('upcoming');
const [now, setNow] = React.useState(() => new Date());
// Tick the now-line every second. We only re-render the components that
// consume `now`; the rest are React.memo or insensitive.
React.useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
// 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(() => {
window.ZAMPP_API.fetch('/schedules?status=' + apiFilter)
.then(d => setSchedules(d.schedules || []))
.catch(() => setSchedules([]));
}, [apiFilter]);
React.useEffect(() => { load(); }, [load]);
React.useEffect(() => {
window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => setRecorders([]));
}, []);
// Auto-refresh: 10s in normal view, 4s if anything is live so the now-state
// catches transitions promptly.
React.useEffect(() => {
const anyLive = (schedules || []).some(s => s.status === 'running');
const id = setInterval(load, anyLive ? 4000 : 10_000);
return () => clearInterval(id);
}, [load, schedules]);
// The Recorders screen broadcasts these on create/delete; refresh the
// gutter so renamed or new recorders show up immediately.
React.useEffect(() => {
const refresh = () => window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => {});
window.addEventListener('df:recorders-changed', refresh);
return () => window.removeEventListener('df:recorders-changed', refresh);
}, []);
const projects = window.ZAMPP_DATA?.PROJECTS || [];
// 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;
const dayStart = _dayStart(day);
const dayEnd = _dayEnd(day);
// Scroll the canvas so "now" sits ~30% from the left edge on first paint
// for Today view. Re-runs when the user jumps days via the Today button.
const canvasRef = React.useRef(null);
React.useLayoutEffect(() => {
if (view !== 'today' || !canvasRef.current) return;
if (!_sameDay(now, dayStart)) {
canvasRef.current.scrollLeft = 0;
return;
}
const min = _minutesIntoDay(now, dayStart);
const x = (min / 60) * pph;
const target = Math.max(0, x - canvasRef.current.clientWidth * 0.3);
canvasRef.current.scrollLeft = target;
// Deliberately only re-run on view/day change, not on `now` ticking.
// Otherwise the canvas would re-scroll every second and trap the user.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, day, pph, recorders.length]);
const openNewAt = (recorder, start) => {
const end = new Date(start.getTime() + 30 * 60 * 1000);
setNewDefaults({ start, end, recorder_id: recorder.id });
setShowNew(true);
};
const openNewBlank = () => { setNewDefaults(null); setShowNew(true); };
const cancel = (s) => {
if (!confirm('Cancel scheduled recording "' + s.name + '"?')) return;
window.ZAMPP_API.fetch('/schedules/' + s.id + '/cancel', { method: 'POST' }).then(load).catch(e => alert('Cancel failed: ' + e.message));
};
const remove = (s) => {
if (!confirm('Delete schedule "' + s.name + '"?')) return;
window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' }).then(load).catch(e => alert('Delete failed: ' + e.message));
};
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
// Drag-resize commit: optimistically patch the in-memory schedule so the
// block stays put after the user lets go, then PUT the new times. The
// refetch reconciles in case the server adjusted anything (or rejected).
const handleResize = (s, newStart, newEnd) => {
setSchedules(prev => prev ? prev.map(x => x.id === s.id ? { ...x, start_at: newStart, end_at: newEnd } : x) : prev);
window.ZAMPP_API.fetch('/schedules/' + s.id, {
method: 'PUT',
body: JSON.stringify({ start_at: newStart, end_at: newEnd }),
})
.then(load)
.catch(e => { alert('Resize failed: ' + e.message); load(); });
};
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
// click on a menu item still fires before the menu unmounts.
React.useEffect(() => {
if (!ctxMenu) return;
const close = () => setCtxMenu(null);
window.addEventListener('click', close);
window.addEventListener('contextmenu', close);
window.addEventListener('scroll', close, true);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('contextmenu', close);
window.removeEventListener('scroll', close, true);
};
}, [ctxMenu]);
const copyId = (id) => {
if (navigator.clipboard) navigator.clipboard.writeText(id).catch(() => {});
};
// Days for Week view: the 7-day window starting at the Sunday of `day`.
const weekDays = React.useMemo(() => {
const sun = _addDays(dayStart, -dayStart.getDay());
return Array.from({ length: 7 }, (_, i) => _addDays(sun, i));
}, [dayStart]);
// List view filters schedules by client-side time bucket too.
const listSchedules = React.useMemo(() => {
if (!schedules) return null;
return [...schedules].sort((a, b) => new Date(a.start_at) - new Date(b.start_at));
}, [schedules]);
return (
<div className="epg-page" style={{ '--epg-pph': pph + 'px' }}>
<_StatusStrip schedules={schedules || []} recorders={recorders} now={now} projects={projects} />
<div className="epg-toolbar">
<div className="epg-date">
<button className="icon-btn" onClick={() => setDay(_addDays(day, -1))} title="Previous day"><Icon name="chevron" style={{ transform: 'rotate(90deg)' }} /></button>
<div className="epg-date-label">{_sameDay(day, new Date()) ? 'Today · ' + _fmtDay(day) : _fmtDay(day)}</div>
<button className="icon-btn" onClick={() => setDay(_addDays(day, 1))} title="Next day"><Icon name="chevron" style={{ transform: 'rotate(-90deg)' }} /></button>
<button className="btn ghost sm" onClick={() => setDay(_dayStart(new Date()))} disabled={_sameDay(day, new Date())}>Today</button>
</div>
<div className="spacer" />
<div className="tab-group">
<button className={view === 'today' ? 'active' : ''} onClick={() => setView('today')}>Today</button>
<button className={view === 'week' ? 'active' : ''} onClick={() => setView('week')}>Week</button>
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')}>List</button>
</div>
<button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
<button className="btn primary" onClick={openNewBlank} disabled={recorders.length === 0}>
<Icon name="plus" />New schedule
</button>
</div>
{schedules === null && (
<div className="epg-empty">Loading</div>
)}
{schedules !== null && recorders.length === 0 && (
<div className="epg-empty">
<div className="epg-empty-title">No recorders configured</div>
<div className="epg-empty-sub">Create a recorder before scheduling.</div>
<button className="btn primary sm" onClick={() => navigate('recorders')}>Go to Recorders</button>
</div>
)}
{schedules !== null && recorders.length > 0 && view === 'today' && (
<div className="epg" ref={canvasRef}>
<div className="epg-corner">
<span className="mono">{_fmtDay(day)}</span>
</div>
<div className="epg-gutter">
<_RecorderGutter recorders={recorders} projects={projects} />
</div>
<div className="epg-canvas-head">
<_EpgRuler pph={pph} />
</div>
<div className="epg-canvas" style={{ width: 24 * pph }}>
<div className="epg-rows">
{recorders.map(r => (
<_EpgRow key={r.id}
recorder={r}
schedules={schedules}
dayStart={dayStart}
dayEnd={dayEnd}
pph={pph}
now={now}
projects={projects}
onEventClick={(s) => setEditing(s)}
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
onEventContextMenu={openCtx}
onEventResize={handleResize}
onEmptyClick={openNewAt} />
))}
</div>
<_NowLine now={now} dayStart={dayStart} pph={pph} />
</div>
</div>
)}
{schedules !== null && recorders.length > 0 && view === 'week' && (
<div className="epg-week">
{weekDays.map(d => {
const dEnd = _dayEnd(d);
const isToday = _sameDay(d, new Date());
return (
<div key={d.toISOString()} className={'epg-week-day' + (isToday ? ' today' : '')}>
<div className="epg-week-dayhead">
<span className="epg-week-dayname">{_fmtDay(d)}</span>
{isToday && <span className="epg-week-todaypip">today</span>}
</div>
<div className="epg-week-row-wrap" style={{ width: 24 * pph }}>
<_EpgRuler pph={pph} />
<div className="epg-rows">
{recorders.map(r => (
<_EpgRow key={r.id}
recorder={r}
schedules={schedules}
dayStart={d}
dayEnd={dEnd}
pph={pph}
now={now}
projects={projects}
onEventClick={(s) => setEditing(s)}
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
onEventContextMenu={openCtx}
onEventResize={handleResize}
onEmptyClick={openNewAt} />
))}
</div>
{isToday && <_NowLine now={now} dayStart={d} pph={pph} />}
</div>
</div>
);
})}
</div>
)}
{schedules !== null && recorders.length > 0 && view === 'list' && (
<div className="epg-list">
<div className="tab-group" style={{ marginBottom: 12 }}>
<button className={listFilter === 'upcoming' ? 'active' : ''} onClick={() => setListFilter('upcoming')}>Upcoming</button>
<button className={listFilter === 'past' ? 'active' : ''} onClick={() => setListFilter('past')}>Past</button>
<button className={listFilter === 'all' ? 'active' : ''} onClick={() => setListFilter('all')}>All</button>
</div>
{(listSchedules || []).length === 0 ? (
<div className="epg-empty"><div className="epg-empty-title">No {listFilter} schedules</div></div>
) : (
<div className="panel">
<div className="schedule-row head">
<div>Name</div><div>Recorder</div><div>Starts</div><div>Duration</div><div>Recurrence</div><div>Status</div><div></div>
</div>
{listSchedules.map(s => {
const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending;
return (
<div key={s.id} className="schedule-row">
<div style={{ fontWeight: 500, fontSize: 13 }}>
{s.name}
{s.error_message && <div style={{ fontSize: 11, color: 'var(--danger)', marginTop: 3 }}>{s.error_message}</div>}
</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-2)' }}>{s.recorder_name || s.recorder_id.slice(0, 8)}</div>
<div className="mono" style={{ fontSize: 11.5 }}>{_fmtWhen(s.start_at)}</div>
<div className="mono" style={{ fontSize: 11.5 }}>{_durationMin(s.start_at, s.end_at)} min</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{s.recurrence === 'none' ? 'one-shot' : s.recurrence}</div>
<div><span className={'badge ' + badge.cls}>{badge.label}</span></div>
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
{s.status === 'pending' && <button className="btn ghost sm" onClick={() => setEditing(s)}>Edit</button>}
{(s.status === 'pending' || s.status === 'running') && <button className="btn ghost sm" onClick={() => cancel(s)}>Cancel</button>}
{(s.status === 'completed' || s.status === 'failed' || s.status === 'cancelled' || s.status === 'pending') &&
<button className="btn ghost sm" onClick={() => remove(s)}>Delete</button>}
</div>
</div>
);
})}
</div>
)}
</div>
)}
{showNew && <NewScheduleModal
recorders={recorders}
defaultRecorderId={newDefaults?.recorder_id}
defaultStart={newDefaults?.start}
defaultEnd={newDefaults?.end}
onClose={() => { setShowNew(false); setNewDefaults(null); }}
onCreated={() => { setShowNew(false); setNewDefaults(null); load(); }} />}
{editing && <EditScheduleModal schedule={editing} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load(); }} />}
feat(schedule): right-click menu + drag-to-resize on EPG event blocks Right-click any event block to open a context menu (Edit, Cancel, Copy schedule ID, Delete) — actions per status mirror the List view so the two surfaces stay in lockstep. Menu is viewport-clamped and dismisses on outside click / scroll, same pattern as the asset menu in the Library. Drag-to-resize works for pending schedules only (the schedules PUT rejects edits to running rows, and terminal statuses are read-only): - Drag the left edge to move the start time - Drag the right edge to move the end time - Drag the body to shift the whole block in time All gestures snap to 15-minute increments to match the new-schedule click snap. Minimum duration is clamped to 5 minutes; the block clamps to the visible day on both edges. While dragging the title shows the preview range ("Start time → end time") and the block lifts with a project-tinted shadow. A short pointer click (< 4px travel) still opens the edit modal — the click and drag share the same pointerdown so the operator never has to know which gesture they made first. Implementation: replaces the <button> block with a <div> hosting three zones (left handle / body / right handle). Pointer events with setPointerCapture so drags survive losing the cursor over the block, and pointerup demotes back to click if travel was below threshold. Optimistic local update on resize, PUT /schedules/:id with just the two changed time fields, refetch to reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:33:57 -04:00
{ctxMenu && (
<_ScheduleContextMenu
schedule={ctxMenu.schedule}
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
onEdit={() => { const s = ctxMenu.schedule; setCtxMenu(null); setEditing(s); }}
onCancel={() => { const s = ctxMenu.schedule; setCtxMenu(null); cancel(s); }}
onDelete={() => { const s = ctxMenu.schedule; setCtxMenu(null); remove(s); }}
onCopyId={() => { const id = ctxMenu.schedule.id; setCtxMenu(null); copyId(id); }} />
)}
</div>
);
}
function EditScheduleModal({ schedule, onClose, onSaved }) {
const toLocalInput = (iso) => {
const d = new Date(iso);
const tz = d.getTimezoneOffset() * 60_000;
return new Date(d.getTime() - tz).toISOString().slice(0, 16);
};
const [form, setForm] = React.useState({
name: schedule.name,
start_at: toLocalInput(schedule.start_at),
end_at: toLocalInput(schedule.end_at),
recurrence: schedule.recurrence || 'none',
});
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
const submit = () => {
setErr(null);
if (!form.name.trim()) return setErr('Name is required');
const startD = new Date(form.start_at);
const endD = new Date(form.end_at);
if (endD <= startD) return setErr('End must be after start');
setSaving(true);
window.ZAMPP_API.fetch('/schedules/' + schedule.id, {
method: 'PUT',
body: JSON.stringify({
name: form.name.trim(),
start_at: startD.toISOString(),
end_at: endD.toISOString(),
recurrence: form.recurrence,
}),
})
.then(onSaved)
.catch(e => { setSaving(false); setErr(e.message || 'Save failed'); });
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Edit scheduled recording</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Name</label>
<input className="field-input" autoFocus value={form.name}
onChange={e => set('name', e.target.value)} />
</div>
<div className="field">
<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>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label className="field-label">Start</label>
<input className="field-input mono" type="datetime-local"
value={form.start_at} onChange={e => set('start_at', e.target.value)} />
</div>
<div className="field">
<label className="field-label">End</label>
<input className="field-input mono" type="datetime-local"
value={form.end_at} onChange={e => set('end_at', e.target.value)} />
</div>
</div>
<div className="field">
<label className="field-label">Recurrence</label>
<select className="field-input" value={form.recurrence}
onChange={e => set('recurrence', e.target.value)}
style={{ appearance: 'auto' }}>
<option value="none">One-shot (no repeat)</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={submit} disabled={saving}>{saving ? 'Saving…' : 'Save changes'}</button>
</div>
</div>
</div>
);
}
function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, defaultEnd, defaultRecorderId }) {
// If the user clicked a day on the calendar we honour that; otherwise default
// to "start in 5 minutes, run for 30 min" so the modal is immediately usable.
const toLocalInput = (d) => {
const tz = d.getTimezoneOffset() * 60_000;
return new Date(d.getTime() - tz).toISOString().slice(0, 16);
};
const now = new Date();
now.setSeconds(0, 0);
const startDefault = defaultStart || new Date(now.getTime() + 5 * 60 * 1000);
const endDefault = defaultEnd || new Date(startDefault.getTime() + 30 * 60 * 1000);
const [form, setForm] = React.useState({
name: '',
recorder_id: defaultRecorderId || recorders[0]?.id || '',
start_at: toLocalInput(startDefault),
end_at: toLocalInput(endDefault),
recurrence: 'none',
});
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
const submit = () => {
setErr(null);
if (!form.name.trim()) return setErr('Name is required');
if (!form.recorder_id) return setErr('Pick a recorder');
if (!form.start_at) return setErr('Start time is required');
if (!form.end_at) return setErr('End time is required');
const startD = new Date(form.start_at);
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
// 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;
}
// Datetime-local inputs are in the browser's local zone; ship as ISO so
// Postgres stores them as TIMESTAMPTZ properly.
const body = {
name: form.name.trim(),
recorder_id: form.recorder_id,
start_at: startD.toISOString(),
end_at: endD.toISOString(),
recurrence: form.recurrence,
};
setSaving(true);
window.ZAMPP_API.fetch('/schedules', { method: 'POST', body: JSON.stringify(body) })
.then(() => onCreated())
.catch(e => { setSaving(false); setErr(e.message || 'Failed to schedule'); });
};
const onKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) submit(); };
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>New scheduled recording</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Name</label>
<input className="field-input" autoFocus value={form.name}
onChange={e => set('name', e.target.value)}
onKeyDown={onKey} placeholder="Morning service stream" />
</div>
<div className="field">
<label className="field-label">Recorder</label>
<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.map(r => (
<option key={r.id} value={r.id}>
{r.name} · {r.source_type?.toUpperCase() || '?'}
</option>
))}
</select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label className="field-label">Start</label>
<input className="field-input mono" type="datetime-local"
value={form.start_at}
onChange={e => set('start_at', e.target.value)} />
</div>
<div className="field">
<label className="field-label">End</label>
<input className="field-input mono" type="datetime-local"
value={form.end_at}
onChange={e => set('end_at', e.target.value)} />
</div>
</div>
<div className="field">
<label className="field-label">Recurrence</label>
<select className="field-input" value={form.recurrence}
onChange={e => set('recurrence', e.target.value)}
style={{ appearance: 'auto' }}>
<option value="none">One-shot (no repeat)</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>
Recurring schedules queue the next occurrence as soon as the current one completes.
</div>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={submit} disabled={saving}>
{saving ? 'Scheduling…' : 'Schedule recording'}
</button>
</div>
</div>
</div>
);
}
Object.assign(window, { Upload, Recorders, Capture, Monitors, Schedule, YouTubeImport });