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:
parent
6bc6478270
commit
1fcb927d26
2 changed files with 146 additions and 2 deletions
|
|
@ -50,6 +50,24 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
|||
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);
|
||||
|
|
@ -320,6 +338,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
|||
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} />;
|
||||
})}
|
||||
|
|
@ -363,6 +382,21 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
|||
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 && (
|
||||
|
|
@ -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);
|
||||
// Pin the menu inside the viewport even if the user right-clicked near
|
||||
// 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>
|
||||
<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) ? (
|
||||
<>
|
||||
|
|
@ -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 [hovered, setHovered] = React.useState(false);
|
||||
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 === '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>
|
||||
)}
|
||||
{(asset.type === 'video' || !asset.type) && asset.duration !== '—' && <div className="thumb-duration">{asset.duration}</div>}
|
||||
</div>
|
||||
<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.AssetCard = AssetCard;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,35 @@
|
|||
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 { width: 70%; height: 50%; opacity: 0.85; }
|
||||
.audio-meter.h { display: flex; gap: 2px; height: 12px; align-items: stretch; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue