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:
parent
1a1fe7044a
commit
821bfac00a
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user