feat(web-ui): library Download button + dismissable size warning (#145)

Adds an inline hi-res download trigger to the asset library.

UI:
- Small 22×22 download icon button in the top-right corner of each
  asset thumbnail. Hidden by default, fades in on card hover or focus
  so the resting-state grid stays clean.
- Only renders for assets that have an `original_s3_key` — proxies
  and unfinished captures never offer it.
- Mirrored as a "Download original…" entry in the right-click
  AssetContextMenu (between Rename and the bin actions).

Flow:
- First click (or any click while the warning is enabled) opens
  DownloadWarningModal: terse copy explaining the file is the full
  original ingest, can be multi-GB, and that speed depends on the
  user's network connection. Footer: Cancel · Download. Body: a
  "Don't show this again on this device" checkbox.
- Ticking the checkbox persists `df.lib.download.warnDismissed=1`
  in localStorage. Subsequent clicks skip the modal and start the
  download straight away.

Download itself reuses /api/v1/assets/:id/hires (presigned S3 URL)
— no proxy round-trip through mam-api, no in-browser progress UI
beyond what the browser already shows.

Spec: #145
Settings → Account "re-enable the warning" toggle is not in this
patch and will land separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-05-28 16:14:24 -04:00
parent 6bc6478270
commit 1fcb927d26
2 changed files with 146 additions and 2 deletions

View file

@ -50,6 +50,24 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []); const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []);
const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y } const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y }
const [renamingAsset, setRenamingAsset] = React.useState(null); 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 [creatingBin, setCreatingBin] = React.useState(false);
const [newBinName, setNewBinName] = React.useState(''); const [newBinName, setNewBinName] = React.useState('');
const [draggingAssetId, setDraggingAssetId] = React.useState(null); const [draggingAssetId, setDraggingAssetId] = React.useState(null);
@ -320,6 +338,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
return <AssetCard key={a.id} asset={a} return <AssetCard key={a.id} asset={a}
onOpen={function() { onOpenAsset(a); }} onOpen={function() { onOpenAsset(a); }}
onContextMenu={function(e) { openCtx(a, e); }} onContextMenu={function(e) { openCtx(a, e); }}
onDownload={function() { requestDownload(a); }}
onDragStart={function(e) { onAssetDragStart(a.id, e); }} onDragStart={function(e) { onAssetDragStart(a.id, e); }}
draggable={true} />; draggable={true} />;
})} })}
@ -363,6 +382,21 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
onChanged={refreshAssets} onChanged={refreshAssets}
onOpen={function() { onOpenAsset(ctxMenu.asset); }} onOpen={function() { onOpenAsset(ctxMenu.asset); }}
onRename={function(a) { setCtxMenu(null); setRenamingAsset(a); }} 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 && ( {renamingAsset && (
@ -410,7 +444,29 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
); );
} }
function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRename }) { // 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); const ref = React.useRef(null);
// Pin the menu inside the viewport even if the user right-clicked near // Pin the menu inside the viewport even if the user right-clicked near
// the bottom-right edge of the grid. // the bottom-right edge of the grid.
@ -454,6 +510,9 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRen
<div className="ctx-header">{asset.display_name || asset.name}</div> <div className="ctx-header">{asset.display_name || asset.name}</div>
<button onClick={function() { onClose(); onOpen(); }}><Icon name="play" size={11} />Open</button> <button onClick={function() { onClose(); onOpen(); }}><Icon name="play" size={11} />Open</button>
<button onClick={rename}><Icon name="edit" size={11} />Rename</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" /> <div className="ctx-divider" />
{(bins && bins.length > 0) ? ( {(bins && bins.length > 0) ? (
<> <>
@ -486,7 +545,7 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRen
); );
} }
function AssetCard({ asset, onOpen, onContextMenu, onDragStart, draggable }) { function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, draggable }) {
const [hoverStream, setHoverStream] = React.useState(null); const [hoverStream, setHoverStream] = React.useState(null);
const [hovered, setHovered] = React.useState(false); const [hovered, setHovered] = React.useState(false);
const timerRef = React.useRef(null); const timerRef = React.useRef(null);
@ -554,6 +613,18 @@ function AssetCard({ asset, onOpen, onContextMenu, onDragStart, draggable }) {
{asset.status === 'processing' && <span className="badge warning">Processing</span>} {asset.status === 'processing' && <span className="badge warning">Processing</span>}
{asset.status === 'error' && <span className="badge danger">Error</span>} {asset.status === 'error' && <span className="badge danger">Error</span>}
</div> </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>
)}
{(asset.type === 'video' || !asset.type) && asset.duration !== '—' && <div className="thumb-duration">{asset.duration}</div>} {(asset.type === 'video' || !asset.type) && asset.duration !== '—' && <div className="thumb-duration">{asset.duration}</div>}
</div> </div>
<div className="meta"> <div className="meta">
@ -634,5 +705,49 @@ function RenameAssetModal({ asset, onClose, onSaved }) {
); );
} }
// 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.Library = Library;
window.AssetCard = AssetCard; window.AssetCard = AssetCard;

View file

@ -44,6 +44,35 @@
display: flex; gap: 4px; display: flex; gap: 4px;
} }
/* Hi-res download button top-right corner of an asset thumbnail.
Hidden by default, revealed on card hover or button focus. Avoids
crowding the resting-state thumb (issue #145). */
.thumb-download-btn {
position: absolute;
right: 6px; top: 6px;
width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
padding: 0;
background: rgba(0, 0, 0, 0.7);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
cursor: pointer;
opacity: 0;
transform: translateY(-2px);
transition: opacity 80ms ease-out, transform 120ms ease-out, background 80ms;
backdrop-filter: blur(4px);
}
.asset-card:hover .thumb-download-btn,
.thumb-download-btn:focus-visible {
opacity: 1;
transform: translateY(0);
}
.thumb-download-btn:hover {
background: rgba(0, 0, 0, 0.85);
border-color: rgba(255, 255, 255, 0.2);
}
/* ========== Waveform ========== */ /* ========== Waveform ========== */
.waveform { width: 70%; height: 50%; opacity: 0.85; } .waveform { width: 70%; height: 50%; opacity: 0.85; }
.audio-meter.h { display: flex; gap: 2px; height: 12px; align-items: stretch; } .audio-meter.h { display: flex; gap: 2px; height: 12px; align-items: stretch; }