feat(recorders): add Edit Recorder panel with PATCH support

- Edit (pencil) button appears on idle recorder cards; hidden while recording
- openEditPanel() pre-populates all form fields from existing recorder state
- openPanel() resets editingId and restores "New recorder" defaults
- closePanel() clears editingId and removes any stale probe result
- handleSaveRecorder() dispatches PATCH /recorders/:id in edit mode, POST otherwise
- Fix field name bugs in create path: codec→recording_codec, resolution→recording_resolution,
  proxy_config object→proxy_enabled/proxy_codec/proxy_resolution flat fields
- Badge in card now reads rec.recording_codec (correct DB field) instead of rec.codec
- Bump api.js cache-buster to v=6
This commit is contained in:
Zac Gaetano 2026-05-18 23:35:16 -04:00
parent 79d44826fe
commit 508cf8d41b

View file

@ -218,7 +218,7 @@
<div class="slide-overlay" id="panelOverlay"></div> <div class="slide-overlay" id="panelOverlay"></div>
<div class="slide-panel" id="recorderPanel"> <div class="slide-panel" id="recorderPanel">
<div class="slide-panel-header"> <div class="slide-panel-header">
<span class="slide-panel-title">New recorder</span> <span class="slide-panel-title" id="panelTitle">New recorder</span>
<button class="btn btn-ghost btn-sm" id="closePanelBtn" style="padding:0;width:28px;height:28px;"> <button class="btn btn-ghost btn-sm" id="closePanelBtn" style="padding:0;width:28px;height:28px;">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
</button> </button>
@ -325,10 +325,10 @@
<div class="toast-container" id="toastContainer" aria-live="polite"></div> <div class="toast-container" id="toastContainer" aria-live="polite"></div>
<script src="js/api.js?v=5"></script> <script src="js/api.js?v=6"></script>
<script src="js/topbar-strip.js?v=1"></script> <script src="js/topbar-strip.js?v=1"></script>
<script> <script>
const pState = { recorders: [], timers: {}, sourceType: 'srt', mode: 'caller', projects: [], signals: {} }; const pState = { recorders: [], timers: {}, sourceType: 'srt', mode: 'caller', projects: [], signals: {}, editingId: null };
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
await Promise.all([loadRecorders(), loadProjects()]); await Promise.all([loadRecorders(), loadProjects()]);
@ -434,10 +434,13 @@
<div class="recorder-name">${esc(rec.name)}</div> <div class="recorder-name">${esc(rec.name)}</div>
<div class="recorder-badges"> <div class="recorder-badges">
<span class="badge ${badgeClass}">${sourceTypeKey.toUpperCase()}</span> <span class="badge ${badgeClass}">${sourceTypeKey.toUpperCase()}</span>
${rec.codec ? `<span class="badge badge-idle">${esc(rec.codec)}</span>` : ''} ${rec.recording_codec ? `<span class="badge badge-idle">${esc(rec.recording_codec)}</span>` : ''}
</div> </div>
</div> </div>
<div class="recorder-actions"> <div class="recorder-actions">
${!isRecording ? `<button class="btn btn-ghost btn-sm" onclick="openEditPanel('${rec.id}')" title="Edit recorder" style="padding:0;width:28px;height:28px;">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
</button>` : ''}
<button class="btn btn-ghost btn-sm" onclick="handleDeleteRecorder('${rec.id}')" title="Delete recorder" style="padding:0;width:28px;height:28px;"> <button class="btn btn-ghost btn-sm" onclick="handleDeleteRecorder('${rec.id}')" title="Delete recorder" style="padding:0;width:28px;height:28px;">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 4h10M6 4V2h4v2M5 4v9h6V4"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 4h10M6 4V2h4v2M5 4v9h6V4"/></svg>
</button> </button>
@ -593,14 +596,100 @@
// ── Panel ───────────────────────────────── // ── Panel ─────────────────────────────────
function openPanel() { function openPanel() {
pState.editingId = null;
document.getElementById('panelTitle').textContent = 'New recorder';
document.getElementById('saveRecorderBtn').textContent = 'Create recorder';
document.getElementById('probeBtn').style.display = '';
// Reset form to defaults
document.getElementById('recName').value = '';
document.getElementById('recCodec').value = 'prores_hq';
document.getElementById('recResolution').value = 'native';
document.getElementById('proxyToggle').checked = false;
document.getElementById('proxyFields').style.display = 'none';
document.getElementById('recProject').value = '';
document.getElementById('recBin').innerHTML = '<option value="">Project root</option>';
const pr = document.getElementById('probeResult');
if (pr) pr.remove();
pState.sourceType = 'srt';
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === 'srt'));
document.getElementById('recorderPanel').classList.add('open'); document.getElementById('recorderPanel').classList.add('open');
document.getElementById('panelOverlay').classList.add('open'); document.getElementById('panelOverlay').classList.add('open');
updateSourceFields(); updateSourceFields();
} }
function openEditPanel(recId) {
const rec = pState.recorders.find(r => r.id === recId);
if (!rec) return;
if (rec.status === 'recording') {
toast('Cannot edit while recording', 'Stop the recorder first', 'warning');
return;
}
pState.editingId = recId;
document.getElementById('panelTitle').textContent = 'Edit recorder';
document.getElementById('saveRecorderBtn').textContent = 'Save changes';
document.getElementById('probeBtn').style.display = '';
const pr = document.getElementById('probeResult');
if (pr) pr.remove();
// Basic fields
document.getElementById('recName').value = rec.name || '';
document.getElementById('recCodec').value = rec.recording_codec || 'prores_hq';
document.getElementById('recResolution').value = rec.recording_resolution || 'native';
// Proxy
const proxyEnabled = !!rec.proxy_enabled;
document.getElementById('proxyToggle').checked = proxyEnabled;
document.getElementById('proxyFields').style.display = proxyEnabled ? 'grid' : 'none';
if (proxyEnabled) {
const pc = document.getElementById('proxyCodec');
if (pc) pc.value = rec.proxy_codec || 'h264';
const pb = document.getElementById('proxyBitrate');
// proxy_resolution stores a value like "4000k" (set from proxy bitrate select on create)
if (pb) pb.value = rec.proxy_resolution || '4000k';
}
// Source type
const srcType = (rec.source_type || 'srt').toLowerCase();
pState.sourceType = srcType;
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === srcType));
updateSourceFields();
// Populate source-specific fields after updateSourceFields injects DOM nodes
setTimeout(() => {
const cfg = rec.source_config || {};
if (srcType === 'sdi') {
const d = document.getElementById('sdiDevice');
if (d) d.value = String(cfg.device ?? 0);
} else if (srcType === 'srt') {
const u = document.getElementById('srtUrl');
if (u) u.value = cfg.url || '';
} else if (srcType === 'rtmp') {
const u = document.getElementById('rtmpUrl');
if (u) u.value = cfg.url || '';
}
}, 0);
// Project / bin
const projSel = document.getElementById('recProject');
projSel.value = rec.project_id || '';
document.getElementById('recBin').innerHTML = '<option value="">Project root</option>';
if (rec.project_id) {
handleProjectChange().then(() => {
if (rec.bin_id) document.getElementById('recBin').value = rec.bin_id;
});
}
document.getElementById('recorderPanel').classList.add('open');
document.getElementById('panelOverlay').classList.add('open');
}
function closePanel() { function closePanel() {
pState.editingId = null;
document.getElementById('recorderPanel').classList.remove('open'); document.getElementById('recorderPanel').classList.remove('open');
document.getElementById('panelOverlay').classList.remove('open'); document.getElementById('panelOverlay').classList.remove('open');
const pr = document.getElementById('probeResult');
if (pr) pr.remove();
} }
// ── Source type ─────────────────────────── // ── Source type ───────────────────────────
@ -692,7 +781,7 @@
if (r.success) r.data.forEach(b => binSel.innerHTML += `<option value="${b.id}">${esc(b.name)}</option>`); if (r.success) r.data.forEach(b => binSel.innerHTML += `<option value="${b.id}">${esc(b.name)}</option>`);
} }
// ── Save recorder ───────────────────────── // ── Save recorder (create or update) ──────
async function handleProbe() { async function handleProbe() {
const btn = document.getElementById('probeBtn'); const btn = document.getElementById('probeBtn');
@ -756,16 +845,14 @@
host.innerHTML = html; host.innerHTML = html;
} }
async function handleSaveRecorder() { async function handleSaveRecorder() {
const name = document.getElementById('recName').value.trim(); const name = document.getElementById('recName').value.trim();
if (!name) { toast('Enter a recorder name', '', 'warning'); return; } if (!name) { toast('Enter a recorder name', '', 'warning'); return; }
const type = pState.sourceType; const type = pState.sourceType;
const mode = pState.mode; const recording_codec = document.getElementById('recCodec').value;
const codec = document.getElementById('recCodec').value; const recording_resolution = document.getElementById('recResolution').value;
const resolution = document.getElementById('recResolution').value;
const projectId = document.getElementById('recProject').value || null; const projectId = document.getElementById('recProject').value || null;
const binId = document.getElementById('recBin').value || null;
let sourceConfig = {}; let sourceConfig = {};
if (type === 'sdi') { if (type === 'sdi') {
@ -778,25 +865,34 @@
sourceConfig.url = document.getElementById('rtmpUrl')?.value; sourceConfig.url = document.getElementById('rtmpUrl')?.value;
} }
const proxy = document.getElementById('proxyToggle').checked; const proxy_enabled = document.getElementById('proxyToggle').checked;
const proxyConfig = proxy ? {
codec: document.getElementById('proxyCodec').value,
bitrate: document.getElementById('proxyBitrate').value,
} : null;
const payload = { const payload = {
name, source_type: type, source_config: sourceConfig, name,
codec, resolution, project_id: projectId, bin_id: binId, source_type: type,
proxy_config: proxyConfig, source_config: sourceConfig,
recording_codec,
recording_resolution,
project_id: projectId,
proxy_enabled,
proxy_codec: proxy_enabled ? document.getElementById('proxyCodec').value : undefined,
proxy_resolution: proxy_enabled ? document.getElementById('proxyBitrate').value : undefined,
}; };
const r = await createRecorder(payload); if (pState.editingId) {
if (r.success) { const r = await patchRecorder(pState.editingId, payload);
toast('Recorder created', name, 'success'); if (r.success) {
closePanel(); toast('Recorder updated', name, 'success');
document.getElementById('recName').value = ''; closePanel();
await loadRecorders(); await loadRecorders();
} else toast('Failed to create recorder', r.error, 'error'); } else toast('Failed to update recorder', r.error, 'error');
} else {
const r = await createRecorder(payload);
if (r.success) {
toast('Recorder created', name, 'success');
closePanel();
await loadRecorders();
} else toast('Failed to create recorder', r.error, 'error');
}
} }
function toast(title, msg, type = 'info') { function toast(title, msg, type = 'info') {