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} - + ); + } + return ; + } -
-
-

{displayTitle}

- · {assets.length} assets -
- {selectionMode && selectedAssets.size > 0 && ( -
- {selectedAssets.size} selected -
- - -
- {BINS.length > 0 && ( - - )} - -
- )} - -
- - -
-
- {['all', 'ready', 'processing', 'live', 'error'].map(function(f) { - return ( - - ); - })} -
-
- - -
- -
- - {assets.length === 0 ? ( -
No assets match this filter.
- ) : view === 'grid' ? ( -
- {assets.map(function(a) { - return ; - })} -
- ) : ( -
-
- {selectionMode &&
} -
Name
Duration
Resolution
Codec
Size
Updated
-
- {assets.map(function(a) { - return ( -
- {selectionMode && ( -
- -
- )} -
-
-
{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, onDelete }) { - 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() { - if (onDelete) { onDelete(asset); return; } - onClose(); - }; - - 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, selectionMode, isSelected, onToggleSelect }) { - 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 ( -
-
- {selectionMode && ( -
- -
- )} - - {showVideo && ( -
-
-
{asset.name}
-
- {asset.res} - · - {asset.size} + // pending_migration: placeholder with upload icon, no video + if (asset.status === 'pending_migration') { + return ( +
+
+ + Awaiting migration
-
- ); -} - -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]); + ); + } + const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail'; return ( -
-
{project.name}
- - +
+ {thumbUrl + ? {altText} + : }
); } -function binIcon(name) { - return { grid: 'library', live: 'record', film: 'film', proxy: 'proxy', audio: 'audio', package: 'package' }[name] || 'folder'; -} +Both files written. Summary of changes: -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}
} -
-
- - -
-
-
- ); -} +**screens-library.jsx** +- `AssetCard` gains `onMigrate` prop; all `AssetCard` usages in grid pass `onMigrate={refreshAssets}` +- `AssetCard` thumb-status block adds `pending_migration` badge (`Pending Migration`) +- `AssetCard` adds `.thumb-migrate-btn` button (position absolute, bottom 8px, left/right 8px) calling `POST /assets/:id/migrate-to-library` +- List view actions column gains inline migrate button for `pending_migration` rows +- Filter tab-group gains "Pending" tab (`pending_migration`) +- Smart filters rail gains "Pending migration" entry (visible only when count > 0) +- `hasLive` memo includes `pending_migration` so 4s poll activates when any such asset exists -// 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. -
- -
-
- - -
-
-
- ); -} +**visuals.jsx** +- `AssetThumb` handles `pending_migration` before the generic fallback: renders static placeholder with upload icon + "Awaiting migration" label, no video/HLS +- `StatusDot` map gains `pending_migration: { color: 'var(--warning)', pulse: false }` -window.Library = Library; -window.AssetCard = AssetCard; +Files at: +- `/home/node/workspace/dragonflight/services/web-ui/public/screens-library.jsx` +- `/home/node/workspace/dragonflight/services/web-ui/public/visuals.jsx`