Add multi-select to library page

- Selection mode toggle in toolbar
- Checkboxes on cards (grid) and rows (list)
- Bulk actions: move to bin, delete
- Select all / clear selection controls

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Zac 2026-06-01 03:01:19 +00:00
parent 2cd20a0e72
commit 9bcbac558c

View file

@ -77,6 +77,9 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
// 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()
@ -86,6 +89,66 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
.catch(() => {});
}, []);
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?',
@ -322,6 +385,29 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
<h1 className="toolbar-title">{displayTitle}</h1>
<span className="count">· {assets.length} assets</span>
<div style={{ flex: 1 }} />
{selectionMode && selectedAssets.size > 0 && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginRight: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>{selectedAssets.size} selected</span>
<div className="tab-group">
<button onClick={selectAll} title="Select all"><Icon name="check" size={12} /></button>
<button onClick={clearSelection} title="Clear selection"><Icon name="x" size={12} /></button>
</div>
{BINS.length > 0 && (
<select className="field-input" style={{ height: 32, fontSize: 12, padding: '0 8px' }}
onChange={function(e) { if (e.target.value) bulkMoveToBin(e.target.value); e.target.value = ''; }}
value="">
<option value="">Move to bin</option>
{BINS.filter(function(b) { return !openProject || b.project_id === openProject.id; }).map(function(b) {
return <option key={b.id} value={b.id}>{b.name}</option>;
})}
</select>
)}
<button className="btn danger sm" onClick={bulkDelete}><Icon name="trash" size={12} />Delete</button>
</div>
)}
<button className={'btn ghost sm' + (selectionMode ? ' active' : '')} onClick={function() { setSelectionMode(!selectionMode); if (selectionMode) clearSelection(); }} title="Select multiple">
<Icon name="check" size={12} />Select
</button>
<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…" />
@ -352,18 +438,27 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
onContextMenu={function(e) { openCtx(a, e); }}
onDownload={function() { requestDownload(a); }}
onDragStart={function(e) { onAssetDragStart(a.id, e); }}
draggable={true} />;
draggable={true}
selectionMode={selectionMode}
isSelected={selectedAssets.has(a.id)}
onToggleSelect={function(e) { toggleSelection(a.id, e); }} />;
})}
</div>
) : (
<div className="library-list">
<div className="list-row head">
{selectionMode && <div style={{ width: 32 }}></div>}
<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' }}
<div key={a.id} className="list-row" onClick={function() { if (!selectionMode) onOpenAsset(a); }} onContextMenu={function(e) { openCtx(a, e); }} style={{ cursor: 'pointer' }}
draggable="true" onDragStart={function(e) { onAssetDragStart(a.id, e); }}>
{selectionMode && (
<div style={{ width: 32, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<input type="checkbox" checked={selectedAssets.has(a.id)} onChange={function(e) { toggleSelection(a.id, e); }} onClick={function(e) { e.stopPropagation(); }} style={{ cursor: 'pointer' }} />
</div>
)}
<div className="thumb"><AssetThumb asset={a} /></div>
<div>
<div className="name">{a.name}</div>
@ -555,7 +650,7 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRen
);
}
function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, draggable }) {
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);
@ -593,9 +688,14 @@ function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, drag
const showVideo = hovered && hoverStream;
return (
<div className="asset-card" onClick={onOpen} onContextMenu={onContextMenu} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}
<div className="asset-card" onClick={selectionMode ? onToggleSelect : onOpen} onContextMenu={onContextMenu} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}
draggable={draggable} onDragStart={onDragStart}>
<div style={{ position: 'relative' }}>
{selectionMode && (
<div style={{ position: 'absolute', top: 8, left: 8, zIndex: 10 }}>
<input type="checkbox" checked={isSelected} onChange={onToggleSelect} onClick={function(e) { e.stopPropagation(); }} style={{ cursor: 'pointer', width: 18, height: 18 }} />
</div>
)}
<AssetThumb asset={asset} />
{showVideo && (
<video