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
This commit is contained in:
parent
af905cf936
commit
d94ed00312
5 changed files with 52 additions and 585 deletions
|
|
@ -70,7 +70,7 @@ function App() {
|
||||||
switch (route) {
|
switch (route) {
|
||||||
case 'home': content = <Home navigate={navigate} />; break;
|
case 'home': content = <Home navigate={navigate} />; break;
|
||||||
case 'dashboard': content = <Dashboard navigate={navigate} />; break;
|
case 'dashboard': content = <Dashboard navigate={navigate} />; break;
|
||||||
case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} />; break;
|
case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} />; break;
|
||||||
case 'projects': content = <Projects navigate={navigate} onOpenProject={openProjectFromAnywhere} />; break;
|
case 'projects': content = <Projects navigate={navigate} onOpenProject={openProjectFromAnywhere} />; break;
|
||||||
case 'upload': content = <Upload navigate={navigate} />; break;
|
case 'upload': content = <Upload navigate={navigate} />; break;
|
||||||
case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;
|
case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ window.ZAMPP_DATA = {
|
||||||
async function apiFetch(path, opts = {}) {
|
async function apiFetch(path, opts = {}) {
|
||||||
const res = await fetch(API + path, {
|
const res = await fetch(API + path, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
|
||||||
...opts,
|
...opts,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
||||||
});
|
});
|
||||||
// 401 from any API call means there's no live session. Bounce to the
|
// 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.
|
// login screen instead of leaving the app in a half-loaded state.
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// screens-library.jsx
|
// screens-library.jsx
|
||||||
|
|
||||||
function Library({ navigate, onOpenAsset, openProject }) {
|
function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
||||||
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
|
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
|
||||||
const [bins, setBins] = React.useState(window.ZAMPP_DATA?.BINS || []);
|
const [bins, setBins] = React.useState(window.ZAMPP_DATA?.BINS || []);
|
||||||
const BINS = bins; // legacy local name; keep so the rest of the function reads unchanged
|
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',
|
method: 'POST',
|
||||||
body: JSON.stringify({ project_id: openProject.id, name: name.trim() }),
|
body: JSON.stringify({ project_id: openProject.id, name: name.trim() }),
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => window.ZAMPP_API.fetch('/bins?project_id=' + openProject.id))
|
||||||
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(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));
|
.catch(e => window.alert('Could not create bin: ' + e.message));
|
||||||
};
|
};
|
||||||
const [view, setView] = React.useState('grid');
|
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 || '');
|
const [search, setSearch] = React.useState(window._dfPendingSearch || '');
|
||||||
React.useEffect(() => { delete window._dfPendingSearch; }, []);
|
React.useEffect(() => { delete window._dfPendingSearch; }, []);
|
||||||
// Local state lets us re-render after delete / move-to-bin without forcing
|
// 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 [creatingBin, setCreatingBin] = React.useState(false);
|
||||||
const [newBinName, setNewBinName] = React.useState('');
|
const [newBinName, setNewBinName] = React.useState('');
|
||||||
const [draggingAssetId, setDraggingAssetId] = React.useState(null);
|
const [draggingAssetId, setDraggingAssetId] = React.useState(null);
|
||||||
|
const [dragOverBinId, setDragOverBinId] = React.useState(null);
|
||||||
const [recentlyMovedId, setRecentlyMovedId] = React.useState(null);
|
const [recentlyMovedId, setRecentlyMovedId] = React.useState(null);
|
||||||
// Rename project state
|
// Rename project state
|
||||||
const [renamingProject, setRenamingProject] = React.useState(null);
|
const [renamingProject, setRenamingProject] = React.useState(null);
|
||||||
|
const [projVersion, setProjVersion] = React.useState(0);
|
||||||
|
|
||||||
const refreshAssets = React.useCallback(() => {
|
const refreshAssets = React.useCallback(() => {
|
||||||
window.ZAMPP_API.fetch('/assets?limit=500')
|
window.ZAMPP_API.fetch('/assets?limit=500')
|
||||||
.then(r => {
|
.then(r => {
|
||||||
const list = Array.isArray(r) ? r : (r.assets || []);
|
const list = Array.isArray(r) ? r : (r.assets || []);
|
||||||
const proj = {};
|
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);
|
const normalized = list.map(a => window.normalizeAsset ? window.normalizeAsset(a, proj) : a);
|
||||||
window.ZAMPP_DATA.ASSETS = normalized;
|
window.ZAMPP_DATA.ASSETS = normalized;
|
||||||
setAllAssets(normalized);
|
setAllAssets(normalized);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [PROJECTS]);
|
}, []);
|
||||||
|
|
||||||
// Auto-refresh: poll the library while it's open so live recordings flip
|
// Auto-refresh: poll the library while it's open so live recordings flip
|
||||||
// to 'ready' (with thumbnail) without a manual reload. Speed up while
|
// to 'ready' (with thumbnail) without a manual reload. Speed up while
|
||||||
|
|
@ -115,9 +115,14 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
||||||
setDraggingAssetId(assetId);
|
setDraggingAssetId(assetId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onBinDragOver = function(e) {
|
const onBinDragOver = function(binId, e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
if (dragOverBinId !== binId) setDragOverBinId(binId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBinDragLeave = function() {
|
||||||
|
setDragOverBinId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onBinDrop = function(binId, e) {
|
const onBinDrop = function(binId, e) {
|
||||||
|
|
@ -125,6 +130,13 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
||||||
setDraggingAssetId(null);
|
setDraggingAssetId(null);
|
||||||
var assetId = e.dataTransfer.getData('text/plain');
|
var assetId = e.dataTransfer.getData('text/plain');
|
||||||
if (!assetId) return;
|
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 }) })
|
window.ZAMPP_API.fetch('/assets/' + assetId, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) })
|
||||||
.then(function() {
|
.then(function() {
|
||||||
setRecentlyMovedId(assetId);
|
setRecentlyMovedId(assetId);
|
||||||
|
|
@ -134,10 +146,6 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
||||||
.catch(function(e2) { alert('Move failed: ' + e2.message); });
|
.catch(function(e2) { alert('Move failed: ' + e2.message); });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onBinDragLeave = function(e) {
|
|
||||||
// Remove highlight — handled via CSS :hover + drag state
|
|
||||||
};
|
|
||||||
|
|
||||||
// Project rename
|
// Project rename
|
||||||
const renameProject = function(p) { setRenamingProject(p); };
|
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.filter(function(a) { return a.project_id === openProject.id; })
|
||||||
: allAssets;
|
: allAssets;
|
||||||
const ALL_ASSETS = 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 (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; });
|
if (selectedBinId) assets = assets.filter(function(a) { return a.bin_id === selectedBinId; });
|
||||||
|
|
||||||
|
|
@ -188,7 +200,7 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
||||||
<div>
|
<div>
|
||||||
<h4>Projects</h4>
|
<h4>Projects</h4>
|
||||||
<div className="rail-list">
|
<div className="rail-list">
|
||||||
<div className={`rail-item ${!openProject ? 'active' : ''}`} onClick={function() { navigate('library'); }} style={{ cursor: 'pointer' }}>
|
<div className={`rail-item ${!openProject ? 'active' : ''}`} onClick={function() { if (onClearProject) onClearProject(); navigate('library'); }} style={{ cursor: 'pointer' }}>
|
||||||
<Icon name="library" size={13} className="rail-icon" />
|
<Icon name="library" size={13} className="rail-icon" />
|
||||||
<span>All projects</span>
|
<span>All projects</span>
|
||||||
<span className="rail-count">{ALL_ASSETS.length}</span>
|
<span className="rail-count">{ALL_ASSETS.length}</span>
|
||||||
|
|
@ -239,12 +251,12 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
||||||
</div>
|
</div>
|
||||||
) : BINS.map(function(b) {
|
) : BINS.map(function(b) {
|
||||||
const isActive = selectedBinId === b.id;
|
const isActive = selectedBinId === b.id;
|
||||||
const isDragTarget = draggingAssetId !== null;
|
const isDragTarget = draggingAssetId !== null && dragOverBinId === b.id;
|
||||||
return (
|
return (
|
||||||
<div key={b.id}
|
<div key={b.id}
|
||||||
className={'rail-item' + (isActive ? ' active' : '') + (isDragTarget ? ' droppable' : '')}
|
className={'rail-item' + (isActive ? ' active' : '') + (isDragTarget ? ' droppable' : '')}
|
||||||
onClick={function() { setSelectedBinId(isActive ? null : b.id); }}
|
onClick={function() { setSelectedBinId(isActive ? null : b.id); }}
|
||||||
onDragOver={onBinDragOver}
|
onDragOver={function(e) { onBinDragOver(b.id, e); }}
|
||||||
onDrop={function(e) { onBinDrop(b.id, e); }}
|
onDrop={function(e) { onBinDrop(b.id, e); }}
|
||||||
onDragLeave={onBinDragLeave}
|
onDragLeave={onBinDragLeave}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
|
|
@ -261,7 +273,7 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
||||||
<h4>Smart filters</h4>
|
<h4>Smart filters</h4>
|
||||||
<div className="rail-list">
|
<div className="rail-list">
|
||||||
{errorCount > 0 && <div className="rail-item" onClick={function() { setFilter('error'); }} style={{ cursor: 'pointer' }}><Icon name="alert" size={13} className="rail-icon" /><span>Errors</span><span className="rail-count">{errorCount}</span></div>}
|
{errorCount > 0 && <div className="rail-item" onClick={function() { setFilter('error'); }} style={{ cursor: 'pointer' }}><Icon name="alert" size={13} className="rail-icon" /><span>Errors</span><span className="rail-count">{errorCount}</span></div>}
|
||||||
<div className="rail-item" onClick={function() { setFilter('all'); }} style={{ cursor: 'pointer' }}><Icon name="clock" size={13} className="rail-icon" /><span>Last 24h</span><span className="rail-count">{recentCount}</span></div>
|
<div className={'rail-item' + (filter === 'recent' ? ' active' : '')} onClick={function() { setFilter(filter === 'recent' ? 'all' : 'recent'); }} style={{ cursor: 'pointer' }}><Icon name="clock" size={13} className="rail-icon" /><span>Last 24h</span><span className="rail-count">{recentCount}</span></div>
|
||||||
<div className="rail-item" onClick={function() { setFilter('ready'); }} style={{ cursor: 'pointer' }}><Icon name="check" size={13} className="rail-icon" /><span>Ready</span><span className="rail-count">{ALL_ASSETS.filter(function(a) { return a.status === 'ready'; }).length}</span></div>
|
<div className="rail-item" onClick={function() { setFilter('ready'); }} style={{ cursor: 'pointer' }}><Icon name="check" size={13} className="rail-icon" /><span>Ready</span><span className="rail-count">{ALL_ASSETS.filter(function(a) { return a.status === 'ready'; }).length}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -365,7 +377,25 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
||||||
<RenameProjectModal
|
<RenameProjectModal
|
||||||
project={renamingProject}
|
project={renamingProject}
|
||||||
onClose={function() { setRenamingProject(null); }}
|
onClose={function() { setRenamingProject(null); }}
|
||||||
onSaved={function() { setRenamingProject(null); }}
|
onSaved={function() {
|
||||||
|
setRenamingProject(null);
|
||||||
|
// Re-fetch projects and update ZAMPP_DATA so the rail refreshes
|
||||||
|
window.ZAMPP_API.fetch('/projects').then(function(list) {
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
window.ZAMPP_DATA.PROJECTS = list.map(function(p, i) {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
color: (window.ZAMPP_DATA.PROJECTS.find(function(x) { return x.id === p.id; }) || {}).color
|
||||||
|
|| window.PROJECT_COLORS?.[i % (window.PROJECT_COLORS?.length || 1)]
|
||||||
|
|| 'var(--accent)',
|
||||||
|
assets: (window.ZAMPP_DATA?.ASSETS || []).filter(function(a) { return a.project_id === p.id; }).length,
|
||||||
|
updated: window.ZAMPP_API.fmtRelative(p.updated_at),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setProjVersion(function(v) { return v + 1; });
|
||||||
|
}
|
||||||
|
}).catch(function() {});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ function Projects({ onOpenProject, navigate }) {
|
||||||
const updated = (list || []).map((p, i) => ({
|
const updated = (list || []).map((p, i) => ({
|
||||||
...p,
|
...p,
|
||||||
color: (window.ZAMPP_DATA.PROJECTS.find(x => x.id === p.id) || {}).color
|
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)',
|
|| 'var(--accent)',
|
||||||
assets: (ASSETS || []).filter(a => a.project_id === p.id).length,
|
assets: (ASSETS || []).filter(a => a.project_id === p.id).length,
|
||||||
updated: window.ZAMPP_API.fmtRelative(p.updated_at),
|
updated: window.ZAMPP_API.fmtRelative(p.updated_at),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue