// screens-library.jsx function Library({ navigate, onOpenAsset, openProject }) { const { BINS, PROJECTS } = window.ZAMPP_DATA; const [view, setView] = React.useState('grid'); const [filter, setFilter] = React.useState('all'); 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 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; }); const normalized = list.map(a => window.normalizeAsset ? window.normalizeAsset(a, proj) : a); window.ZAMPP_DATA.ASSETS = normalized; setAllAssets(normalized); }) .catch(() => {}); }, [PROJECTS]); // 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 }); }; let assets = 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 (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); }); const displayTitle = 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 && ( )}
); } function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen }) { 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() { onClose(); const next = prompt('Rename asset', asset.display_name || asset.name || ''); if (next == null) return; const trimmed = next.trim(); if (!trimmed || trimmed === (asset.display_name || asset.name)) return; window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ display_name: trimmed }) }) .then(onChanged) .catch(function(e) { alert('Rename failed: ' + e.message); }); }; const moveToBin = function(binId) { onClose(); window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) }) .then(onChanged) .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}
{(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 }) { 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.status === 'live' && LIVE} {asset.status === 'processing' && Processing} {asset.status === 'error' && Error}
{(asset.type === 'video' || !asset.type) && asset.duration !== '—' &&
{asset.duration}
}
{asset.name}
{asset.res} · {asset.size}
); } function binIcon(name) { return { grid: 'library', live: 'record', film: 'film', proxy: 'proxy', audio: 'audio', package: 'package' }[name] || 'folder'; } window.Library = Library; window.AssetCard = AssetCard;