diff --git a/services/web-ui/public/screens-library.jsx b/services/web-ui/public/screens-library.jsx
index 1b318d5..faa82e0 100644
--- a/services/web-ui/public/screens-library.jsx
+++ b/services/web-ui/public/screens-library.jsx
@@ -1,863 +1,85 @@
// screens-library.jsx
+// visuals.jsx - reusable visual elements
-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
+const _thumbCache = new Map();
- // 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]);
+function AssetThumb({ asset, size = 'md' }) {
+ const aspect = size === 'tall' ? '9 / 16' : '16 / 9';
+ const [thumbUrl, setThumbUrl] = React.useState(_thumbCache.get(asset.id) || null);
- 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);
- const [confirm, confirmModal] = window.useConfirm();
- // 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);
- // Multi-select state
- const [selectedAssets, setSelectedAssets] = React.useState(new Set());
- const [selectionMode, setSelectionMode] = React.useState(false);
-
- const refreshAssets = React.useCallback(() => {
- window.ZAMPP_API.refreshAssets()
- .then(function(normalized) {
- setAllAssets(normalized);
- })
+ React.useEffect(() => {
+ if (!asset.id || thumbUrl || !asset.thumbnail_s3_key) return;
+ let cancelled = false;
+ fetch((window.ZAMPP_API_PREFIX || '/api/v1') + '/assets/' + asset.id + '/thumbnail', { credentials: 'include' })
+ .then(r => r.ok ? r.json() : null)
+ .then(d => { if (!cancelled && d && d.url) { _thumbCache.set(asset.id, d.url); setThumbUrl(d.url); } })
.catch(() => {});
- }, []);
+ return () => { cancelled = true; };
+ }, [asset.id, asset.thumbnail_s3_key]);
- const toggleSelection = function(assetId, e) {
- if (e) e.stopPropagation();
- setSelectedAssets(function(prev) {
- var next = new Set(prev);
- if (next.has(assetId)) next.delete(assetId);
- else next.add(assetId);
- return next;
- });
- };
-
- const selectAll = function() {
- setSelectedAssets(new Set(assets.map(function(a) { return a.id; })));
- };
-
- const clearSelection = function() {
- setSelectedAssets(new Set());
- setSelectionMode(false);
- };
-
- const bulkMoveToBin = async function(binId) {
- var ids = Array.from(selectedAssets);
- if (ids.length === 0) return;
- var targetBin = bins.find(function(b) { return b.id === binId; });
- for (var i = 0; i < ids.length; i++) {
- var asset = allAssets.find(function(a) { return a.id === ids[i]; });
- if (asset && targetBin && asset.project_id && targetBin.project_id && asset.project_id !== targetBin.project_id) {
- alert('Cannot move assets to a bin in a different project.');
- return;
- }
- }
- try {
- await Promise.all(ids.map(function(id) {
- return window.ZAMPP_API.fetch('/assets/' + id, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) });
- }));
- refreshAssets();
- window.dispatchEvent(new Event('df:bins-changed'));
- clearSelection();
- } catch (e) {
- alert('Bulk move failed: ' + e.message);
- }
- };
-
- const bulkDelete = async function() {
- var ids = Array.from(selectedAssets);
- if (ids.length === 0) return;
- if (!(await confirm({
- title: 'Delete ' + ids.length + ' assets?',
- message: 'Delete ' + ids.length + ' assets permanently?\nThis removes the database rows and S3 objects.\nThis cannot be undone.',
- }))) return;
- try {
- await Promise.all(ids.map(function(id) {
- return window.ZAMPP_API.fetch('/assets/' + id + '?hard=true', { method: 'DELETE' });
- }));
- refreshAssets();
- clearSelection();
- } catch (e) {
- alert('Bulk delete failed: ' + e.message);
- }
- };
-
- const deleteAsset = React.useCallback(async (asset) => {
- if (!(await confirm({
- title: 'Delete asset?',
- message: '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(refreshAssets)
- .catch(function(e) { alert('Delete failed: ' + e.message); });
- }, [confirm, refreshAssets]);
-
- // 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 (asset.type === 'audio' || asset.media_type === 'audio') {
+ return (
+
+ );
}
- 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 (
-
- {confirmModal}
-