Admin gallery: robust UX overhaul

- Toast notification system replaces all alert()/confirm() dialogs
- Login now verifies password against /photos/auth-check before granting access
- Backend connectivity banner + periodic health check (60s interval)
- Single-photo delete confirmation modal with thumbnail preview
- Bulk delete runs sequentially with per-photo error counting
- Drag-and-drop file upload zone with thumbnail previews before upload
- Prev/Next auto-save no longer blocked by alerts; navigation always proceeds

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-06-27 10:30:18 -04:00
parent 1a1fe7044a
commit 821bfac00a
4 changed files with 345 additions and 45 deletions

View File

@ -168,6 +168,87 @@
border-radius: 10px;
}
/* ── Toast notifications ────────────────────────────────────────────────────── */
#toast-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
max-width: 380px;
width: calc(100% - 2rem);
}
.admin-toast {
pointer-events: auto;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
animation: toastIn 0.2s ease;
transition: opacity 0.3s;
}
@keyframes toastIn {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: translateX(0); }
}
/* ── Connectivity banner ────────────────────────────────────────────────────── */
#connectivity-banner {
border-radius: 0;
margin: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
/* ── Drop zone ──────────────────────────────────────────────────────────────── */
.admin-drop-zone {
border: 2px dashed #b5d9ef;
border-radius: 8px;
padding: 0.85rem;
background: #f8fcff;
transition: border-color 0.15s, background 0.15s;
}
.admin-drop-zone.drag-over {
border-color: #2e7dbf;
background: #e3f2fb;
}
/* ── Upload file previews ───────────────────────────────────────────────────── */
.upload-preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 0.4rem;
}
.upload-preview-item {
position: relative;
border-radius: 6px;
overflow: hidden;
aspect-ratio: 1;
background: #e8e8e8;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
}
.upload-preview-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.preview-name {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0,0,0,0.5);
color: #fff;
font-size: 0.58rem;
padding: 2px 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Modal light theme ──────────────────────────────────────────────────────── */
.modal-card-head {
background-color: #f5f5f5;

View File

@ -66,6 +66,8 @@ document.addEventListener('DOMContentLoaded', () => {
let photos = [];
let needsTaggingFilter = false;
let currentEditIndex = -1;
let backendOk = true;
let deleteTargetId = null;
// Store Status Elements
const messageInput = document.getElementById('scrollingMessageInput');
@ -158,18 +160,78 @@ document.addEventListener('DOMContentLoaded', () => {
showLogin('Enter password to continue');
};
function showToast(message, type = 'info', duration = 3500) {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
const colorMap = { success: 'is-success', danger: 'is-danger', warning: 'is-warning', info: 'is-info' };
toast.className = `notification ${colorMap[type] || 'is-info'} is-light admin-toast`;
toast.innerHTML = `<button class="delete"></button>${message}`;
toast.querySelector('.delete').addEventListener('click', () => toast.remove());
container.appendChild(toast);
if (duration > 0) {
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 320);
}, duration);
}
}
function setConnectivityState(ok) {
if (ok === backendOk) return;
backendOk = ok;
const banner = document.getElementById('connectivity-banner');
if (banner) banner.style.display = ok ? 'none' : '';
if (!ok) showToast('Cannot reach the backend. Check your connection.', 'warning', 6000);
else showToast('Backend reconnected.', 'success', 2500);
}
async function checkConnectivity() {
try {
const r = await fetch(`${backendUrl}/photos`, {
signal: AbortSignal.timeout(5000),
cache: 'no-store'
});
setConnectivityState(r.ok || r.status === 401 || r.status === 403);
} catch {
setConnectivityState(false);
}
}
// --- Password Protection ---
function login(event) {
async function login(event) {
event.preventDefault();
const passwordVal = passwordInput.value.trim();
if (!passwordVal) return;
loginButton.classList.add('is-loading');
passwordInput.disabled = true;
try {
const r = await fetch(`${backendUrl}/photos/auth-check`, {
headers: { 'X-Admin-Password': passwordVal },
signal: AbortSignal.timeout(6000)
});
if (r.status === 401) {
passwordInput.value = '';
passwordInput.placeholder = 'Wrong password — try again';
loginButton.classList.remove('is-loading');
passwordInput.disabled = false;
passwordInput.focus();
return;
}
// Non-401 errors (network, server down) — allow login, connectivity banner will appear
} catch {
// Backend unreachable — allow login anyway; connectivity banner will show
}
adminPassword = passwordVal;
localStorage.setItem('bpb-admin-password', adminPassword);
loginButton.classList.remove('is-loading');
passwordInput.disabled = false;
showAdmin();
fetchTagMeta();
fetchPhotos();
fetchStatus();
preloadLastTags();
checkConnectivity();
}
loginForm.addEventListener('submit', login);
@ -183,6 +245,7 @@ document.addEventListener('DOMContentLoaded', () => {
fetchPhotos();
fetchStatus();
preloadLastTags();
checkConnectivity();
} else {
showLogin();
}
@ -241,6 +304,7 @@ document.addEventListener('DOMContentLoaded', () => {
updateBulkUI();
} catch (error) {
console.error('Error fetching photos:', error);
setConnectivityState(false);
}
}
@ -389,11 +453,11 @@ document.addEventListener('DOMContentLoaded', () => {
const photoId = editPhotoId.value;
const canonicalTags = normalizeTagsInput(editTags.value);
if (!canonicalTags.length) {
alert('Please include at least one valid tag.');
showToast('Please include at least one valid tag.', 'warning');
return;
}
if (canonicalTags.length > (tagMeta.maxTags || DEFAULT_MAX_TAGS)) {
alert(`Keep tags under ${tagMeta.maxTags || DEFAULT_MAX_TAGS}.`);
showToast(`Keep tags under ${tagMeta.maxTags || DEFAULT_MAX_TAGS}.`, 'warning');
return;
}
const updatedPhoto = {
@ -422,28 +486,31 @@ document.addEventListener('DOMContentLoaded', () => {
closeEditModal();
renderManageGallery();
fetchTagMeta();
showToast('Photo saved.', 'success', 2000);
}
} else {
alert('Failed to save changes.');
showToast('Failed to save changes.', 'danger');
}
} catch (error) {
console.error('Error saving changes:', error);
alert('An error occurred while saving. Please try again.');
showToast('Could not save — check your connection.', 'danger');
setConnectivityState(false);
}
}
async function deletePhoto(id) {
if (confirm('Are you sure you want to delete this photo?')) {
try {
await fetch(`${backendUrl}/photos/${id}`, {
method: 'DELETE',
headers: { 'X-Admin-Password': getAdminPassword() }
});
fetchPhotos();
} catch (error) {
console.error('Error deleting photo:', error);
}
function deletePhoto(id) {
const photo = photos.find(p => p._id === id);
if (!photo) return;
deleteTargetId = id;
const deleteModal = document.getElementById('deletePhotoModal');
const deleteImg = document.getElementById('deleteModalImg');
const deleteCaption = document.getElementById('deleteModalCaption');
if (deleteImg) {
deleteImg.src = photo.variants?.thumb ? `${backendUrl}/${photo.variants.thumb}` : `${backendUrl}/${photo.path}`;
deleteImg.alt = photo.caption;
}
if (deleteCaption) deleteCaption.textContent = photo.caption;
if (deleteModal) deleteModal.classList.add('is-active');
}
function openBulkDeleteModal() {
@ -465,19 +532,32 @@ document.addEventListener('DOMContentLoaded', () => {
}
confirmBulkDeleteBtn.classList.add('is-loading');
let succeeded = 0;
let failed = 0;
for (const id of ids) {
try {
await Promise.all(ids.map(id => fetch(`${backendUrl}/photos/${id}`, {
const r = await fetch(`${backendUrl}/photos/${id}`, {
method: 'DELETE',
headers: { 'X-Admin-Password': getAdminPassword() }
})));
});
if (r.status === 401) { handleUnauthorized(); closeBulkDeleteModal(); return; }
if (r.ok) succeeded++;
else failed++;
} catch {
failed++;
}
}
confirmBulkDeleteBtn.classList.remove('is-loading');
clearSelection();
fetchPhotos();
closeBulkDeleteModal();
} catch (error) {
console.error('Error deleting photos:', error);
alert('Some deletions may have failed. Please refresh and check.');
} finally {
confirmBulkDeleteBtn.classList.remove('is-loading');
if (failed === 0) {
showToast(`Deleted ${succeeded} photo${succeeded !== 1 ? 's' : ''}.`, 'success');
} else {
showToast(`Deleted ${succeeded}, failed to delete ${failed}. Refresh to verify.`, 'warning', 6000);
}
}
@ -494,15 +574,15 @@ document.addEventListener('DOMContentLoaded', () => {
const maxTagsAllowed = tagMeta.maxTags || DEFAULT_MAX_TAGS;
const incomingCanonical = hasTags ? normalizeTagsInput(tagStr) : [];
if (hasTags && !incomingCanonical.length) {
alert('Bulk tags must include at least one valid option from the list.');
showToast('Bulk tags must include at least one valid option from the list.', 'warning');
return;
}
if (incomingCanonical.length > maxTagsAllowed) {
alert(`Please keep bulk tags under ${maxTagsAllowed}.`);
showToast(`Please keep bulk tags under ${maxTagsAllowed}.`, 'warning');
return;
}
if (!hasCaption && !hasTags) {
alert('Enter a caption and/or tags to apply.');
showToast('Enter a caption and/or tags to apply.', 'warning');
return;
}
const ids = Array.from(selectedPhotoIds);
@ -541,7 +621,7 @@ document.addEventListener('DOMContentLoaded', () => {
bulkAppendTags.checked = false;
} catch (error) {
console.error('Error applying bulk edits:', error);
alert('Some edits may have failed. Please refresh and verify.');
showToast('Some edits may have failed. Please refresh and verify.', 'danger', 6000);
}
}
@ -717,6 +797,7 @@ document.addEventListener('DOMContentLoaded', () => {
fetchPhotos();
fetchTagMeta();
uploadForm.reset();
showFilePreviews([]);
preloadLastTags();
} catch (jsonError) {
console.error('Error parsing upload response:', jsonError);
@ -826,7 +907,7 @@ document.addEventListener('DOMContentLoaded', () => {
async function savePreset() {
const name = newPresetName.value.trim();
const tags = newPresetTags.value.split(',').map(t => t.trim().toLowerCase().replace(/\s+/g, '-')).filter(Boolean);
if (!name || !tags.length) { alert('Enter a name and at least one tag.'); return; }
if (!name || !tags.length) { showToast('Enter a name and at least one tag.', 'warning'); return; }
const idx = editPresetIndex.value;
const method = idx !== '' ? 'PUT' : 'POST';
const url = idx !== '' ? `${backendUrl}/photos/presets/${idx}` : `${backendUrl}/photos/presets`;
@ -850,7 +931,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
async function deletePreset(idx) {
if (!confirm('Delete this preset?')) return;
const res = await fetch(`${backendUrl}/photos/presets/${idx}`, {
method: 'DELETE',
headers: { 'X-Admin-Password': getAdminPassword() }
@ -861,6 +941,7 @@ document.addEventListener('DOMContentLoaded', () => {
renderPresetButtons();
renderPresetManagementList();
renderEditPresetButtons();
showToast('Preset deleted.', 'info', 2000);
}
}
@ -993,6 +1074,107 @@ document.addEventListener('DOMContentLoaded', () => {
});
// ── Drag-drop file upload + preview ───────────────────────────────────────
const uploadDropZone = document.getElementById('uploadDropZone');
const uploadPreviewGrid = document.getElementById('uploadPreviewGrid');
const photoInput = document.getElementById('photoInput');
const photoFileName = document.getElementById('photoFileName');
function showFilePreviews(files) {
if (!uploadPreviewGrid) return;
uploadPreviewGrid.innerHTML = '';
if (!files || !files.length) { uploadPreviewGrid.style.display = 'none'; return; }
uploadPreviewGrid.style.display = '';
Array.from(files).forEach(file => {
const item = document.createElement('div');
item.className = 'upload-preview-item';
const img = document.createElement('img');
const url = URL.createObjectURL(file);
img.src = url;
img.onload = () => URL.revokeObjectURL(url);
img.onerror = () => { img.src = ''; item.style.background = '#ddd'; };
const nameEl = document.createElement('div');
nameEl.className = 'preview-name';
nameEl.textContent = file.name;
item.appendChild(img);
item.appendChild(nameEl);
uploadPreviewGrid.appendChild(item);
});
}
if (photoInput) {
photoInput.addEventListener('change', () => {
const n = photoInput.files.length;
if (photoFileName) photoFileName.textContent = n > 1 ? `${n} files selected` : (photoInput.files[0]?.name || 'No files selected');
showFilePreviews(photoInput.files);
});
}
if (uploadDropZone) {
uploadDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadDropZone.classList.add('drag-over');
});
uploadDropZone.addEventListener('dragleave', (e) => {
if (!uploadDropZone.contains(e.relatedTarget)) uploadDropZone.classList.remove('drag-over');
});
uploadDropZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadDropZone.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (!files.length) return;
try {
const dt = new DataTransfer();
Array.from(files).forEach(f => dt.items.add(f));
photoInput.files = dt.files;
} catch { /* DataTransfer not supported — previews only */ }
const n = files.length;
if (photoFileName) photoFileName.textContent = n > 1 ? `${n} files selected` : files[0].name;
showFilePreviews(files);
});
}
// ── Single photo delete modal ──────────────────────────────────────────────
const deletePhotoModal = document.getElementById('deletePhotoModal');
const confirmDeletePhotoBtn = document.getElementById('confirmDeletePhoto');
const cancelDeletePhotoBtn = document.getElementById('cancelDeletePhoto');
const deletePhotoModalClose = document.getElementById('deletePhotoModalClose');
function closeDeletePhotoModal() {
if (deletePhotoModal) deletePhotoModal.classList.remove('is-active');
deleteTargetId = null;
}
if (confirmDeletePhotoBtn) {
confirmDeletePhotoBtn.addEventListener('click', async () => {
if (!deleteTargetId) return;
confirmDeletePhotoBtn.classList.add('is-loading');
try {
const r = await fetch(`${backendUrl}/photos/${deleteTargetId}`, {
method: 'DELETE',
headers: { 'X-Admin-Password': getAdminPassword() }
});
if (r.status === 401) { handleUnauthorized(); closeDeletePhotoModal(); return; }
if (!r.ok) throw new Error('Delete failed');
closeDeletePhotoModal();
showToast('Photo deleted.', 'success', 2000);
fetchPhotos();
} catch (error) {
console.error('Error deleting photo:', error);
showToast('Failed to delete photo.', 'danger');
} finally {
confirmDeletePhotoBtn.classList.remove('is-loading');
}
});
}
if (cancelDeletePhotoBtn) cancelDeletePhotoBtn.addEventListener('click', closeDeletePhotoModal);
if (deletePhotoModalClose) deletePhotoModalClose.addEventListener('click', closeDeletePhotoModal);
if (deletePhotoModal) deletePhotoModal.querySelector('.modal-background')?.addEventListener('click', closeDeletePhotoModal);
// ── Connectivity retry + periodic check ──────────────────────────────────
document.getElementById('retryConnectivity')?.addEventListener('click', checkConnectivity);
setInterval(checkConnectivity, 60000);
// --- Store Status Management ---
async function fetchStatus() {
try {

View File

@ -10,6 +10,7 @@
<link rel="stylesheet" href="admin.css">
</head>
<body class="admin-page">
<div id="toast-container" aria-live="polite"></div>
<div id="login-modal" class="modal is-active">
<div class="modal-content">
<div class="box admin-login-card has-background-light">
@ -57,6 +58,14 @@
</div>
</div>
<div id="connectivity-banner" class="notification is-warning is-light" style="display:none;border-radius:0;margin:0;padding:0.6rem 1.5rem;">
<span class="icon-text">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
<span><strong>Backend unreachable.</strong> Changes cannot be saved until the connection is restored.</span>
</span>
<button id="retryConnectivity" class="button is-warning is-small ml-3">Retry</button>
</div>
<div class="container padding">
<div class="tabs is-boxed">
<ul>
@ -78,10 +87,10 @@
<form id="uploadForm" novalidate>
<div class="field">
<label class="label">Photo</label>
<div id="uploadDropZone" class="admin-drop-zone">
<div class="file has-name is-fullwidth admin-file-input">
<label class="file-label">
<input class="file-input" type="file" id="photoInput" accept="image/*,.heic,.heif" multiple required
onchange="var n=this.files.length; document.getElementById('photoFileName').textContent = n>1 ? n+' files selected' : (this.files[0]?.name || 'No files selected')">
<input class="file-input" type="file" id="photoInput" accept="image/*,.heic,.heif" multiple required>
<span class="file-cta">
<span class="file-icon"><i class="fas fa-cloud-upload-alt"></i></span>
<span class="file-label">Choose photos…</span>
@ -89,7 +98,10 @@
<span class="file-name" id="photoFileName">No files selected</span>
</label>
</div>
<p class="has-text-grey-light is-size-7 mt-2" style="text-align:center;">or drop files here</p>
</div>
<p class="help is-size-7 has-text-grey mt-1">HEIC/HEIF auto-converted to WebP.</p>
<div id="uploadPreviewGrid" class="upload-preview-grid" style="display:none;"></div>
</div>
<div class="field">
<label class="label has-text-dark">Caption</label>
@ -327,6 +339,28 @@
</div>
</div>
<!-- Single Photo Delete Modal -->
<div id="deletePhotoModal" class="modal">
<div class="modal-background"></div>
<div class="modal-card" style="max-width:420px;width:90%;">
<header class="modal-card-head">
<p class="modal-card-title">Delete photo?</p>
<button class="delete" aria-label="close" id="deletePhotoModalClose"></button>
</header>
<section class="modal-card-body has-text-centered">
<figure class="image" style="max-height:180px;overflow:hidden;border-radius:6px;margin-bottom:0.75rem;">
<img id="deleteModalImg" src="" alt="" style="object-fit:contain;max-height:180px;width:100%;">
</figure>
<p class="has-text-grey is-size-7 mb-2" id="deleteModalCaption"></p>
<p>This action <strong>cannot be undone</strong>.</p>
</section>
<footer class="modal-card-foot is-justify-content-flex-end" style="gap:0.5rem;">
<button id="cancelDeletePhoto" class="button">Cancel</button>
<button id="confirmDeletePhoto" class="button is-danger">Delete</button>
</footer>
</div>
</div>
<script src="admin.js" defer></script>
<script>
// Keep "Store is open/closed" toggle label in sync

View File

@ -34,6 +34,9 @@ function requireAuth(req, res, next) {
}
next();
}
// Lightweight auth verification endpoint — no side effects
router.get('/auth-check', requireAuth, (_req, res) => res.json({ ok: true }));
// We now use a visible diagonal watermark only. Invisible watermarking is disabled by default.
const DISABLE_WM = true;