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.
This commit is contained in:
parent
f99f07e0e7
commit
349bc5a41d
6 changed files with 381 additions and 18 deletions
|
|
@ -187,7 +187,7 @@ router.get('/:id', async (req, res, next) => {
|
||||||
router.patch('/:id', async (req, res, next) => {
|
router.patch('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { display_name, tags, notes } = req.body;
|
const { display_name, tags, notes, bin_id } = req.body;
|
||||||
|
|
||||||
const updates = [];
|
const updates = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
|
|
@ -208,6 +208,12 @@ router.patch('/:id', async (req, res, next) => {
|
||||||
params.push(notes);
|
params.push(notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bin_id !== undefined) {
|
||||||
|
// Accept null to move the asset back to the project root
|
||||||
|
updates.push(`bin_id = $${paramCount++}`);
|
||||||
|
params.push(bin_id || null);
|
||||||
|
}
|
||||||
|
|
||||||
if (updates.length === 0) {
|
if (updates.length === 0) {
|
||||||
return res.status(400).json({ error: 'No fields to update' });
|
return res.status(400).json({ error: 'No fields to update' });
|
||||||
}
|
}
|
||||||
|
|
@ -234,6 +240,59 @@ router.patch('/:id', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /:id/copy - Reference-copy an asset into another bin (or project)
|
||||||
|
//
|
||||||
|
// Same S3 keys, new asset row. Mirrors filename + metadata. Useful for
|
||||||
|
// multi-binning a single piece of media without duplicating storage.
|
||||||
|
router.post('/:id/copy', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { binId, projectId } = req.body;
|
||||||
|
|
||||||
|
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
||||||
|
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
|
const src = r.rows[0];
|
||||||
|
|
||||||
|
const newId = uuidv4();
|
||||||
|
const ins = await pool.query(
|
||||||
|
`INSERT INTO assets (
|
||||||
|
id, project_id, bin_id, filename, display_name,
|
||||||
|
status, media_type, original_s3_key, proxy_s3_key, thumbnail_s3_key,
|
||||||
|
codec, resolution, fps, duration_ms, start_tc, file_size, tags, notes,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5,
|
||||||
|
$6, $7, $8, $9, $10,
|
||||||
|
$11, $12, $13, $14, $15, $16, $17, $18,
|
||||||
|
NOW(), NOW()
|
||||||
|
) RETURNING *`,
|
||||||
|
[
|
||||||
|
newId,
|
||||||
|
projectId || src.project_id,
|
||||||
|
binId === undefined ? src.bin_id : (binId || null),
|
||||||
|
src.filename,
|
||||||
|
src.display_name,
|
||||||
|
src.status,
|
||||||
|
src.media_type,
|
||||||
|
src.original_s3_key,
|
||||||
|
src.proxy_s3_key,
|
||||||
|
src.thumbnail_s3_key,
|
||||||
|
src.codec,
|
||||||
|
src.resolution,
|
||||||
|
src.fps,
|
||||||
|
src.duration_ms,
|
||||||
|
src.start_tc,
|
||||||
|
src.file_size,
|
||||||
|
src.tags,
|
||||||
|
src.notes,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
res.status(201).json(ins.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE /:id - Soft or hard delete
|
// DELETE /:id - Soft or hard delete
|
||||||
router.delete('/:id', async (req, res, next) => {
|
router.delete('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,10 @@
|
||||||
--bg-hover: oklch(29% 0.015 250);
|
--bg-hover: oklch(29% 0.015 250);
|
||||||
|
|
||||||
/* Accent — amber tally light */
|
/* Accent — amber tally light */
|
||||||
--accent: oklch(76% 0.178 52);
|
--accent: oklch(45% 0.20 266);
|
||||||
--accent-dim: oklch(56% 0.130 52);
|
--accent-dim: oklch(56% 0.130 52);
|
||||||
--accent-subtle: oklch(76% 0.178 52 / 0.10);
|
--accent-subtle: oklch(55% 0.20 266 / 0.12);
|
||||||
--accent-border: oklch(76% 0.178 52 / 0.30);
|
--accent-border: oklch(55% 0.20 266 / 0.36);
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
--text-primary: oklch(93% 0.008 250);
|
--text-primary: oklch(93% 0.008 250);
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
--status-green-bg: oklch(68% 0.18 148 / 0.10);
|
--status-green-bg: oklch(68% 0.18 148 / 0.10);
|
||||||
--status-red-bg: oklch(62% 0.22 25 / 0.10);
|
--status-red-bg: oklch(62% 0.22 25 / 0.10);
|
||||||
--status-blue-bg: oklch(65% 0.16 245 / 0.10);
|
--status-blue-bg: oklch(65% 0.16 245 / 0.10);
|
||||||
--status-amber-bg: oklch(76% 0.178 52 / 0.10);
|
--status-amber-bg: oklch(55% 0.20 266 / 0.12);
|
||||||
|
|
||||||
/* Spacing — 4pt base */
|
/* Spacing — 4pt base */
|
||||||
--sp-1: 4px;
|
--sp-1: 4px;
|
||||||
|
|
@ -332,8 +332,8 @@ svg { display: block; flex-shrink: 0; }
|
||||||
color: oklch(11% 0.010 250);
|
color: oklch(11% 0.010 250);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
.btn-primary:hover { background: oklch(80% 0.178 52); border-color: oklch(80% 0.178 52); }
|
.btn-primary:hover { background: oklch(52% 0.21 266); border-color: oklch(52% 0.21 266); }
|
||||||
.btn-primary:active { background: oklch(70% 0.178 52); }
|
.btn-primary:active { background: oklch(40% 0.19 266); }
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
|
|
@ -540,9 +540,9 @@ textarea:focus { border-color: var(--accent-border); box-shadow: 0 0 0 3px var(-
|
||||||
.status-dot--idle { background: var(--bg-hover); border: 1px solid var(--border-strong); }
|
.status-dot--idle { background: var(--bg-hover); border: 1px solid var(--border-strong); }
|
||||||
|
|
||||||
@keyframes pulse-amber {
|
@keyframes pulse-amber {
|
||||||
0% { box-shadow: 0 0 0 0 oklch(76% 0.178 52 / 0.8); }
|
0% { box-shadow: 0 0 0 0 oklch(55% 0.20 266 / 0.8); }
|
||||||
60% { box-shadow: 0 0 0 5px oklch(76% 0.178 52 / 0); }
|
60% { box-shadow: 0 0 0 5px oklch(55% 0.20 266 / 0); }
|
||||||
100% { box-shadow: 0 0 0 0 oklch(76% 0.178 52 / 0); }
|
100% { box-shadow: 0 0 0 0 oklch(55% 0.20 266 / 0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
|
|
@ -785,3 +785,18 @@ textarea:focus { border-color: var(--accent-border); box-shadow: 0 0 0 3px var(-
|
||||||
.mt-2 { margin-top: var(--sp-2); }
|
.mt-2 { margin-top: var(--sp-2); }
|
||||||
.mt-4 { margin-top: var(--sp-4); }
|
.mt-4 { margin-top: var(--sp-4); }
|
||||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||||
|
|
||||||
|
/* === AMPP Safe loading indicator =============================
|
||||||
|
Use anywhere we'd otherwise show a generic spinner. The helmet
|
||||||
|
pulses gently; the dot underneath pulses on a different beat. */
|
||||||
|
.ampp-loading{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--sp-3);padding:var(--sp-6);color:var(--text-tertiary);font-size:var(--text-sm)}
|
||||||
|
.ampp-loading-img{width:160px;aspect-ratio:1963/1236;background-image:url(/img/ampp-safe.jpg);background-size:contain;background-position:center;background-repeat:no-repeat;filter:drop-shadow(0 8px 24px oklch(55% 0.20 266 / 0.35));animation:amppPulse 1.8s ease-in-out infinite}
|
||||||
|
.ampp-loading-label{display:flex;align-items:center;gap:8px;font-size:var(--text-xs);letter-spacing:.1em;text-transform:uppercase;color:var(--text-secondary)}
|
||||||
|
.ampp-loading-dot{width:6px;height:6px;border-radius:50%;background:oklch(55% 0.20 266);animation:amppDot 1.2s ease-in-out infinite}
|
||||||
|
.ampp-loading--sm .ampp-loading-img{width:96px}
|
||||||
|
.ampp-loading--xs .ampp-loading-img{width:64px}
|
||||||
|
.ampp-loading--inline{flex-direction:row;padding:var(--sp-2) var(--sp-3);gap:var(--sp-2)}
|
||||||
|
.ampp-loading--inline .ampp-loading-img{width:28px}
|
||||||
|
.ampp-loading--inline .ampp-loading-label{font-size:11px}
|
||||||
|
@keyframes amppPulse{0%,100%{transform:scale(.96);opacity:.78}50%{transform:scale(1);opacity:1}}
|
||||||
|
@keyframes amppDot{0%,100%{transform:scale(.7);opacity:.35}50%{transform:scale(1.15);opacity:1}}
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,7 @@
|
||||||
.drop-overlay {
|
.drop-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: oklch(76% 0.178 52 / 0.07);
|
background: oklch(55% 0.20 266 / 0.09);
|
||||||
border: 2px dashed var(--accent);
|
border: 2px dashed var(--accent);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
@ -297,9 +297,9 @@
|
||||||
|
|
||||||
.first-splash{position:fixed;inset:0;z-index:60;background:radial-gradient(ellipse at 50% 45%,#1a1d28 0%,#08090d 70%);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:24px;opacity:1;transition:opacity .55s ease-out, visibility .55s}
|
.first-splash{position:fixed;inset:0;z-index:60;background:radial-gradient(ellipse at 50% 45%,#1a1d28 0%,#08090d 70%);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:24px;opacity:1;transition:opacity .55s ease-out, visibility .55s}
|
||||||
.first-splash.hidden{opacity:0;visibility:hidden;pointer-events:none}
|
.first-splash.hidden{opacity:0;visibility:hidden;pointer-events:none}
|
||||||
.first-splash-img{width:min(420px,46vw);aspect-ratio:1963/1236;background-image:url(img/ampp-safe.jpg);background-size:contain;background-position:center;background-repeat:no-repeat;filter:drop-shadow(0 20px 40px rgba(232,160,32,.15))}
|
.first-splash-img{width:min(420px,46vw);aspect-ratio:1963/1236;background-image:url(img/ampp-safe.jpg);background-size:contain;background-position:center;background-repeat:no-repeat;filter:drop-shadow(0 20px 40px rgba(31,58,208,.15))}
|
||||||
.first-splash-stamp{display:flex;align-items:center;gap:10px;font-size:11px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:oklch(76% 0.178 52)}
|
.first-splash-stamp{display:flex;align-items:center;gap:10px;font-size:11px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:oklch(55% 0.20 266)}
|
||||||
.first-splash-dot{width:8px;height:8px;background:oklch(76% 0.178 52);border-radius:50%;animation:fsPulse 1.4s ease-in-out infinite}
|
.first-splash-dot{width:8px;height:8px;background:oklch(55% 0.20 266);border-radius:50%;animation:fsPulse 1.4s ease-in-out infinite}
|
||||||
@keyframes fsPulse{0%,100%{opacity:.35;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
|
@keyframes fsPulse{0%,100%{opacity:.35;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
|
||||||
.first-splash-title{font-size:13px;color:var(--text-secondary);letter-spacing:.04em}
|
.first-splash-title{font-size:13px;color:var(--text-secondary);letter-spacing:.04em}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -407,8 +407,9 @@
|
||||||
<button class="btn btn-primary btn-sm" id="emptyUploadBtn">Upload files</button>
|
<button class="btn btn-primary btn-sm" id="emptyUploadBtn">Upload files</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="assetLoading" class="empty-state" style="display:none;">
|
<div id="assetLoading" class="ampp-loading ampp-loading--sm" style="display:none;">
|
||||||
<div class="empty-state-body">Loading assets…</div>
|
<div class="ampp-loading-img"></div>
|
||||||
|
<div class="ampp-loading-label"><span class="ampp-loading-dot"></span><span>Loading assets</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -472,6 +473,7 @@
|
||||||
|
|
||||||
<script src="js/api.js?v=5"></script>
|
<script src="js/api.js?v=5"></script>
|
||||||
<script src="js/preview.js?v=1"></script>
|
<script src="js/preview.js?v=1"></script>
|
||||||
|
<script src="js/selection.js?v=1"></script>
|
||||||
<script>
|
<script>
|
||||||
const state = {
|
const state = {
|
||||||
projects: [],
|
projects: [],
|
||||||
|
|
@ -527,6 +529,22 @@
|
||||||
setupDrag();
|
setupDrag();
|
||||||
setupSearch();
|
setupSearch();
|
||||||
|
|
||||||
|
// Multi-select bulk actions
|
||||||
|
if (window.SelectionManager) {
|
||||||
|
SelectionManager.attach({
|
||||||
|
getProjectId: () => state.currentProjectId,
|
||||||
|
getBins: () => state.bins,
|
||||||
|
getProjects: () => state.projects,
|
||||||
|
onChange: (info) => {
|
||||||
|
if (info.action) {
|
||||||
|
const verb = ({move:'moved',copy:'copied',delete:'deleted'})[info.action] || info.action;
|
||||||
|
toast(`${info.ok} ${verb}` + (info.fail ? ` · ${info.fail} failed` : ''), '', info.fail ? 'warning' : 'success');
|
||||||
|
}
|
||||||
|
loadAssets();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('uploadBtn').onclick = () => location.href = 'upload.html' + (state.currentProjectId ? `?project=${state.currentProjectId}` : '');
|
document.getElementById('uploadBtn').onclick = () => location.href = 'upload.html' + (state.currentProjectId ? `?project=${state.currentProjectId}` : '');
|
||||||
document.getElementById('emptyUploadBtn').onclick = () => document.getElementById('uploadBtn').click();
|
document.getElementById('emptyUploadBtn').onclick = () => document.getElementById('uploadBtn').click();
|
||||||
document.getElementById('newProjectBtn').onclick = () => openPanel('project');
|
document.getElementById('newProjectBtn').onclick = () => openPanel('project');
|
||||||
|
|
@ -676,6 +694,7 @@
|
||||||
|
|
||||||
grid.appendChild(card);
|
grid.appendChild(card);
|
||||||
});
|
});
|
||||||
|
if (window.SelectionManager) SelectionManager.refreshUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadgeClass(s) {
|
function statusBadgeClass(s) {
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,20 @@ async function deleteAsset(assetId, { hard = false } = {}) {
|
||||||
return api(`/assets/${assetId}${qs}`, { method: 'DELETE' });
|
return api(`/assets/${assetId}${qs}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function moveAsset(assetId, binId) {
|
||||||
|
return api(`/assets/${assetId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ bin_id: binId || null }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyAsset(assetId, { binId, projectId } = {}) {
|
||||||
|
return api(`/assets/${assetId}/copy`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ binId: binId || null, projectId: projectId || null }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// PROJECT API CALLS
|
// PROJECT API CALLS
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
256
services/web-ui/public/js/selection.js
Normal file
256
services/web-ui/public/js/selection.js
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
// 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 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,'>').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 };
|
||||||
|
})();
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
:root{
|
:root{
|
||||||
--bg:#08090d;--surface:#11141b;--surface-2:#171a23;--border:#1f2330;
|
--bg:#08090d;--surface:#11141b;--surface-2:#171a23;--border:#1f2330;
|
||||||
--accent:#e8a020;--accent-strong:#f0b740;--text:#e8eaf0;--text-dim:#7a8195;
|
--accent:#1f3ad0;--accent-strong:#3b50d6;--text:#e8eaf0;--text-dim:#7a8195;
|
||||||
--error:#e05555;--success:#3ecf6a;--radius:8px;--input-h:42px;
|
--error:#e05555;--success:#3ecf6a;--radius:8px;--input-h:42px;
|
||||||
}
|
}
|
||||||
html,body{height:100%;background:var(--bg);color:var(--text);font-family:'Inter',-apple-system,sans-serif;font-size:14px;line-height:1.4;-webkit-font-smoothing:antialiased}
|
html,body{height:100%;background:var(--bg);color:var(--text);font-family:'Inter',-apple-system,sans-serif;font-size:14px;line-height:1.4;-webkit-font-smoothing:antialiased}
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
.hero{position:relative;overflow:hidden;background:radial-gradient(ellipse at 30% 40%,#1a1d28 0%,#0a0b10 70%);display:flex;align-items:flex-end;padding:48px 56px}
|
.hero{position:relative;overflow:hidden;background:radial-gradient(ellipse at 30% 40%,#1a1d28 0%,#0a0b10 70%);display:flex;align-items:flex-end;padding:48px 56px}
|
||||||
.hero-img{position:absolute;inset:0;background-image:url(img/ampp-safe.jpg);background-size:contain;background-position:center;background-repeat:no-repeat}
|
.hero-img{position:absolute;inset:0;background-image:url(img/ampp-safe.jpg);background-size:contain;background-position:center;background-repeat:no-repeat}
|
||||||
.hero-grad-bot{position:absolute;inset:auto 0 0 0;height:40%;background:linear-gradient(to top,rgba(8,9,13,.85),transparent);pointer-events:none}
|
.hero-grad-bot{position:absolute;inset:auto 0 0 0;height:40%;background:linear-gradient(to top,rgba(8,9,13,.85),transparent);pointer-events:none}
|
||||||
.hero-stamp{position:absolute;top:32px;left:32px;display:flex;align-items:center;gap:10px;z-index:2;background:rgba(8,9,13,.55);backdrop-filter:blur(6px);padding:8px 14px 8px 12px;border:1px solid rgba(232,160,32,.25);border-radius:999px}
|
.hero-stamp{position:absolute;top:32px;left:32px;display:flex;align-items:center;gap:10px;z-index:2;background:rgba(8,9,13,.55);backdrop-filter:blur(6px);padding:8px 14px 8px 12px;border:1px solid rgba(31,58,208,.25);border-radius:999px}
|
||||||
.hero-stamp-dot{width:8px;height:8px;background:var(--accent);border-radius:50%;box-shadow:0 0 12px var(--accent)}
|
.hero-stamp-dot{width:8px;height:8px;background:var(--accent);border-radius:50%;box-shadow:0 0 12px var(--accent)}
|
||||||
.hero-stamp-text{font-size:11px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:var(--accent-strong)}
|
.hero-stamp-text{font-size:11px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:var(--accent-strong)}
|
||||||
.hero-caption{position:relative;z-index:2;max-width:520px}
|
.hero-caption{position:relative;z-index:2;max-width:520px}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue