From d94ed00312d75efec43f78243328abf6c89c1357 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 24 May 2026 14:20:00 -0400 Subject: [PATCH] fix: apiFetch headers spread, droppable highlight, project rename, color stability, orphaned api.js removal - Fix apiFetch headers spread bug (custom headers overwrote Content-Type) - Track per-bin hover state for droppable highlight - Refresh project rail after rename from Library screen - Use ID-hash for project colors instead of array index - Remove orphaned js/api.js (563 lines, never loaded) - 'All projects' rail item clears openProject filter - Add project boundary guard to drag-and-drop bin moves - Stabilize refreshAssets useCallback with empty deps - 'Last 24h' filter now actually filters by created_at --- services/web-ui/public/app.jsx | 2 +- services/web-ui/public/data.jsx | 2 +- services/web-ui/public/js/api.js | 563 -------------------- services/web-ui/public/screens-library.jsx | 68 ++- services/web-ui/public/screens-projects.jsx | 2 +- 5 files changed, 52 insertions(+), 585 deletions(-) delete mode 100644 services/web-ui/public/js/api.js diff --git a/services/web-ui/public/app.jsx b/services/web-ui/public/app.jsx index c98c678..e509032 100644 --- a/services/web-ui/public/app.jsx +++ b/services/web-ui/public/app.jsx @@ -70,7 +70,7 @@ function App() { switch (route) { case 'home': content = ; break; case 'dashboard': content = ; break; - case 'library': content = ; break; + case 'library': content = setOpenProject(null)} />; break; case 'projects': content = ; break; case 'upload': content = ; break; case 'recorders': content = setShowNewRecorder(true)} />; break; diff --git a/services/web-ui/public/data.jsx b/services/web-ui/public/data.jsx index 6250289..66e4acc 100644 --- a/services/web-ui/public/data.jsx +++ b/services/web-ui/public/data.jsx @@ -17,8 +17,8 @@ window.ZAMPP_DATA = { async function apiFetch(path, opts = {}) { const res = await fetch(API + path, { credentials: 'include', - headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }, ...opts, + headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }, }); // 401 from any API call means there's no live session. Bounce to the // login screen instead of leaving the app in a half-loaded state. diff --git a/services/web-ui/public/js/api.js b/services/web-ui/public/js/api.js deleted file mode 100644 index f1774e7..0000000 --- a/services/web-ui/public/js/api.js +++ /dev/null @@ -1,563 +0,0 @@ -// Wild Dragon MAM - API Helper Module - -const API_BASE = '/api/v1'; -const CAPTURE_BASE = '/capture'; - -// Session ID for the active capture — set on start, cleared on stop -let _captureSessionId = null; - -/** - * Wrapper around fetch with JSON parsing and error handling - */ -async function api(path, options = {}) { - const url = `${API_BASE}${path}`; - const config = { - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, - ...options, - }; - - try { - const response = await fetch(url, config); - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: response.statusText })); - throw new Error(error.message || `API Error: ${response.status}`); - } - - const data = await response.json(); - return { success: true, data }; - } catch (error) { - console.error(`API Error on ${path}:`, error); - return { success: false, error: error.message }; - } -} - -/** - * Wrapper around fetch for capture endpoints (goes directly to capture service via nginx) - */ -async function captureApi(path, options = {}) { - const url = `${CAPTURE_BASE}${path}`; - const config = { - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, - ...options, - }; - - try { - const response = await fetch(url, config); - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: response.statusText })); - throw new Error(error.message || `Capture API Error: ${response.status}`); - } - - const data = await response.json(); - return { success: true, data }; - } catch (error) { - console.error(`Capture API Error on ${path}:`, error); - return { success: false, error: error.message }; - } -} - -// ============================================================ -// ASSET API CALLS -// ============================================================ - -async function getAssets(filters = {}) { - const params = new URLSearchParams(filters); - const path = `/assets${params.toString() ? '?' + params.toString() : ''}`; - const r = await api(path); - // API returns { assets, total }. Callers expect r.data to be the array, - // so unwrap here — but preserve total as a sibling for any caller that wants it. - if (r.success && r.data && typeof r.data === 'object' && Array.isArray(r.data.assets)) { - r.total = r.data.total; - r.data = r.data.assets; - } - return r; -} - -async function getAsset(assetId) { - return api(`/assets/${assetId}`); -} - -async function getAssetStreamUrl(assetId) { - return api(`/assets/${assetId}/stream`); -} - -async function updateAsset(assetId, data) { - return api(`/assets/${assetId}`, { - method: 'PATCH', - body: JSON.stringify(data), - }); -} - -async function searchAssets(query) { - return api(`/assets?q=${encodeURIComponent(query)}`); -} - -async function getAssetMetadata(assetId) { - return api(`/assets/${assetId}`); -} - -async function deleteAsset(assetId, { hard = false } = {}) { - const qs = hard ? '?hard=true' : ''; - return api(`/assets/${assetId}${qs}`, { method: 'DELETE' }); -} - -async function retryAsset(assetId) { - return api(`/assets/${assetId}/retry`, { method: 'POST' }); -} - -async function moveAsset(assetId, binId) { - return api(`/assets/${assetId}`, { - method: 'PATCH', - body: JSON.stringify({ bin_id: binId || null }), - }); -} - -async function copyAsset(assetId, { binId, projectId } = {}) { - return api(`/assets/${assetId}/copy`, { - method: 'POST', - body: JSON.stringify({ binId: binId || null, projectId: projectId || null }), - }); -} - -// ============================================================ -// PROJECT API CALLS -// ============================================================ - -async function getProjects() { - return api('/projects'); -} - -async function getProject(projectId) { - return api(`/projects/${projectId}`); -} - -async function createProject(name, description = '') { - return api('/projects', { - method: 'POST', - body: JSON.stringify({ name, description }), - }); -} - -async function updateProject(projectId, data) { - return api(`/projects/${projectId}`, { - method: 'PATCH', - body: JSON.stringify(data), - }); -} - -async function deleteProject(projectId) { - return api(`/projects/${projectId}`, { - method: 'DELETE', - }); -} - -// ============================================================ -// BIN API CALLS -// ============================================================ - -async function getBins(projectId) { - return api(`/bins?project_id=${projectId}`); -} - -async function getBin(projectId, binId) { - return api(`/bins/${binId}`); -} - -async function createBin(projectId, name, description = '') { - return api('/bins', { - method: 'POST', - body: JSON.stringify({ project_id: projectId, name }), - }); -} - -async function updateBin(projectId, binId, data) { - return api(`/bins/${binId}`, { - method: 'PATCH', - body: JSON.stringify(data), - }); -} - -async function deleteBin(projectId, binId) { - return api(`/bins/${binId}`, { - method: 'DELETE', - }); -} - -// ============================================================ -// CAPTURE API CALLS -// ============================================================ - -async function getCaptureDevices() { - const result = await captureApi('/devices'); - if (result.success && result.data) { - const raw = Array.isArray(result.data) - ? result.data - : (result.data.devices || []); - result.data = raw.map(d => ({ - id: d.index !== undefined ? d.index : d.id, - name: d.name, - interface: d.interface || 'DeckLink', - })); - } - return result; -} - -async function getCaptureStatus() { - return captureApi('/status'); -} - -async function getRecordingStatus() { - return captureApi('/status'); -} - -async function startRecording(deviceIndex, projectId, binId, clipName) { - const result = await captureApi('/start', { - method: 'POST', - body: JSON.stringify({ - device: parseInt(deviceIndex, 10), - project_id: projectId, - bin_id: binId || null, - clip_name: clipName, - }), - }); - if (result.success && result.data && result.data.sessionId) { - _captureSessionId = result.data.sessionId; - } - return result; -} - -async function stopRecording() { - let sessionId = _captureSessionId; - - if (!sessionId) { - const statusResult = await captureApi('/status'); - if (statusResult.success && statusResult.data && statusResult.data.sessionId) { - sessionId = statusResult.data.sessionId; - } - } - - const result = await captureApi('/stop', { - method: 'POST', - body: JSON.stringify({ session_id: sessionId }), - }); - - if (result.success) { - _captureSessionId = null; - } - return result; -} - -async function getRecentCaptures(limit = 10) { - const r = await api(`/assets?limit=${limit}`); - if (r.success && r.data && typeof r.data === 'object' && Array.isArray(r.data.assets)) { - r.total = r.data.total; - r.data = r.data.assets; - } - return r; -} - -async function getRecordingTimecode() { - return { success: true, data: { timecode: null } }; -} - -// ============================================================ -// UTILITY FUNCTIONS -// ============================================================ - -function formatFileSize(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; -} - -function formatDuration(seconds) { - if (!seconds || seconds < 0) return '00:00:00'; - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - return [hours, minutes, secs].map(v => v.toString().padStart(2, '0')).join(':'); -} - -function getStatusLabel(status) { - const labels = { - ingesting: 'Ingesting', - processing: 'Processing', - ready: 'Ready', - error: 'Error', - archived: 'Archived', - }; - return labels[status] || status; -} - -function getStatusBadgeClass(status) { - return `badge-${status}`; -} - -function debounce(func, wait) { - let timeout; - return function executedFunction(...args) { - const later = () => { clearTimeout(timeout); func(...args); }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; -} - -function throttle(func, limit) { - let inThrottle; - return function(...args) { - if (!inThrottle) { - func.apply(this, args); - inThrottle = true; - setTimeout(() => inThrottle = false, limit); - } - }; -} - -// ============================================================ -// UPLOAD API CALLS -// ============================================================ - -async function initUpload(data) { - const body = { - filename: data.filename, - fileSize: data.file_size || data.fileSize, - contentType: data.content_type || data.contentType, - projectId: data.project_id || data.projectId, - binId: data.bin_id || data.binId || null, - tags: data.tags || null, - }; - return api('/upload/init', { method: 'POST', body: JSON.stringify(body) }); -} - -async function completeUpload(data) { - const body = { - uploadId: data.upload_id || data.uploadId, - key: data.key, - assetId: data.asset_id || data.assetId, - parts: (data.parts || []).map(p => ({ - partNumber: p.part_number || p.partNumber, - ETag: p.etag || p.ETag, - })), - }; - return api('/upload/complete', { method: 'POST', body: JSON.stringify(body) }); -} - -async function abortUpload(data) { - const body = { - uploadId: data.upload_id || data.uploadId, - key: data.key, - assetId: data.asset_id || data.assetId, - }; - return api('/upload/abort', { method: 'POST', body: JSON.stringify(body) }); -} - -async function uploadPart(formData) { - try { - const response = await fetch('/api/v1/upload/part', { - method: 'POST', - credentials: 'include', - body: formData, - }); - if (!response.ok) throw new Error(`Upload part failed: ${response.status}`); - const data = await response.json(); - return { success: true, data }; - } catch (error) { - return { success: false, error: error.message }; - } -} - -async function simpleUpload(formData) { - try { - const response = await fetch('/api/v1/upload/simple', { - method: 'POST', - credentials: 'include', - body: formData, - }); - if (!response.ok) throw new Error(`Upload failed: ${response.status}`); - const data = await response.json(); - return { success: true, data }; - } catch (error) { - return { success: false, error: error.message }; - } -} - -// ============================================================ -// RECORDER API CALLS -// ============================================================ - -async function getRecorders() { - return api('/recorders'); -} - -async function createRecorder(data) { - return api('/recorders', { method: 'POST', body: JSON.stringify(data) }); -} - -async function patchRecorder(id, data) { - return api(`/recorders/${id}`, { method: 'PATCH', body: JSON.stringify(data) }); -} - -async function startRecorder(id) { - return api(`/recorders/${id}/start`, { method: 'POST' }); -} - -async function stopRecorder(id) { - return api(`/recorders/${id}/stop`, { method: 'POST' }); -} - -async function deleteRecorder(id) { - return api(`/recorders/${id}`, { method: 'DELETE' }); -} - -async function getRecorderStatus(id) { - return api(`/recorders/${id}/status`); -} - -// ============================================================ -// USERS API CALLS (admin) -// ============================================================ - -async function getUsers() { - return api('/users'); -} - -async function createUser(data) { - return api('/users', { method: 'POST', body: JSON.stringify(data) }); -} - -async function updateUser(id, data) { - return api(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(data) }); -} - -async function deleteUser(id) { - return api(`/users/${id}`, { method: 'DELETE' }); -} - -// ============================================================ -// GROUPS API CALLS (admin) -// ============================================================ - -async function getGroups() { - return api('/groups'); -} - -async function createGroup(data) { - return api('/groups', { method: 'POST', body: JSON.stringify(data) }); -} - -async function updateGroup(id, data) { - return api(`/groups/${id}`, { method: 'PATCH', body: JSON.stringify(data) }); -} - -async function deleteGroup(id) { - return api(`/groups/${id}`, { method: 'DELETE' }); -} - -async function getGroupMembers(id) { - return api(`/groups/${id}/members`); -} - -async function addGroupMember(groupId, userId) { - return api(`/groups/${groupId}/members`, { - method: 'POST', - body: JSON.stringify({ user_id: userId }), - }); -} - -async function removeGroupMember(groupId, userId) { - return api(`/groups/${groupId}/members/${userId}`, { method: 'DELETE' }); -} - -// ============================================================ -// TOKENS API CALLS -// ============================================================ - -async function getTokens() { - return api('/tokens'); -} - -async function createToken(data) { - return api('/tokens', { method: 'POST', body: JSON.stringify(data) }); -} - -async function revokeToken(id) { - return api(`/tokens/${id}`, { method: 'DELETE' }); -} - -// ============================================================ -// SEQUENCE API CALLS -// ============================================================ - -async function getSequences(projectId) { - return api(`/sequences?project_id=${projectId}`); -} - -async function createSequence(data) { - return api('/sequences', { - method: 'POST', - body: JSON.stringify(data), - }); -} - -async function getSequence(sequenceId) { - return api(`/sequences/${sequenceId}`); -} - -async function updateSequence(sequenceId, data) { - return api(`/sequences/${sequenceId}`, { - method: 'PUT', - body: JSON.stringify(data), - }); -} - -async function deleteSequence(sequenceId) { - return api(`/sequences/${sequenceId}`, { method: 'DELETE' }); -} - -/** - * Replace all clips in a sequence. - * @param {string} sequenceId - * @param {Array<{asset_id, track, timeline_in_frames, timeline_out_frames, source_in_frames, source_out_frames}>} clips - */ -async function syncSequenceClips(sequenceId, clips) { - return api(`/sequences/${sequenceId}/clips`, { - method: 'PUT', - body: JSON.stringify(clips), - }); -} - -/** - * Download EDL for the V1 track of a sequence. - * Triggers a file download in the browser. - */ -async function exportSequenceEDL(sequenceId, filename) { - try { - const response = await fetch(`${API_BASE}/sequences/${sequenceId}/export/edl`, { - method: 'POST', - credentials: 'include', - }); - if (!response.ok) throw new Error(`EDL export failed: ${response.status}`); - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename || 'sequence.edl'; - a.click(); - URL.revokeObjectURL(url); - return { success: true }; - } catch (error) { - return { success: false, error: error.message }; - } -} diff --git a/services/web-ui/public/screens-library.jsx b/services/web-ui/public/screens-library.jsx index 4b383a1..1add333 100644 --- a/services/web-ui/public/screens-library.jsx +++ b/services/web-ui/public/screens-library.jsx @@ -1,6 +1,6 @@ // screens-library.jsx -function Library({ navigate, onOpenAsset, openProject }) { +function Library({ navigate, onOpenAsset, openProject, onClearProject }) { const PROJECTS = window.ZAMPP_DATA?.PROJECTS || []; const [bins, setBins] = React.useState(window.ZAMPP_DATA?.BINS || []); const BINS = bins; // legacy local name; keep so the rest of the function reads unchanged @@ -34,14 +34,12 @@ function Library({ navigate, onOpenAsset, openProject }) { method: 'POST', body: JSON.stringify({ project_id: openProject.id, name: name.trim() }), }) - .then(() => { - window.ZAMPP_API.fetch('/bins?project_id=' + openProject.id) - .then(list => setBins((list || []).map(b => ({ ...b, count: b.asset_count || 0, icon: b.type || 'grid' })))); - }) + .then(() => window.ZAMPP_API.fetch('/bins?project_id=' + openProject.id)) + .then(list => setBins((list || []).map(b => ({ ...b, count: b.asset_count || 0, icon: b.type || 'grid' })))) .catch(e => window.alert('Could not create bin: ' + e.message)); }; const [view, setView] = React.useState('grid'); - const [filter, setFilter] = React.useState('all'); + const [filter, setFilter] = React.useState('all'); // 'all', 'ready', 'processing', 'live', 'error', 'recent' const [search, setSearch] = React.useState(window._dfPendingSearch || ''); React.useEffect(() => { delete window._dfPendingSearch; }, []); // Local state lets us re-render after delete / move-to-bin without forcing @@ -52,22 +50,24 @@ function Library({ navigate, onOpenAsset, openProject }) { const [creatingBin, setCreatingBin] = React.useState(false); const [newBinName, setNewBinName] = React.useState(''); const [draggingAssetId, setDraggingAssetId] = React.useState(null); + const [dragOverBinId, setDragOverBinId] = React.useState(null); const [recentlyMovedId, setRecentlyMovedId] = React.useState(null); // Rename project state const [renamingProject, setRenamingProject] = React.useState(null); + const [projVersion, setProjVersion] = React.useState(0); const refreshAssets = React.useCallback(() => { window.ZAMPP_API.fetch('/assets?limit=500') .then(r => { const list = Array.isArray(r) ? r : (r.assets || []); const proj = {}; - (PROJECTS || []).forEach(p => { proj[p.id] = p.name; }); + (window.ZAMPP_DATA?.PROJECTS || []).forEach(p => { proj[p.id] = p.name; }); const normalized = list.map(a => window.normalizeAsset ? window.normalizeAsset(a, proj) : a); window.ZAMPP_DATA.ASSETS = normalized; setAllAssets(normalized); }) .catch(() => {}); - }, [PROJECTS]); + }, []); // Auto-refresh: poll the library while it's open so live recordings flip // to 'ready' (with thumbnail) without a manual reload. Speed up while @@ -115,9 +115,14 @@ function Library({ navigate, onOpenAsset, openProject }) { setDraggingAssetId(assetId); }; - const onBinDragOver = function(e) { + const onBinDragOver = function(binId, e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; + if (dragOverBinId !== binId) setDragOverBinId(binId); + }; + + const onBinDragLeave = function() { + setDragOverBinId(null); }; const onBinDrop = function(binId, e) { @@ -125,6 +130,13 @@ function Library({ navigate, onOpenAsset, openProject }) { setDraggingAssetId(null); var assetId = e.dataTransfer.getData('text/plain'); if (!assetId) return; + // Guard against cross-project moves + var asset = allAssets.find(function(a) { return a.id === assetId; }); + var targetBin = bins.find(function(b) { return b.id === binId; }); + if (asset && targetBin && asset.project_id && targetBin.project_id && asset.project_id !== targetBin.project_id) { + alert('Cannot move asset to a bin in a different project.'); + return; + } window.ZAMPP_API.fetch('/assets/' + assetId, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) }) .then(function() { setRecentlyMovedId(assetId); @@ -134,10 +146,6 @@ function Library({ navigate, onOpenAsset, openProject }) { .catch(function(e2) { alert('Move failed: ' + e2.message); }); }; - const onBinDragLeave = function(e) { - // Remove highlight — handled via CSS :hover + drag state - }; - // Project rename const renameProject = function(p) { setRenamingProject(p); }; @@ -171,7 +179,11 @@ function Library({ navigate, onOpenAsset, openProject }) { ? allAssets.filter(function(a) { return a.project_id === openProject.id; }) : allAssets; const ALL_ASSETS = allAssets; - if (filter !== 'all') assets = assets.filter(function(a) { return a.status === filter; }); + if (filter === 'recent') { + assets = assets.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }); + } else if (filter !== 'all') { + assets = assets.filter(function(a) { return a.status === filter; }); + } if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); }); if (selectedBinId) assets = assets.filter(function(a) { return a.bin_id === selectedBinId; }); @@ -188,7 +200,7 @@ function Library({ navigate, onOpenAsset, openProject }) {

Projects

-
+
All projects {ALL_ASSETS.length} @@ -239,12 +251,12 @@ function Library({ navigate, onOpenAsset, openProject }) {
) : BINS.map(function(b) { const isActive = selectedBinId === b.id; - const isDragTarget = draggingAssetId !== null; + const isDragTarget = draggingAssetId !== null && dragOverBinId === b.id; return (
Smart filters
{errorCount > 0 &&
Errors{errorCount}
} -
Last 24h{recentCount}
+
Last 24h{recentCount}
Ready{ALL_ASSETS.filter(function(a) { return a.status === 'ready'; }).length}
@@ -365,7 +377,25 @@ function Library({ navigate, onOpenAsset, openProject }) { )}
diff --git a/services/web-ui/public/screens-projects.jsx b/services/web-ui/public/screens-projects.jsx index 5673ee4..99d6e30 100644 --- a/services/web-ui/public/screens-projects.jsx +++ b/services/web-ui/public/screens-projects.jsx @@ -56,7 +56,7 @@ function Projects({ onOpenProject, navigate }) { const updated = (list || []).map((p, i) => ({ ...p, color: (window.ZAMPP_DATA.PROJECTS.find(x => x.id === p.id) || {}).color - || window.PROJECT_COLORS?.[i % (window.PROJECT_COLORS?.length || 1)] + || (window.PROJECT_COLORS ? window.PROJECT_COLORS[p.id.charCodeAt(p.id.length - 1) % window.PROJECT_COLORS.length] : null) || 'var(--accent)', assets: (ASSETS || []).filter(a => a.project_id === p.id).length, updated: window.ZAMPP_API.fmtRelative(p.updated_at),