diff --git a/main-site/admin/admin.css b/main-site/admin/admin.css index 879b99c..3db1325 100644 --- a/main-site/admin/admin.css +++ b/main-site/admin/admin.css @@ -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; diff --git a/main-site/admin/admin.js b/main-site/admin/admin.js index 0676fa8..203fd24 100644 --- a/main-site/admin/admin.js +++ b/main-site/admin/admin.js @@ -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 = `${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'); - try { - await Promise.all(ids.map(id => fetch(`${backendUrl}/photos/${id}`, { - method: 'DELETE', - headers: { 'X-Admin-Password': getAdminPassword() } - }))); - 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'); + let succeeded = 0; + let failed = 0; + + for (const id of ids) { + try { + 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(); + + 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 { diff --git a/main-site/admin/index.html b/main-site/admin/index.html index aaad1db..b84dfc4 100644 --- a/main-site/admin/index.html +++ b/main-site/admin/index.html @@ -10,6 +10,7 @@
+