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);
|
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 */
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user