// Multi-select + bulk move/copy/delete for the library asset grid. // Usage: SelectionManager.attach({ getProjectId, getBins, getProjects, onChange }) // The grid markup must use .asset-card data-asset-id="". // // Public API after attach(): // SelectionManager.size() // SelectionManager.ids() -> Array // SelectionManager.clear() // SelectionManager.refreshUI() (function () { const state = { selected: new Set(), lastIndex: -1, // for shift-click range cfg: null, }; function attach(cfg) { state.cfg = cfg || {}; injectStyles(); injectBar(); // Delegated click handler on the grid: handle checkbox AND shift-range. const grid = document.getElementById('assetGrid'); if (!grid) return; grid.addEventListener('click', onGridClick, true); refreshUI(); } function onGridClick(e) { const card = e.target.closest('.asset-card'); if (!card) return; const id = card.dataset.assetId; if (!id) return; // Click on the checkbox — toggle selection only, never open preview. if (e.target.closest('.asset-check')) { e.preventDefault(); e.stopPropagation(); const cards = Array.from(document.querySelectorAll('#assetGrid .asset-card')); const idx = cards.indexOf(card); if (e.shiftKey && state.lastIndex >= 0) { const [a,b] = [state.lastIndex, idx].sort((x,y)=>x-y); for (let i = a; i <= b; i++) state.selected.add(cards[i].dataset.assetId); } else { if (state.selected.has(id)) state.selected.delete(id); else state.selected.add(id); state.lastIndex = idx; } refreshUI(); return; } // If there's an active selection, plain click toggles instead of opening preview. if (state.selected.size > 0) { e.preventDefault(); e.stopPropagation(); if (state.selected.has(id)) state.selected.delete(id); else state.selected.add(id); refreshUI(); } } function refreshUI() { // Ensure each card has a checkbox stamp + selected styling document.querySelectorAll('#assetGrid .asset-card').forEach(card => { const id = card.dataset.assetId; if (!card.querySelector('.asset-check')) { const check = document.createElement('div'); check.className = 'asset-check'; check.innerHTML = ''; card.appendChild(check); } card.classList.toggle('is-selected', state.selected.has(id)); }); const bar = document.getElementById('selectionBar'); if (!bar) return; const n = state.selected.size; bar.classList.toggle('open', n > 0); bar.querySelector('.sel-count').textContent = n + (n === 1 ? ' selected' : ' selected'); } function clear() { state.selected.clear(); state.lastIndex = -1; refreshUI(); } function injectBar() { if (document.getElementById('selectionBar')) return; const bar = document.createElement('div'); bar.id = 'selectionBar'; bar.className = 'selection-bar'; bar.innerHTML = ` 0 selected `; document.body.appendChild(bar); bar.addEventListener('click', (e) => { const btn = e.target.closest('[data-act]'); if (!btn) return; const act = btn.dataset.act; if (act === 'clear') clear(); else if (act === 'delete') doDelete(); else if (act === 'move' || act === 'copy') openBinPicker(act); }); } async function doDelete() { const ids = Array.from(state.selected); if (!ids.length) return; if (!confirm('Delete ' + ids.length + ' asset' + (ids.length === 1 ? '' : 's') + '? They will be archived and hidden from the library.')) return; let ok = 0, fail = 0; await Promise.all(ids.map(async (id) => { const r = await deleteAsset(id); r.success ? ok++ : fail++; })); clear(); if (state.cfg.onChange) state.cfg.onChange({ action: 'delete', ok, fail }); } function openBinPicker(action) { const cfg = state.cfg; const projects = (cfg.getProjects && cfg.getProjects()) || []; const currentProjectId = cfg.getProjectId && cfg.getProjectId(); const bins = (cfg.getBins && cfg.getBins()) || []; const n = state.selected.size; const overlay = document.createElement('div'); overlay.className = 'sel-picker-overlay'; const projOpt = projects.map(p => '').join(''); const binOpt = '' + bins.map(b => '').join(''); overlay.innerHTML = '
' + (action === 'move' ? 'Move ' : 'Copy ') + n + ' asset' + (n === 1 ? '' : 's') + '
' + '' + '' + '' + '' + '
' + '' + '' + '
'; document.body.appendChild(overlay); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); const role = e.target.dataset && e.target.dataset.role; if (role === 'cancel') overlay.remove(); else if (role === 'go') { const projId = overlay.querySelector('[data-role="proj"]').value || null; const binId = overlay.querySelector('[data-role="bin"]').value || null; overlay.remove(); if (action === 'move') doMove(projId, binId); else doCopy(projId, binId); } }); // When the project changes mid-pick, we'd want to reload bins. // For now keep it simple: pick the bin within currently loaded bins. The user // can re-open the picker after switching projects in the topbar. overlay.querySelector('[data-role="proj"]').addEventListener('change', (e) => { if (e.target.value !== currentProjectId) { const sel = overlay.querySelector('[data-role="bin"]'); sel.innerHTML = ''; const hint = document.createElement('div'); hint.style.cssText = 'font-size:11px;color:var(--text-tertiary);margin-top:6px'; hint.textContent = 'Bins for the chosen project aren’t loaded here — will use project root.'; sel.parentElement.insertBefore(hint, sel.nextSibling); } }); } async function doMove(projectId, binId) { const ids = Array.from(state.selected); if (!ids.length) return; let ok = 0, fail = 0; await Promise.all(ids.map(async (id) => { // moveAsset only changes bin_id; for cross-project moves we PATCH that explicitly const body = { bin_id: binId || null }; // If user explicitly switched project, PATCH project_id too — the route does not accept it today, // so the simpler path is to fall back to copy+delete. For now, ignore project change on move. const r = await api('/assets/' + id, { method: 'PATCH', body: JSON.stringify(body) }); r.success ? ok++ : fail++; })); clear(); if (state.cfg.onChange) state.cfg.onChange({ action: 'move', ok, fail }); } async function doCopy(projectId, binId) { const ids = Array.from(state.selected); if (!ids.length) return; let ok = 0, fail = 0; await Promise.all(ids.map(async (id) => { const r = await copyAsset(id, { projectId, binId }); r.success ? ok++ : fail++; })); clear(); if (state.cfg.onChange) state.cfg.onChange({ action: 'copy', ok, fail }); } function esc(s) { if (s == null) return ''; return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function injectStyles() { if (document.getElementById('selectionStyles')) return; const css = document.createElement('style'); css.id = 'selectionStyles'; css.textContent = STYLES; document.head.appendChild(css); } const STYLES = ` .asset-card{position:relative} .asset-check{position:absolute;top:8px;left:8px;width:22px;height:22px;border-radius:6px;border:1.5px solid oklch(70% 0 0 / 0.7);background:oklch(8% 0.010 250 / 0.65);backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;color:transparent;cursor:pointer;opacity:0;transition:opacity .15s, border-color .15s, background .15s, color .15s;z-index:3} .asset-card:hover .asset-check{opacity:1} .asset-check svg{width:14px;height:14px} .asset-card.is-selected .asset-check{opacity:1;background:oklch(55% 0.20 266);border-color:oklch(55% 0.20 266);color:#fff} .asset-card.is-selected{border-color:oklch(55% 0.20 266);box-shadow:0 0 0 2px oklch(55% 0.20 266 / 0.35)} .selection-bar{position:fixed;left:50%;bottom:24px;transform:translate(-50%, 200%);display:flex;align-items:center;gap:8px;padding:8px 10px 8px 16px;background:var(--bg-panel);border:1px solid var(--border);border-radius:999px;box-shadow:0 20px 50px oklch(0% 0 0 / 0.55);font-size:13px;color:var(--text-primary);transition:transform .25s cubic-bezier(.16,.84,.34,1);z-index:55} .selection-bar.open{transform:translate(-50%, 0)} .sel-count{font-weight:500;margin-right:6px;letter-spacing:-.01em;color:oklch(55% 0.20 266);font-variant-numeric:tabular-nums} .sel-btn{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:transparent;border:1px solid var(--border);color:var(--text-secondary);border-radius:999px;font-size:12px;font-weight:500;cursor:pointer;transition:border-color .15s, color .15s, background .15s;font-family:inherit} .sel-btn:hover{border-color:oklch(55% 0.20 266 / 0.6);color:var(--text-primary);background:oklch(55% 0.20 266 / 0.08)} .sel-btn svg{width:13px;height:13px} .sel-btn--danger:hover{color:oklch(62% 0.22 25);border-color:oklch(62% 0.22 25);background:oklch(62% 0.22 25 / 0.1)} .sel-btn--primary{background:oklch(55% 0.20 266);border-color:oklch(55% 0.20 266);color:#fff} .sel-btn--primary:hover{background:oklch(52% 0.21 266);border-color:oklch(52% 0.21 266);color:#fff} .sel-btn--ghost{border-color:transparent;color:var(--text-tertiary)} .sel-btn--ghost:hover{color:var(--text-primary);background:var(--bg-hover);border-color:var(--border)} .sel-picker-overlay{position:fixed;inset:0;background:oklch(6% 0.010 250 / 0.7);display:flex;align-items:center;justify-content:center;z-index:65;backdrop-filter:blur(4px)} .sel-picker{background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--r-lg);padding:20px;width:min(380px,90vw);display:flex;flex-direction:column;gap:8px} .sel-picker-title{font-size:14px;font-weight:500;margin-bottom:8px} .sel-picker-label{font-size:11px;color:var(--text-tertiary);letter-spacing:.06em;text-transform:uppercase;margin-top:4px} .sel-picker-select{height:36px;background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--r-md);color:var(--text-primary);font-family:inherit;font-size:13px;padding:0 10px} .sel-picker-row{display:flex;justify-content:flex-end;gap:8px;margin-top:14px} `; // Expose window.SelectionManager = { attach, clear, refreshUI, ids: () => Array.from(state.selected), size: () => state.selected.size }; })();