feat: drag-to-select and full-header click in admin gallery

- Click anywhere on the top bar of a photo card to toggle selection
  (replaces the tiny checkbox)
- Drag across the grid to rubber-band select multiple photos at once
- Selected cards show a blue ring + tinted header + solid checkmark icon
- Cards swept during drag show a green ring preview before releasing
- Fixed innerHTML += perf issue (now builds all cards then sets once)
- Thumbnails used in grid so page loads faster

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-05-21 11:49:31 -04:00
parent 7fce1632be
commit 1435964f6f
2 changed files with 121 additions and 43 deletions

View File

@ -37,15 +37,43 @@
transform: translateY(-2px); transform: translateY(-2px);
} }
/* Selected card: blue ring */ /* Selected card: blue ring + tinted header */
.admin-gallery-grid .card:has(.select-photo-checkbox:checked) { .admin-gallery-grid .card.is-selected {
box-shadow: 0 0 0 2.5px #2e7dbf inset, 0 6px 20px rgba(24, 40, 72, 0.1); box-shadow: 0 0 0 3px #2e7dbf inset, 0 6px 20px rgba(24, 40, 72, 0.1);
}
.admin-gallery-grid .card.is-selected .select-header {
background: rgba(46, 125, 191, 0.14) !important;
} }
/* Card top bar (checkbox + tag count row) */ /* Card being swept over during drag */
.admin-gallery-grid .card-content.py-2 { .admin-gallery-grid .card.drag-over {
box-shadow: 0 0 0 3px #48c78e inset;
}
/* Rubber-band selection rectangle */
#drag-select-rect {
position: fixed;
border: 2px solid hsl(217, 71%, 53%);
background: rgba(72, 95, 199, 0.08);
border-radius: 4px;
pointer-events: none;
z-index: 1000;
}
/* Card top bar — now the full select target */
.select-header {
cursor: pointer;
user-select: none;
background: rgba(0, 0, 0, 0.03); background: rgba(0, 0, 0, 0.03);
border-bottom: 1px solid #ebe5d2; border-bottom: 1px solid #ebe5d2;
transition: background 0.1s;
}
.select-header:hover {
background: rgba(46, 125, 191, 0.07) !important;
}
.select-check {
font-size: 1.1rem;
line-height: 1;
} }
/* Action buttons in card footer */ /* Action buttons in card footer */

View File

@ -272,44 +272,47 @@ document.addEventListener('DOMContentLoaded', () => {
manageGallery.innerHTML = ''; manageGallery.innerHTML = '';
const filtered = getFilteredPhotos(); const filtered = getFilteredPhotos();
if (!filtered.length) { if (!filtered.length) {
const message = query const message = needsTaggingFilter
? 'No photos match your search.' ? 'All photos are tagged!'
: 'No photos yet. Upload a photo to get started.'; : (getFilteredPhotos().length === 0 && manageSearchInput?.value)
? 'No photos match your search.'
: 'No photos yet. Upload a photo to get started.';
manageGallery.innerHTML = `<div class="column"><p class="has-text-grey">${message}</p></div>`; manageGallery.innerHTML = `<div class="column"><p class="has-text-grey">${message}</p></div>`;
return; return;
} }
filtered.forEach(photo => { const cards = filtered.map(photo => {
const tagCount = Array.isArray(photo.tags) ? photo.tags.length : 0; const tagCount = Array.isArray(photo.tags) ? photo.tags.length : 0;
const tagStatusClass = tagCount <= 2 ? 'is-warning' : 'is-light'; const tagStatusClass = tagCount <= 2 ? 'is-warning' : 'is-light';
const lowTagClass = tagCount <= 2 ? 'low-tag-card' : ''; const lowTagClass = tagCount <= 2 ? 'low-tag-card' : '';
const isSelected = selectedPhotoIds.has(photo._id);
const readableTags = (Array.isArray(photo.tags) ? photo.tags : []).map(displayTag); const readableTags = (Array.isArray(photo.tags) ? photo.tags : []).map(displayTag);
const photoCard = ` const imgSrc = photo.variants?.thumb ? `${backendUrl}/${photo.variants.thumb}` : `${backendUrl}/${photo.path}`;
return `
<div class="column is-half-tablet is-one-third-desktop is-one-quarter-widescreen"> <div class="column is-half-tablet is-one-third-desktop is-one-quarter-widescreen">
<div class="card has-background-light ${lowTagClass}" data-photo-id="${photo._id}"> <div class="card has-background-light ${lowTagClass}${isSelected ? ' is-selected' : ''}" data-photo-id="${photo._id}">
<div class="card-content py-2 px-3 is-flex is-align-items-center is-justify-content-space-between"> <div class="select-header card-content py-2 px-3 is-flex is-align-items-center is-justify-content-space-between" data-photo-id="${photo._id}">
<label class="checkbox is-size-7"> <span class="select-check">
<input type="checkbox" class="select-photo-checkbox" data-photo-id="${photo._id}" ${selectedPhotoIds.has(photo._id) ? 'checked' : ''}> <i class="fa-${isSelected ? 'solid fa-circle-check has-text-link' : 'regular fa-circle has-text-grey-light'}"></i>
</label> </span>
<span class="tag ${tagStatusClass}">${tagCount} tag${tagCount === 1 ? '' : 's'}${tagCount <= 2 ? ' • add more' : ''}</span> <span class="tag ${tagStatusClass}">${tagCount} tag${tagCount === 1 ? '' : 's'}${tagCount <= 2 ? ' • add more' : ''}</span>
</div> </div>
<div class="card-image"> <div class="card-image">
<figure class="image is-3by2"> <figure class="image is-3by2">
<img src="${backendUrl}/${photo.path}" alt="${photo.caption}"> <img loading="lazy" src="${imgSrc}" alt="${photo.caption}">
</figure> </figure>
</div> </div>
<div class="card-content"> <div class="card-content">
<p class="has-text-dark"><strong class="has-text-dark">Caption:</strong> ${photo.caption}</p> <p class="has-text-dark is-size-7"><strong class="has-text-dark">Caption:</strong> ${photo.caption}</p>
<p class="has-text-dark"><strong class="has-text-dark">Tags:</strong> ${readableTags.join(', ')}</p> <p class="has-text-dark is-size-7"><strong class="has-text-dark">Tags:</strong> ${readableTags.join(', ')}</p>
</div> </div>
<footer class="card-footer"> <footer class="card-footer">
<a href="#" class="card-footer-item edit-button"><i class="fas fa-pencil mr-1"></i>Edit</a> <a href="#" class="card-footer-item edit-button"><i class="fas fa-pencil mr-1"></i>Edit</a>
<a href="#" class="card-footer-item delete-button"><i class="fas fa-trash mr-1"></i>Delete</a> <a href="#" class="card-footer-item delete-button"><i class="fas fa-trash mr-1"></i>Delete</a>
</footer> </footer>
</div> </div>
</div> </div>`;
`;
manageGallery.innerHTML += photoCard;
}); });
manageGallery.innerHTML = cards.join('');
} }
function openEditModal(photoId) { function openEditModal(photoId) {
@ -543,37 +546,84 @@ document.addEventListener('DOMContentLoaded', () => {
} }
manageGallery.addEventListener('click', (e) => { manageGallery.addEventListener('click', (e) => {
if (e.target.classList.contains('edit-button')) { if (dragMoved) return; // suppress click after a drag
const editBtn = e.target.closest('.edit-button');
const deleteBtn = e.target.closest('.delete-button');
const header = e.target.closest('.select-header');
if (editBtn) {
e.preventDefault(); e.preventDefault();
const photoId = e.target.closest('.card').dataset.photoId; openEditModal(editBtn.closest('.card').dataset.photoId);
openEditModal(photoId); return;
} }
if (e.target.classList.contains('delete-button')) { if (deleteBtn) {
e.preventDefault(); e.preventDefault();
const photoId = e.target.closest('.card').dataset.photoId; deletePhoto(deleteBtn.closest('.card').dataset.photoId);
deletePhoto(photoId); return;
} }
if (e.target.classList.contains('select-photo-checkbox')) { if (header) {
const id = e.target.dataset.photoId; const id = header.dataset.photoId;
if (e.target.checked) { if (selectedPhotoIds.has(id)) selectedPhotoIds.delete(id);
selectedPhotoIds.add(id); else selectedPhotoIds.add(id);
} else { renderManageGallery();
selectedPhotoIds.delete(id);
}
updateBulkUI(); updateBulkUI();
} }
}); });
manageGallery.addEventListener('change', (e) => { // ── Drag-to-select ────────────────────────────────────────────────────────
if (e.target.classList.contains('select-photo-checkbox')) { let dragRect = null;
const id = e.target.dataset.photoId; let dragOrigin = { x: 0, y: 0 };
if (e.target.checked) { let dragMoved = false;
selectedPhotoIds.add(id);
} else { manageGallery.addEventListener('mousedown', (e) => {
selectedPhotoIds.delete(id); if (e.button !== 0) return;
if (e.target.closest('.card-footer-item') || e.target.closest('.card-image')) return;
dragOrigin = { x: e.clientX, y: e.clientY };
dragMoved = false;
dragRect = null;
const onMove = (ev) => {
const dx = ev.clientX - dragOrigin.x;
const dy = ev.clientY - dragOrigin.y;
if (!dragMoved && Math.abs(dx) < 6 && Math.abs(dy) < 6) return;
dragMoved = true;
if (!dragRect) {
dragRect = document.createElement('div');
dragRect.id = 'drag-select-rect';
document.body.appendChild(dragRect);
} }
updateBulkUI(); const x = Math.min(ev.clientX, dragOrigin.x);
} const y = Math.min(ev.clientY, dragOrigin.y);
const w = Math.abs(dx);
const h = Math.abs(dy);
Object.assign(dragRect.style, { left: x + 'px', top: y + 'px', width: w + 'px', height: h + 'px' });
const selBounds = { left: x, top: y, right: x + w, bottom: y + h };
manageGallery.querySelectorAll('[data-photo-id]').forEach(card => {
const b = card.getBoundingClientRect();
const hit = b.right > selBounds.left && b.left < selBounds.right &&
b.bottom > selBounds.top && b.top < selBounds.bottom;
card.classList.toggle('drag-over', hit);
});
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (dragRect) { dragRect.remove(); dragRect = null; }
if (dragMoved) {
manageGallery.querySelectorAll('.card.drag-over').forEach(card => {
selectedPhotoIds.add(card.dataset.photoId);
card.classList.remove('drag-over');
});
renderManageGallery();
updateBulkUI();
}
setTimeout(() => { dragMoved = false; }, 0);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}); });
if (manageSearchInput) { if (manageSearchInput) {