dragonflight/services/web-ui/public/js/selection.js
Zac 349bc5a41d feat: multi-select + bulk move/copy/delete, brand blue, hardhat loader
* Library cards now show a checkbox on hover (and persistent when selected). Click checkbox = toggle, shift-click = range. Plain click on a card with an active selection extends/shrinks the selection instead of opening preview. Floating pill at the bottom shows count + Move / Copy / Delete / Clear. Move + Copy open a tiny bin picker (current project, default to current bin).

* mam-api/routes/assets.js: PATCH /:id now also accepts bin_id (null = move out of bin). New POST /:id/copy makes a reference-copy of the asset row (same S3 keys, new id) into the target bin/project.

* api.js: moveAsset(id, binId) and copyAsset(id, {binId, projectId}) helpers.

* All accent tokens swapped from the amber oklch(76% 0.178 52) to the Wild Dragon signature blue oklch(55% 0.20 266) = #1f3ad0 ish. Login splash + first-load splash + signal-receiving + button primary all picked it up automatically through common.css.

* Loading indicator across the app uses the AMPP Safe hardhat photo gently pulsing with a tiny blue dot underneath. .ampp-loading component lives in common.css with --sm / --xs / --inline variants. Replaces the plain "Loading assets…" empty state in index.html.
2026-05-17 14:48:34 -04:00

256 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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="<uuid>".
//
// Public API after attach():
// SelectionManager.size()
// SelectionManager.ids() -> Array<uuid>
// 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 = '<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 7l3 3 5-6"/></svg>';
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 = `
<span class="sel-count">0 selected</span>
<button class="sel-btn" data-act="move">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="5" width="9" height="9" rx="1"/><path d="M1 5l2.5-3h4L10 5M10 9h5M13 7l2 2-2 2"/></svg>
Move
</button>
<button class="sel-btn" data-act="copy">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="9" height="10" rx="1"/><path d="M5 14h9V5"/></svg>
Copy
</button>
<button class="sel-btn sel-btn--danger" data-act="delete">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 4h10M6 4V2h4v2M5 4v9h6V4"/></svg>
Delete
</button>
<button class="sel-btn sel-btn--ghost" data-act="clear">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
Clear
</button>`;
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 => '<option value="' + p.id + '"' + (p.id === currentProjectId ? ' selected' : '') + '>' + esc(p.name) + '</option>').join('');
const binOpt = '<option value="">Project root (no bin)</option>' + bins.map(b => '<option value="' + b.id + '">' + esc(b.name) + '</option>').join('');
overlay.innerHTML = '<div class="sel-picker"><div class="sel-picker-title">' + (action === 'move' ? 'Move ' : 'Copy ') + n + ' asset' + (n === 1 ? '' : 's') + '</div>' +
'<label class="sel-picker-label">Project</label>' +
'<select class="sel-picker-select" data-role="proj">' + projOpt + '</select>' +
'<label class="sel-picker-label">Bin</label>' +
'<select class="sel-picker-select" data-role="bin">' + binOpt + '</select>' +
'<div class="sel-picker-row">' +
'<button class="sel-btn sel-btn--ghost" data-role="cancel">Cancel</button>' +
'<button class="sel-btn sel-btn--primary" data-role="go">' + (action === 'move' ? 'Move' : 'Copy') + '</button>' +
'</div></div>';
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 = '<option value="">Project root (no bin)</option>';
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 arent 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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 };
})();