// screens-library.jsx 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 // Re-fetch bins on mount + whenever the open project changes; surfaces // every-project bins when the global view is on, project-scoped otherwise. var refreshBins = React.useCallback(function() { var qs2 = openProject ? '?project_id=' + openProject.id : ''; window.ZAMPP_API.fetch('/bins' + qs2) .then(function(list) { var normalized = (list || []).map(function(b) { return { ...b, count: b.asset_count != null ? b.asset_count : (b.count || 0), icon: b.type || 'grid' }; }); if (!openProject) window.ZAMPP_DATA.BINS = normalized; setBins(normalized); }) .catch(function() {}); }, [openProject]); React.useEffect(function() { refreshBins(); var onBinsChanged = function() { refreshBins(); }; window.addEventListener('df:bins-changed', onBinsChanged); return function() { window.removeEventListener('df:bins-changed', onBinsChanged); }; }, [refreshBins]); const createBin = () => { if (!openProject) { window.alert('Open a project first (Projects → click a project), then create a bin inside it.'); return; } setNewBinName(''); setCreatingBin(true); }; const submitBin = (name) => { if (!name || !name.trim()) { setCreatingBin(false); return; } setCreatingBin(false); window.ZAMPP_API.fetch('/bins', { 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' })))) .catch(e => window.alert('Could not create bin: ' + e.message)); }; const [view, setView] = React.useState('grid'); 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 // a full app reload - keeps ZAMPP_DATA in sync as the cache of record. const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []); const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y } const [renamingAsset, setRenamingAsset] = React.useState(null); // Asset queued for hi-res download. Null means no modal showing. Set when // the user clicks Download and has NOT dismissed the "are you sure" warning. const [pendingDownload, setPendingDownload] = React.useState(null); // Single entry point for hi-res downloads from the library. If the user // ticked "Don't show again" in the warning modal, skip straight to the // download; otherwise pop the modal with this asset queued. The modal's // confirm handler calls runDownload directly. const requestDownload = function(asset) { if (!asset || !asset.original_s3_key) return; try { if (localStorage.getItem('df.lib.download.warnDismissed') === '1') { runDownload(asset); return; } } catch (_) { /* localStorage unavailable: show modal to be safe */ } setPendingDownload(asset); }; 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.refreshAssets() .then(function(normalized) { setAllAssets(normalized); }) .catch(() => {}); }, []); // Auto-refresh: poll the library while it's open so live recordings flip // to 'ready' (with thumbnail) without a manual reload. Also pull once on // mount so uploads/imports created on other screens appear immediately. const hasLive = React.useMemo( () => allAssets.some(a => a.status === 'live' || a.status === 'processing' || a.status === 'ingesting'), [allAssets] ); React.useEffect(() => { refreshAssets(); const tick = hasLive ? 4000 : 15000; const id = setInterval(refreshAssets, tick); const onAssetsChanged = () => refreshAssets(); window.addEventListener('df:assets-changed', onAssetsChanged); return () => { clearInterval(id); window.removeEventListener('df:assets-changed', onAssetsChanged); }; }, [hasLive, refreshAssets]); // Dismiss the context menu on any outside click (capture phase so clicking // 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 openCtx = (asset, e) => { e.preventDefault(); e.stopPropagation(); setCtxMenu({ asset, x: e.clientX, y: e.clientY }); }; // Drag-and-drop: asset → bin const onAssetDragStart = function(assetId, e) { e.dataTransfer.setData('text/plain', assetId); e.dataTransfer.effectAllowed = 'move'; setDraggingAssetId(assetId); }; 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) { e.preventDefault(); 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); refreshAssets(); window.dispatchEvent(new Event('df:bins-changed')); setTimeout(function() { setRecentlyMovedId(null); }, 2000); }) .catch(function(e2) { alert('Move failed: ' + e2.message); }); }; // Project rename const renameProject = function(p) { setRenamingProject(p); }; // Close drag state when drag ends anywhere React.useEffect(function() { if (!draggingAssetId) return; var onEnd = function() { setDraggingAssetId(null); }; window.addEventListener('dragend', onEnd); return function() { window.removeEventListener('dragend', onEnd); }; }, [draggingAssetId]); // Project context menu state var [projectCtx, setProjectCtx] = React.useState(null); React.useEffect(function() { if (!projectCtx) return; var close = function() { setProjectCtx(null); }; // #49: add contextmenu + scroll dismiss so right-clicking another project // or scrolling away doesn't leave the menu orphaned window.addEventListener('click', close); window.addEventListener('contextmenu', close); window.addEventListener('scroll', close, true); return function() { window.removeEventListener('click', close); window.removeEventListener('contextmenu', close); window.removeEventListener('scroll', close, true); }; }, [projectCtx]); var openProjectCtx = function(p, e) { e.preventDefault(); e.stopPropagation(); setProjectCtx({ project: p, x: e.clientX, y: e.clientY }); }; const [selectedBinId, setSelectedBinId] = React.useState(null); // Clear bin filter on project change so a stale id doesn't hide everything. React.useEffect(() => { setSelectedBinId(null); }, [openProject?.id]); let assets = openProject ? allAssets.filter(function(a) { return a.project_id === openProject.id; }) : allAssets; const ALL_ASSETS = allAssets; 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; }); const activeBin = selectedBinId ? BINS.find(b => b.id === selectedBinId) : null; const displayTitle = activeBin ? (openProject ? openProject.name + ' · ' : '') + activeBin.name : (openProject ? openProject.name : 'All Assets'); const errorCount = ALL_ASSETS.filter(function(a) { return a.status === 'error'; }).length; const recentCount = ALL_ASSETS.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }).length; return (

{displayTitle}

· {assets.length} assets
{['all', 'ready', 'processing', 'live', 'error'].map(function(f) { return ( ); })}
{assets.length === 0 ? (
No assets match this filter.
) : view === 'grid' ? (
{assets.map(function(a) { return ; })}
) : (
Name
Duration
Resolution
Codec
Size
Updated
{assets.map(function(a) { return (
{a.name}
{a.status}
{a.duration}
{a.res}
{a.codec || '·'}
{a.size}
{a.updated}
); })}
)}
{ctxMenu && ( )} {pendingDownload && ( )} {renamingAsset && ( )} {projectCtx && ( )} {renamingProject && window.RenameProjectModal && ( React.createElement(window.RenameProjectModal, { project: renamingProject, onClose: 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() {}); } }) )}
); } // Hi-res download trigger shared by the card and the context menu. Resolves // a presigned S3 URL via /assets/:id/hires (returns { url, filename, ext }) // and clicks a hidden anchor so the browser does the download. The download // itself is direct S3 - never proxied through mam-api - so big files don't // touch the API container. function runDownload(asset) { if (!asset || !asset.id) return; return window.ZAMPP_API.fetch('/assets/' + asset.id + '/hires') .then(function(r) { if (!r || !r.url) { alert('No hi-res source available for this asset.'); return; } const a = document.createElement('a'); a.href = r.url; a.download = r.filename || ((asset.display_name || asset.name || asset.id) + '.' + (r.ext || 'mov')); a.target = '_blank'; a.rel = 'noopener'; document.body.appendChild(a); a.click(); document.body.removeChild(a); }) .catch(function(e) { alert('Download failed: ' + (e.message || 'unknown error')); }); } function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRename, onDownload }) { const ref = React.useRef(null); // Pin the menu inside the viewport even if the user right-clicked near // the bottom-right edge of the grid. 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 rename = function() { if (onRename) onRename(asset); else onClose(); }; const moveToBin = function(binId) { onClose(); window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) }) .then(function() { onChanged(); window.dispatchEvent(new Event('df:bins-changed')); }) .catch(function(e) { alert('Move failed: ' + e.message); }); }; const copyId = function() { onClose(); if (navigator.clipboard) navigator.clipboard.writeText(asset.id).catch(function() {}); }; const remove = function() { onClose(); if (!confirm('Delete "' + (asset.display_name || asset.name) + '" permanently?\nThis removes the database row and the S3 objects.\nThis cannot be undone.')) return; window.ZAMPP_API.fetch('/assets/' + asset.id + '?hard=true', { method: 'DELETE' }) .then(onChanged) .catch(function(e) { alert('Delete failed: ' + e.message); }); }; return (
{asset.display_name || asset.name}
{asset.original_s3_key && onDownload && ( )}
{(bins && bins.length > 0) ? ( <>
Move to bin
{bins .filter(function(b) { return !asset.project_id || b.project_id === asset.project_id; }) .slice(0, 10) .map(function(b) { const isCurrent = asset.bin_id === b.id; return ( ); })} {asset.bin_id && ( )} ) : (
No bins: create one inside a project
)}
); } function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, draggable }) { const [hoverStream, setHoverStream] = React.useState(null); const [hovered, setHovered] = React.useState(false); const timerRef = React.useRef(null); const hlsRef = React.useRef(null); const videoRef = React.useRef(null); const handleMouseEnter = function() { timerRef.current = setTimeout(function() { setHovered(true); if (!hoverStream) { window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream') .then(function(r) { if (r && r.url) setHoverStream(r); }) .catch(function() {}); } }, 350); }; const handleMouseLeave = function() { clearTimeout(timerRef.current); setHovered(false); }; // HLS wiring React.useEffect(function() { if (!hovered || !hoverStream || hoverStream.type !== 'hls' || !videoRef.current) return; if (!window.Hls) return; hlsRef.current = new window.Hls({ maxBufferLength: 10 }); hlsRef.current.loadSource(hoverStream.url); hlsRef.current.attachMedia(videoRef.current); return function() { if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } }; }, [hovered, hoverStream]); const showVideo = hovered && hoverStream; return (
{showVideo && (
{asset.name}
{asset.res} · {asset.size}
); } function ProjectContextMenu({ project, x, y, onClose, onRename }) { var ref = React.useRef(null); var [pos, setPos] = React.useState({ left: x, top: y }); React.useLayoutEffect(function() { if (!ref.current) return; var r = ref.current.getBoundingClientRect(); var margin = 8; var 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]); return (
{project.name}
); } function binIcon(name) { return { grid: 'library', live: 'record', film: 'film', proxy: 'proxy', audio: 'audio', package: 'package' }[name] || 'folder'; } function RenameAssetModal({ asset, onClose, onSaved }) { const [name, setName] = React.useState(asset.display_name || asset.name || ''); const [saving, setSaving] = React.useState(false); const [err, setErr] = React.useState(null); const original = asset.display_name || asset.name || ''; const submit = function() { const trimmed = name.trim(); if (!trimmed || trimmed === original) { onClose(); return; } setSaving(true); setErr(null); window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ display_name: trimmed }) }) .then(onSaved) .catch(function(e) { setSaving(false); setErr(e.message); }); }; return (
Rename asset
{err &&
{err}
}
); } // First-run-style warning before a hi-res download. Tells the operator the // file is full-length, may be multi-GB, and that speed depends on their // connection. "Don't show again on this device" persists the dismissal so // subsequent downloads (and every Download click after dismissal) skip // straight to the file. Settings → Account exposes a control to re-enable. function DownloadWarningModal({ asset, onClose, onConfirm }) { const [dismiss, setDismiss] = React.useState(false); const name = asset.display_name || asset.name || asset.id; return (
Download original ingest
You're about to download the full-length original ingest for {name}.
Originals are often multi-gigabyte. Download speed depends on your network connection; don't start this on a metered link or a busy uplink.
); } window.Library = Library; window.AssetCard = AssetCard;