// screens-library.jsx
function Library({ navigate, onOpenAsset, openProject }) {
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.
React.useEffect(() => {
const qs = openProject ? '?project_id=' + openProject.id : '';
window.ZAMPP_API.fetch('/bins' + qs)
.then(list => {
const normalized = (list || []).map(b => ({
...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(() => {});
}, [openProject]);
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');
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);
const [creatingBin, setCreatingBin] = React.useState(false);
const [newBinName, setNewBinName] = React.useState('');
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]);
// Auto-refresh: poll the library while it's open so live recordings flip
// to 'ready' (with thumbnail) without a manual reload. Speed up while
// anything is mid-flight so the operator sees the transition right away.
const hasLive = React.useMemo(
() => allAssets.some(a => a.status === 'live' || a.status === 'processing'),
[allAssets]
);
React.useEffect(() => {
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 });
};
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 !== '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.duration}
{a.res}
{a.codec || '—'}
{a.size}
{a.updated}
);
})}
)}
{ctxMenu && (
)}
{renamingAsset && (
)}
);
}
function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRename }) {
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(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 (
{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';
}
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 (
{err &&
{err}
}
);
}
window.Library = Library;
window.AssetCard = AssetCard;