dragonflight/services/web-ui/public/screens-library.jsx

754 lines
34 KiB
React
Raw Normal View History

// 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(() => {
2026-05-25 01:13:19 -04:00
window.ZAMPP_API.refreshAssets()
.then(function(normalized) {
setAllAssets(normalized);
})
.catch(() => {});
}, []);
// Auto-refresh: poll the library while it's open so live recordings flip
2026-05-25 01:13:19 -04:00
// 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(
2026-05-25 01:13:19 -04:00
() => allAssets.some(a => a.status === 'live' || a.status === 'processing' || a.status === 'ingesting'),
[allAssets]
);
React.useEffect(() => {
2026-05-25 01:13:19 -04:00
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); };
fix: close all 24 open issues (#40–#94) Bug fixes: - #91: dockerApi() 10s socket timeout (Docker daemon hang) - #77: await syncToAmpp() with .catch() — no longer fire-and-forget - #75: migration 016 — add 'proxy','import' to job_type enum; add 'completed' to job_status - #73: BullMQ orphan job cleanup on hard asset delete - #70: batch-trim jobs table gets expires_at; trim-status auto-expires stale rows - #66: scheduler tick marks stale live assets (>2h) as error - #63: migration 017 — partial unique index prevents concurrent live asset overwrite - #61: recorders.js uses getS3Bucket() not stale process.env.S3_BUCKET - #60: already fixed (copy nulls proxy/thumbnail keys, requeues proxy) - #40: already fixed (All projects clears openProject) - #64: already fixed (sourceType/needsProxy handled) - #90: GET /jobs now includes DB jobs table (trim jobs visible in UI) - #74: nginx Content-Type header preserved; multer 500MB file size limit - #68: GET /upload returns in-progress ingesting assets - #58: /stream and /video endpoints fall back to original file for all video types - #55: recorder poll .catch() logs auth errors cleanly; redirect stops interval - #52: thumb-status and thumb-duration moved inside position:relative wrapper - #50: ProjectCard gets onContextMenu handler with rename/delete menu - #49: project context menu dismisses on contextmenu + scroll events Features: - #93: POST /assets/:id/reprocess?type=proxy|thumbnail — force re-queue any asset Asset ⋯ menu now shows 'Re-generate proxy' and 'Re-generate thumbnail' buttons UI: - Logo: brightness(0) invert(1) filter applied consistently in sidebar, launcher, and login — white logo pops on dark UI; inline style removed from login.html
2026-05-26 10:10:44 -04:00
// #49: add contextmenu + scroll dismiss so right-clicking another project
// or scrolling away doesn't leave the menu orphaned
window.addEventListener('click', close);
fix: close all 24 open issues (#40–#94) Bug fixes: - #91: dockerApi() 10s socket timeout (Docker daemon hang) - #77: await syncToAmpp() with .catch() — no longer fire-and-forget - #75: migration 016 — add 'proxy','import' to job_type enum; add 'completed' to job_status - #73: BullMQ orphan job cleanup on hard asset delete - #70: batch-trim jobs table gets expires_at; trim-status auto-expires stale rows - #66: scheduler tick marks stale live assets (>2h) as error - #63: migration 017 — partial unique index prevents concurrent live asset overwrite - #61: recorders.js uses getS3Bucket() not stale process.env.S3_BUCKET - #60: already fixed (copy nulls proxy/thumbnail keys, requeues proxy) - #40: already fixed (All projects clears openProject) - #64: already fixed (sourceType/needsProxy handled) - #90: GET /jobs now includes DB jobs table (trim jobs visible in UI) - #74: nginx Content-Type header preserved; multer 500MB file size limit - #68: GET /upload returns in-progress ingesting assets - #58: /stream and /video endpoints fall back to original file for all video types - #55: recorder poll .catch() logs auth errors cleanly; redirect stops interval - #52: thumb-status and thumb-duration moved inside position:relative wrapper - #50: ProjectCard gets onContextMenu handler with rename/delete menu - #49: project context menu dismisses on contextmenu + scroll events Features: - #93: POST /assets/:id/reprocess?type=proxy|thumbnail — force re-queue any asset Asset ⋯ menu now shows 'Re-generate proxy' and 'Re-generate thumbnail' buttons UI: - Logo: brightness(0) invert(1) filter applied consistently in sidebar, launcher, and login — white logo pops on dark UI; inline style removed from login.html
2026-05-26 10:10:44 -04:00
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 (
<div className="library-layout">
<aside className="library-rail">
<div>
<h4>Projects</h4>
<div className="rail-list">
<div className={`rail-item ${!openProject ? 'active' : ''}`} onClick={function() { if (onClearProject) onClearProject(); navigate('library'); }} style={{ cursor: 'pointer' }}>
<Icon name="library" size={13} className="rail-icon" />
<span>All projects</span>
<span className="rail-count">{ALL_ASSETS.length}</span>
</div>
{PROJECTS.slice(0, 8).map(function(p) {
return (
<div key={p.id} className={`rail-item ${openProject && openProject.id === p.id ? 'active' : ''}`} style={{ cursor: 'pointer' }}
onClick={function() { navigate('projects'); }}
onContextMenu={function(e) { openProjectCtx(p, e); }}>
<span className="rail-color-dot" style={{ background: p.color }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
<span className="rail-count">{p.assets}</span>
</div>
);
})}
</div>
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<h4 style={{ flex: 1, margin: 0 }}>Bins</h4>
chore: 1.2 ship-prep sweep — close 38 issues Frontend / UX / a11y - Sidebar collapse/expand toggle with localStorage persistence (#142) - Settings sections wrap inputs in <form> with Enter-to-submit + native validation; password autocomplete=new-password (#141, #138) - Asset thumbnails get descriptive alt text (#140) - Production deploy now precompiles JSX via esbuild and loads the production React UMD instead of dev builds + in-browser Babel (#139, #122) - Search wrapper gets role=search; global search input gets aria-label, role=combobox, aria-controls/aria-expanded/aria-activedescendant wiring (#137, #135) - Dashboard and Library no longer share the same nav icon (#136) - Sidebar collapses off-canvas with a topbar menu button below 768 px; mobile default is collapsed (#134) - --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133) - Schedule and Library routes were rendering empty inside the .main flex container — switched to flex:1 + min-height:0 (#131, #132, editor + asset detail get the same fix) - Jobs nav badge now polls /jobs?status=active every 10 s and reflects the live count (#130, #113) - aria-label sweep on every icon-only button (#126) - Premiere panel release list moved to window.PREMIERE_RELEASES in data.jsx; Editor + Settings read from the same source (#125) - Typo setPgMclips → setPgmClips (#124) - Stray console.error / console.warn calls gated behind window.DF_LOG.{warn,error} (#123) - Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115) - Schedule rows no longer crash on null recorder_id (#117) - EditorKeyboard guards against document.activeElement === null (#116) - Unmount-safe timers for PasswordResetModal, Containers, Editor (#111) - Player seek clamps below totalMs, server-side range clamping + uncached 416 on EOF, client-side EOF-stall watchdog (#143) - Duration badge overlap fix on narrow asset cards (#52) Backend / security / reliability - GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id; Docker inspects bounded to actually-recording rows (#121) - Upload disk-storage (multer.diskStorage) streams parts to S3 instead of buffering 500 MB in RAM (#120) - /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119) - SDK upload archive listing + post-extract sanitize block zip-slip / tar-slip and symlink escapes (#118) - Migrations track applied state in schema_migrations, run in a transaction, and exit non-zero on failure (#107) - node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem detection wins (#109, #127) - GPU_COUNT override now merges with nvidia-smi enrichment (#108) - /cluster/heartbeat requires a node-bound token or admin user; tokens carry bound_hostname (#106) - /recorders/:id/start error responses no longer echo the Docker create payload — env vars stay out of client responses (#105) - /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks private + loopback hosts for non-admins, denies common service ports (#104) - Scheduler tick guarded by a Postgres advisory lock; pending/running rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to survive multi-node deploys (#103) - UUID validateUuid('id') param middleware on every /:id route (#102) - Error handler scrubs Postgres error messages and 5xx detail (#101) - Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP server, ends the pool, 25 s force-exit watchdog (#100) - AMPP sync moved from fire-and-forget to a persisted retry queue (ampp_sync_status / attempts / next_attempt_at + scheduler retry loop with exponential backoff) (#77) Migrations - 019: api_tokens.bound_hostname (#106) - 020: assets.ampp_sync_status + retry bookkeeping (#77) Other - Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57 Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3 migration tool to v1.3
2026-05-26 22:06:14 -04:00
<button className="icon-btn" aria-label="Create bin" onClick={createBin}
title={openProject ? 'Create bin in this project' : 'Open a project to create a bin'}
style={{ opacity: openProject ? 1 : 0.5 }}>
<Icon name="plus" size={11} />
</button>
</div>
<div className="rail-list">
{creatingBin && (
<div style={{ padding: '4px 6px', display: 'flex', gap: 4, alignItems: 'center' }}>
<input
className="field-input"
autoFocus
value={newBinName}
onChange={function(e) { setNewBinName(e.target.value); }}
onKeyDown={function(e) {
if (e.key === 'Enter') submitBin(newBinName);
if (e.key === 'Escape') { setCreatingBin(false); }
}}
onBlur={function() { submitBin(newBinName); }}
placeholder="Bin name"
style={{ fontSize: 12, height: 26, padding: '0 6px', flex: 1 }}
/>
</div>
)}
{!creatingBin && BINS.length === 0 ? (
<div style={{ fontSize: 11, color: 'var(--text-3)', padding: '6px 8px', fontStyle: 'italic' }}>
{openProject ? 'No bins yet — click + to create one.' : 'Open a project to manage bins.'}
</div>
) : BINS.map(function(b) {
const isActive = selectedBinId === b.id;
const isDragTarget = draggingAssetId !== null && dragOverBinId === b.id;
return (
<div key={b.id}
className={'rail-item' + (isActive ? ' active' : '') + (isDragTarget ? ' droppable' : '')}
onClick={function() { setSelectedBinId(isActive ? null : b.id); }}
onDragOver={function(e) { onBinDragOver(b.id, e); }}
onDrop={function(e) { onBinDrop(b.id, e); }}
onDragLeave={onBinDragLeave}
style={{ cursor: 'pointer' }}
title={isActive ? 'Click to clear bin filter' : 'Filter to this bin'}>
<Icon name={binIcon(b.icon)} size={13} className="rail-icon" />
<span>{b.name}</span>
<span className="rail-count">{b.count}</span>
</div>
);
})}
</div>
</div>
<div>
<h4>Smart filters</h4>
<div className="rail-list">
{errorCount > 0 && <div className="rail-item" onClick={function() { setFilter('error'); }} style={{ cursor: 'pointer' }}><Icon name="alert" size={13} className="rail-icon" /><span>Errors</span><span className="rail-count">{errorCount}</span></div>}
<div className={'rail-item' + (filter === 'recent' ? ' active' : '')} onClick={function() { setFilter(filter === 'recent' ? 'all' : 'recent'); }} style={{ cursor: 'pointer' }}><Icon name="clock" size={13} className="rail-icon" /><span>Last 24h</span><span className="rail-count">{recentCount}</span></div>
<div className="rail-item" onClick={function() { setFilter('ready'); }} style={{ cursor: 'pointer' }}><Icon name="check" size={13} className="rail-icon" /><span>Ready</span><span className="rail-count">{ALL_ASSETS.filter(function(a) { return a.status === 'ready'; }).length}</span></div>
</div>
</div>
</aside>
<div className="library-main">
<div className="library-toolbar">
<div className="toolbar-title">{displayTitle}</div>
<span className="count">· {assets.length} assets</span>
<div style={{ flex: 1 }} />
<div className="search" style={{ width: 220 }}>
<Icon name="search" className="search-icon" />
<input value={search} onChange={function(e) { setSearch(e.target.value); }} placeholder="Filter assets…" />
</div>
<div className="tab-group">
{['all', 'ready', 'processing', 'live', 'error'].map(function(f) {
return (
<button key={f} className={filter === f ? 'active' : ''} onClick={function() { setFilter(f); }}>
{f === 'all' ? 'All' : f[0].toUpperCase() + f.slice(1)}
</button>
);
})}
</div>
<div className="tab-group">
<button className={view === 'grid' ? 'active' : ''} onClick={function() { setView('grid'); }}><Icon name="grid" size={12} /></button>
<button className={view === 'list' ? 'active' : ''} onClick={function() { setView('list'); }}><Icon name="list" size={12} /></button>
</div>
<button className="btn primary" onClick={function() { navigate('upload'); }}><Icon name="upload" />Upload</button>
</div>
{assets.length === 0 ? (
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No assets match this filter.</div>
) : view === 'grid' ? (
<div className="library-grid">
{assets.map(function(a) {
return <AssetCard key={a.id} asset={a}
onOpen={function() { onOpenAsset(a); }}
onContextMenu={function(e) { openCtx(a, e); }}
onDownload={function() { requestDownload(a); }}
onDragStart={function(e) { onAssetDragStart(a.id, e); }}
draggable={true} />;
})}
</div>
) : (
<div className="library-list">
<div className="list-row head">
<div></div><div>Name</div><div>Duration</div><div>Resolution</div><div>Codec</div><div>Size</div><div>Updated</div><div></div>
</div>
{assets.map(function(a) {
return (
<div key={a.id} className="list-row" onClick={function() { onOpenAsset(a); }} onContextMenu={function(e) { openCtx(a, e); }} style={{ cursor: 'pointer' }}
draggable="true" onDragStart={function(e) { onAssetDragStart(a.id, e); }}>
<div className="thumb"><AssetThumb asset={a} /></div>
<div>
<div className="name">{a.name}</div>
<div style={{ display: 'flex', gap: 6, marginTop: 2 }}>
<StatusDot status={a.status} />
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>{a.status}</span>
</div>
</div>
<div className="col-sub">{a.duration}</div>
<div className="col-sub">{a.res}</div>
<div className="col-sub">{a.codec || '—'}</div>
<div className="col-sub">{a.size}</div>
<div className="col-sub">{a.updated}</div>
chore: 1.2 ship-prep sweep — close 38 issues Frontend / UX / a11y - Sidebar collapse/expand toggle with localStorage persistence (#142) - Settings sections wrap inputs in <form> with Enter-to-submit + native validation; password autocomplete=new-password (#141, #138) - Asset thumbnails get descriptive alt text (#140) - Production deploy now precompiles JSX via esbuild and loads the production React UMD instead of dev builds + in-browser Babel (#139, #122) - Search wrapper gets role=search; global search input gets aria-label, role=combobox, aria-controls/aria-expanded/aria-activedescendant wiring (#137, #135) - Dashboard and Library no longer share the same nav icon (#136) - Sidebar collapses off-canvas with a topbar menu button below 768 px; mobile default is collapsed (#134) - --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133) - Schedule and Library routes were rendering empty inside the .main flex container — switched to flex:1 + min-height:0 (#131, #132, editor + asset detail get the same fix) - Jobs nav badge now polls /jobs?status=active every 10 s and reflects the live count (#130, #113) - aria-label sweep on every icon-only button (#126) - Premiere panel release list moved to window.PREMIERE_RELEASES in data.jsx; Editor + Settings read from the same source (#125) - Typo setPgMclips → setPgmClips (#124) - Stray console.error / console.warn calls gated behind window.DF_LOG.{warn,error} (#123) - Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115) - Schedule rows no longer crash on null recorder_id (#117) - EditorKeyboard guards against document.activeElement === null (#116) - Unmount-safe timers for PasswordResetModal, Containers, Editor (#111) - Player seek clamps below totalMs, server-side range clamping + uncached 416 on EOF, client-side EOF-stall watchdog (#143) - Duration badge overlap fix on narrow asset cards (#52) Backend / security / reliability - GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id; Docker inspects bounded to actually-recording rows (#121) - Upload disk-storage (multer.diskStorage) streams parts to S3 instead of buffering 500 MB in RAM (#120) - /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119) - SDK upload archive listing + post-extract sanitize block zip-slip / tar-slip and symlink escapes (#118) - Migrations track applied state in schema_migrations, run in a transaction, and exit non-zero on failure (#107) - node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem detection wins (#109, #127) - GPU_COUNT override now merges with nvidia-smi enrichment (#108) - /cluster/heartbeat requires a node-bound token or admin user; tokens carry bound_hostname (#106) - /recorders/:id/start error responses no longer echo the Docker create payload — env vars stay out of client responses (#105) - /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks private + loopback hosts for non-admins, denies common service ports (#104) - Scheduler tick guarded by a Postgres advisory lock; pending/running rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to survive multi-node deploys (#103) - UUID validateUuid('id') param middleware on every /:id route (#102) - Error handler scrubs Postgres error messages and 5xx detail (#101) - Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP server, ends the pool, 25 s force-exit watchdog (#100) - AMPP sync moved from fire-and-forget to a persisted retry queue (ampp_sync_status / attempts / next_attempt_at + scheduler retry loop with exponential backoff) (#77) Migrations - 019: api_tokens.bound_hostname (#106) - 020: assets.ampp_sync_status + retry bookkeeping (#77) Other - Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57 Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3 migration tool to v1.3
2026-05-26 22:06:14 -04:00
<button className="icon-btn" aria-label="Asset actions" onClick={function(e) { e.stopPropagation(); openCtx(a, e); }}><Icon name="more" /></button>
</div>
);
})}
</div>
)}
</div>
{ctxMenu && (
<AssetContextMenu
asset={ctxMenu.asset}
x={ctxMenu.x}
y={ctxMenu.y}
bins={BINS}
onClose={function() { setCtxMenu(null); }}
onChanged={refreshAssets}
onOpen={function() { onOpenAsset(ctxMenu.asset); }}
onRename={function(a) { setCtxMenu(null); setRenamingAsset(a); }}
onDownload={function(a) { setCtxMenu(null); requestDownload(a); }}
/>
)}
{pendingDownload && (
<DownloadWarningModal
asset={pendingDownload}
onClose={function() { setPendingDownload(null); }}
onConfirm={function(dismissForever) {
const a = pendingDownload;
setPendingDownload(null);
if (dismissForever) {
try { localStorage.setItem('df.lib.download.warnDismissed', '1'); } catch (_) {}
}
runDownload(a);
}}
/>
)}
{renamingAsset && (
<RenameAssetModal
asset={renamingAsset}
onClose={function() { setRenamingAsset(null); }}
onSaved={function() { setRenamingAsset(null); refreshAssets(); }}
/>
)}
{projectCtx && (
<ProjectContextMenu
project={projectCtx.project}
x={projectCtx.x}
y={projectCtx.y}
onClose={function() { setProjectCtx(null); }}
onRename={function(p) { setProjectCtx(null); setRenamingProject(p); }}
/>
)}
{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() {});
}
})
)}
</div>
);
}
// 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 (
<div ref={ref} className="ctx-menu" style={{ left: pos.left, top: pos.top }}
onClick={function(e) { e.stopPropagation(); }}
onContextMenu={function(e) { e.preventDefault(); e.stopPropagation(); }}>
<div className="ctx-header">{asset.display_name || asset.name}</div>
<button onClick={function() { onClose(); onOpen(); }}><Icon name="play" size={11} />Open</button>
<button onClick={rename}><Icon name="edit" size={11} />Rename</button>
{asset.original_s3_key && onDownload && (
<button onClick={function() { onDownload(asset); }}><Icon name="download" size={11} />Download original</button>
)}
<div className="ctx-divider" />
{(bins && bins.length > 0) ? (
<>
<div className="ctx-section-label">Move to bin</div>
{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 (
<button key={b.id} onClick={function() { moveToBin(b.id); }} disabled={isCurrent}
title={b.project_name ? 'in ' + b.project_name : ''}>
<Icon name="folder" size={11} />{b.name}{isCurrent && <span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--text-3)' }}>current</span>}
</button>
);
})}
{asset.bin_id && (
<button onClick={function() { moveToBin(null); }}>
<Icon name="x" size={11} />Remove from bin
</button>
)}
</>
) : (
<div className="ctx-empty">No bins create one inside a project</div>
)}
<div className="ctx-divider" />
<button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
<button className="danger" onClick={remove}><Icon name="trash" size={11} />Delete permanently</button>
</div>
);
}
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 (
<div className="asset-card" onClick={onOpen} onContextMenu={onContextMenu} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}
draggable={draggable} onDragStart={onDragStart}>
<div style={{ position: 'relative' }}>
<AssetThumb asset={asset} />
{showVideo && (
<video
key={hoverStream.url}
ref={videoRef}
src={hoverStream.type !== 'hls' ? hoverStream.url : undefined}
autoPlay
muted
loop
playsInline
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: 6,
}}
/>
)}
fix: close all 24 open issues (#40–#94) Bug fixes: - #91: dockerApi() 10s socket timeout (Docker daemon hang) - #77: await syncToAmpp() with .catch() — no longer fire-and-forget - #75: migration 016 — add 'proxy','import' to job_type enum; add 'completed' to job_status - #73: BullMQ orphan job cleanup on hard asset delete - #70: batch-trim jobs table gets expires_at; trim-status auto-expires stale rows - #66: scheduler tick marks stale live assets (>2h) as error - #63: migration 017 — partial unique index prevents concurrent live asset overwrite - #61: recorders.js uses getS3Bucket() not stale process.env.S3_BUCKET - #60: already fixed (copy nulls proxy/thumbnail keys, requeues proxy) - #40: already fixed (All projects clears openProject) - #64: already fixed (sourceType/needsProxy handled) - #90: GET /jobs now includes DB jobs table (trim jobs visible in UI) - #74: nginx Content-Type header preserved; multer 500MB file size limit - #68: GET /upload returns in-progress ingesting assets - #58: /stream and /video endpoints fall back to original file for all video types - #55: recorder poll .catch() logs auth errors cleanly; redirect stops interval - #52: thumb-status and thumb-duration moved inside position:relative wrapper - #50: ProjectCard gets onContextMenu handler with rename/delete menu - #49: project context menu dismisses on contextmenu + scroll events Features: - #93: POST /assets/:id/reprocess?type=proxy|thumbnail — force re-queue any asset Asset ⋯ menu now shows 'Re-generate proxy' and 'Re-generate thumbnail' buttons UI: - Logo: brightness(0) invert(1) filter applied consistently in sidebar, launcher, and login — white logo pops on dark UI; inline style removed from login.html
2026-05-26 10:10:44 -04:00
{/* Status badges and duration inside the relative wrapper so
position:absolute is anchored to the thumbnail, not the card (#52) */}
<div className="thumb-status">
{asset.status === 'live' && <span className="badge live">LIVE</span>}
{asset.status === 'processing' && <span className="badge warning">Processing</span>}
{asset.status === 'error' && <span className="badge danger">Error</span>}
</div>
{/* Hi-res download trigger only shown when the asset has an
original_s3_key (everything queued through ingest / conform).
Hidden until card hover, lives in top-right of the thumb. */}
{asset.original_s3_key && onDownload && (
<button
className="thumb-download-btn"
aria-label="Download original"
title="Download original"
onClick={function(e) { e.stopPropagation(); onDownload(); }}>
<Icon name="download" size={12} />
</button>
)}
fix: close all 24 open issues (#40–#94) Bug fixes: - #91: dockerApi() 10s socket timeout (Docker daemon hang) - #77: await syncToAmpp() with .catch() — no longer fire-and-forget - #75: migration 016 — add 'proxy','import' to job_type enum; add 'completed' to job_status - #73: BullMQ orphan job cleanup on hard asset delete - #70: batch-trim jobs table gets expires_at; trim-status auto-expires stale rows - #66: scheduler tick marks stale live assets (>2h) as error - #63: migration 017 — partial unique index prevents concurrent live asset overwrite - #61: recorders.js uses getS3Bucket() not stale process.env.S3_BUCKET - #60: already fixed (copy nulls proxy/thumbnail keys, requeues proxy) - #40: already fixed (All projects clears openProject) - #64: already fixed (sourceType/needsProxy handled) - #90: GET /jobs now includes DB jobs table (trim jobs visible in UI) - #74: nginx Content-Type header preserved; multer 500MB file size limit - #68: GET /upload returns in-progress ingesting assets - #58: /stream and /video endpoints fall back to original file for all video types - #55: recorder poll .catch() logs auth errors cleanly; redirect stops interval - #52: thumb-status and thumb-duration moved inside position:relative wrapper - #50: ProjectCard gets onContextMenu handler with rename/delete menu - #49: project context menu dismisses on contextmenu + scroll events Features: - #93: POST /assets/:id/reprocess?type=proxy|thumbnail — force re-queue any asset Asset ⋯ menu now shows 'Re-generate proxy' and 'Re-generate thumbnail' buttons UI: - Logo: brightness(0) invert(1) filter applied consistently in sidebar, launcher, and login — white logo pops on dark UI; inline style removed from login.html
2026-05-26 10:10:44 -04:00
{(asset.type === 'video' || !asset.type) && asset.duration !== '—' && <div className="thumb-duration">{asset.duration}</div>}
</div>
<div className="meta">
<div className="name">{asset.name}</div>
<div className="sub">
<span>{asset.res}</span>
<span>·</span>
<span>{asset.size}</span>
</div>
</div>
</div>
);
}
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 (
<div ref={ref} className="ctx-menu" style={{ left: pos.left, top: pos.top }}
onClick={function(e) { e.stopPropagation(); }}
onContextMenu={function(e) { e.preventDefault(); e.stopPropagation(); }}>
<div className="ctx-header">{project.name}</div>
<button onClick={function() { onClose(); onRename(project); }}><Icon name="edit" size={11} />Rename project</button>
<button onClick={function() { onClose(); window.ZAMPP_API.fetch('/projects/' + project.id, { method: 'DELETE' }).then(function() { window.location.reload(); }).catch(function(e) { alert('Delete failed: ' + e.message); }); }} className="danger"><Icon name="trash" size={11} />Delete project</button>
</div>
);
}
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 (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 420 }} onClick={function(e) { e.stopPropagation(); }}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Rename asset</div>
chore: 1.2 ship-prep sweep — close 38 issues Frontend / UX / a11y - Sidebar collapse/expand toggle with localStorage persistence (#142) - Settings sections wrap inputs in <form> with Enter-to-submit + native validation; password autocomplete=new-password (#141, #138) - Asset thumbnails get descriptive alt text (#140) - Production deploy now precompiles JSX via esbuild and loads the production React UMD instead of dev builds + in-browser Babel (#139, #122) - Search wrapper gets role=search; global search input gets aria-label, role=combobox, aria-controls/aria-expanded/aria-activedescendant wiring (#137, #135) - Dashboard and Library no longer share the same nav icon (#136) - Sidebar collapses off-canvas with a topbar menu button below 768 px; mobile default is collapsed (#134) - --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133) - Schedule and Library routes were rendering empty inside the .main flex container — switched to flex:1 + min-height:0 (#131, #132, editor + asset detail get the same fix) - Jobs nav badge now polls /jobs?status=active every 10 s and reflects the live count (#130, #113) - aria-label sweep on every icon-only button (#126) - Premiere panel release list moved to window.PREMIERE_RELEASES in data.jsx; Editor + Settings read from the same source (#125) - Typo setPgMclips → setPgmClips (#124) - Stray console.error / console.warn calls gated behind window.DF_LOG.{warn,error} (#123) - Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115) - Schedule rows no longer crash on null recorder_id (#117) - EditorKeyboard guards against document.activeElement === null (#116) - Unmount-safe timers for PasswordResetModal, Containers, Editor (#111) - Player seek clamps below totalMs, server-side range clamping + uncached 416 on EOF, client-side EOF-stall watchdog (#143) - Duration badge overlap fix on narrow asset cards (#52) Backend / security / reliability - GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id; Docker inspects bounded to actually-recording rows (#121) - Upload disk-storage (multer.diskStorage) streams parts to S3 instead of buffering 500 MB in RAM (#120) - /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119) - SDK upload archive listing + post-extract sanitize block zip-slip / tar-slip and symlink escapes (#118) - Migrations track applied state in schema_migrations, run in a transaction, and exit non-zero on failure (#107) - node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem detection wins (#109, #127) - GPU_COUNT override now merges with nvidia-smi enrichment (#108) - /cluster/heartbeat requires a node-bound token or admin user; tokens carry bound_hostname (#106) - /recorders/:id/start error responses no longer echo the Docker create payload — env vars stay out of client responses (#105) - /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks private + loopback hosts for non-admins, denies common service ports (#104) - Scheduler tick guarded by a Postgres advisory lock; pending/running rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to survive multi-node deploys (#103) - UUID validateUuid('id') param middleware on every /:id route (#102) - Error handler scrubs Postgres error messages and 5xx detail (#101) - Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP server, ends the pool, 25 s force-exit watchdog (#100) - AMPP sync moved from fire-and-forget to a persisted retry queue (ampp_sync_status / attempts / next_attempt_at + scheduler retry loop with exponential backoff) (#77) Migrations - 019: api_tokens.bound_hostname (#106) - 020: assets.ampp_sync_status + retry bookkeeping (#77) Other - Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57 Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3 migration tool to v1.3
2026-05-26 22:06:14 -04:00
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Display name</label>
<input className="field-input" autoFocus value={name}
onChange={function(e) { setName(e.target.value); }}
onKeyDown={function(e) { if (e.key === 'Enter') submit(); if (e.key === 'Escape') onClose(); }} />
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={submit} disabled={saving || !name.trim()}>{saving ? 'Saving…' : 'Rename'}</button>
</div>
</div>
</div>
);
}
// 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 (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 460 }} onClick={function(e) { e.stopPropagation(); }}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Download original ingest</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body" style={{ fontSize: 12.5, color: 'var(--text-2)', lineHeight: 1.45 }}>
<div style={{ marginBottom: 8 }}>
You're about to download the full-length original ingest for
<span style={{ color: 'var(--text-1)', fontFamily: 'var(--font-mono)', marginLeft: 4 }}>{name}</span>.
</div>
<div style={{ marginBottom: 12, color: 'var(--text-3)' }}>
Originals are often multi-gigabyte. Download speed depends on
your network connection; don't start this on a metered link
or a busy uplink.
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
<input
type="checkbox"
checked={dismiss}
onChange={function(e) { setDismiss(e.target.checked); }}
style={{ accentColor: 'var(--accent)' }}
/>
<span style={{ fontSize: 12 }}>Don't show this again on this device</span>
</label>
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={function() { onConfirm(dismiss); }}>Download</button>
</div>
</div>
</div>
);
}
window.Library = Library;
window.AssetCard = AssetCard;