diff --git a/services/web-ui/public/screens-library.jsx b/services/web-ui/public/screens-library.jsx index 42a7665..526f5ae 100644 --- a/services/web-ui/public/screens-library.jsx +++ b/services/web-ui/public/screens-library.jsx @@ -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 ; })} @@ -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 && ( + )} {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
{asset.display_name || asset.name}
+ {asset.original_s3_key && onDownload && ( + + )}
{(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' && Processing} {asset.status === 'error' && Error}
+ {/* 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 && ( + + )} {(asset.type === 'video' || !asset.type) && asset.duration !== '—' &&
{asset.duration}
}
@@ -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 ( +
+
+
+
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. +
+ +
+
+ + +
+
+
+ ); +} + window.Library = Library; window.AssetCard = AssetCard; diff --git a/services/web-ui/public/styles-screens.css b/services/web-ui/public/styles-screens.css index fce0775..7dc8401 100644 --- a/services/web-ui/public/styles-screens.css +++ b/services/web-ui/public/styles-screens.css @@ -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; }