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:
parent
7fce1632be
commit
1435964f6f
@ -37,15 +37,43 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Selected card: blue ring */
|
||||
.admin-gallery-grid .card:has(.select-photo-checkbox:checked) {
|
||||
box-shadow: 0 0 0 2.5px #2e7dbf inset, 0 6px 20px rgba(24, 40, 72, 0.1);
|
||||
/* Selected card: blue ring + tinted header */
|
||||
.admin-gallery-grid .card.is-selected {
|
||||
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) */
|
||||
.admin-gallery-grid .card-content.py-2 {
|
||||
/* Card being swept over during drag */
|
||||
.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);
|
||||
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 */
|
||||
|
||||
@ -272,44 +272,47 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
manageGallery.innerHTML = '';
|
||||
const filtered = getFilteredPhotos();
|
||||
if (!filtered.length) {
|
||||
const message = query
|
||||
? 'No photos match your search.'
|
||||
: 'No photos yet. Upload a photo to get started.';
|
||||
const message = needsTaggingFilter
|
||||
? 'All photos are tagged!'
|
||||
: (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>`;
|
||||
return;
|
||||
}
|
||||
filtered.forEach(photo => {
|
||||
const cards = filtered.map(photo => {
|
||||
const tagCount = Array.isArray(photo.tags) ? photo.tags.length : 0;
|
||||
const tagStatusClass = tagCount <= 2 ? 'is-warning' : 'is-light';
|
||||
const lowTagClass = tagCount <= 2 ? 'low-tag-card' : '';
|
||||
const isSelected = selectedPhotoIds.has(photo._id);
|
||||
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="card has-background-light ${lowTagClass}" data-photo-id="${photo._id}">
|
||||
<div class="card-content py-2 px-3 is-flex is-align-items-center is-justify-content-space-between">
|
||||
<label class="checkbox is-size-7">
|
||||
<input type="checkbox" class="select-photo-checkbox" data-photo-id="${photo._id}" ${selectedPhotoIds.has(photo._id) ? 'checked' : ''}>
|
||||
</label>
|
||||
<div class="card has-background-light ${lowTagClass}${isSelected ? ' is-selected' : ''}" data-photo-id="${photo._id}">
|
||||
<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}">
|
||||
<span class="select-check">
|
||||
<i class="fa-${isSelected ? 'solid fa-circle-check has-text-link' : 'regular fa-circle has-text-grey-light'}"></i>
|
||||
</span>
|
||||
<span class="tag ${tagStatusClass}">${tagCount} tag${tagCount === 1 ? '' : 's'}${tagCount <= 2 ? ' • add more' : ''}</span>
|
||||
</div>
|
||||
<div class="card-image">
|
||||
<figure class="image is-3by2">
|
||||
<img src="${backendUrl}/${photo.path}" alt="${photo.caption}">
|
||||
<img loading="lazy" src="${imgSrc}" alt="${photo.caption}">
|
||||
</figure>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p class="has-text-dark"><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">Caption:</strong> ${photo.caption}</p>
|
||||
<p class="has-text-dark is-size-7"><strong class="has-text-dark">Tags:</strong> ${readableTags.join(', ')}</p>
|
||||
</div>
|
||||
<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 delete-button"><i class="fas fa-trash mr-1"></i>Delete</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
manageGallery.innerHTML += photoCard;
|
||||
</div>`;
|
||||
});
|
||||
manageGallery.innerHTML = cards.join('');
|
||||
}
|
||||
|
||||
function openEditModal(photoId) {
|
||||
@ -543,37 +546,84 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
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();
|
||||
const photoId = e.target.closest('.card').dataset.photoId;
|
||||
openEditModal(photoId);
|
||||
openEditModal(editBtn.closest('.card').dataset.photoId);
|
||||
return;
|
||||
}
|
||||
if (e.target.classList.contains('delete-button')) {
|
||||
if (deleteBtn) {
|
||||
e.preventDefault();
|
||||
const photoId = e.target.closest('.card').dataset.photoId;
|
||||
deletePhoto(photoId);
|
||||
deletePhoto(deleteBtn.closest('.card').dataset.photoId);
|
||||
return;
|
||||
}
|
||||
if (e.target.classList.contains('select-photo-checkbox')) {
|
||||
const id = e.target.dataset.photoId;
|
||||
if (e.target.checked) {
|
||||
selectedPhotoIds.add(id);
|
||||
} else {
|
||||
selectedPhotoIds.delete(id);
|
||||
}
|
||||
if (header) {
|
||||
const id = header.dataset.photoId;
|
||||
if (selectedPhotoIds.has(id)) selectedPhotoIds.delete(id);
|
||||
else selectedPhotoIds.add(id);
|
||||
renderManageGallery();
|
||||
updateBulkUI();
|
||||
}
|
||||
});
|
||||
|
||||
manageGallery.addEventListener('change', (e) => {
|
||||
if (e.target.classList.contains('select-photo-checkbox')) {
|
||||
const id = e.target.dataset.photoId;
|
||||
if (e.target.checked) {
|
||||
selectedPhotoIds.add(id);
|
||||
} else {
|
||||
selectedPhotoIds.delete(id);
|
||||
// ── Drag-to-select ────────────────────────────────────────────────────────
|
||||
let dragRect = null;
|
||||
let dragOrigin = { x: 0, y: 0 };
|
||||
let dragMoved = false;
|
||||
|
||||
manageGallery.addEventListener('mousedown', (e) => {
|
||||
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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user