document.addEventListener('DOMContentLoaded', () => { // General Admin Elements const loginModal = document.getElementById('login-modal'); const loginForm = document.getElementById('loginForm'); const passwordInput = document.getElementById('passwordInput'); const loginButton = document.getElementById('loginButton'); const adminContent = document.getElementById('admin-content'); // Tabs const tabs = document.querySelectorAll('.tabs li'); const tabContents = document.querySelectorAll('.tab-content'); // Photo Gallery Elements const uploadForm = document.getElementById('uploadForm'); const uploadButton = document.getElementById('uploadButton'); const uploadStatus = document.getElementById('uploadStatus'); const uploadProgress = document.getElementById('uploadProgress'); const tagsInput = document.getElementById('tagsInput'); const tagSuggestions = document.getElementById('tagSuggestions'); const quickTagButtons = document.getElementById('quickTagButtons'); const captionInput = document.getElementById('captionInput'); const captionToTagsButton = document.getElementById('captionToTags'); const manageGallery = document.getElementById('manage-gallery'); const manageSearchInput = document.getElementById('manageSearchInput'); const editModal = document.getElementById('editModal'); const editPhotoId = document.getElementById('editPhotoId'); const editCaption = document.getElementById('editCaption'); const editTags = document.getElementById('editTags'); const saveChanges = document.getElementById('saveChanges'); const modalCloseButton = editModal.querySelector('.delete'); const modalCancelButton = editModal.querySelector('.modal-card-foot .button:not(.is-success)'); // Bulk Delete Modal const bulkDeleteModal = document.getElementById('bulkDeleteModal'); const confirmBulkDeleteBtn = document.getElementById('confirmBulkDelete'); const cancelBulkDeleteBtn = document.getElementById('cancelBulkDelete'); const bulkDeleteModalCloseBtn = bulkDeleteModal.querySelector('.delete'); const bulkDeleteCountEl = document.getElementById('bulk-delete-count'); const bulkCaption = document.getElementById('bulkCaption'); const bulkTags = document.getElementById('bulkTags'); const bulkAppendTags = document.getElementById('bulkAppendTags'); const applyBulkEdits = document.getElementById('applyBulkEdits'); const bulkDelete = document.getElementById('bulkDelete'); const selectAllPhotosBtn = document.getElementById('selectAllPhotos'); const clearSelectionBtn = document.getElementById('clearSelection'); const selectedCountEl = document.getElementById('selectedCount'); const bulkPanel = document.getElementById('bulkPanel'); let selectedPhotoIds = new Set(); let photos = []; // Store Status Elements const messageInput = document.getElementById('scrollingMessageInput'); const isClosedCheckbox = document.getElementById('isClosedCheckbox'); const closedMessageInput = document.getElementById('closedMessageInput'); const updateButton = document.getElementById('updateButton'); const responseDiv = document.getElementById('response'); const backendUrl = (() => { const { protocol, hostname } = window.location; const productionHosts = new Set([ 'beachpartyballoons.com', 'www.beachpartyballoons.com', 'preview.beachpartyballoons.com', 'photobackend.beachpartyballoons.com' ]); const isProduction = productionHosts.has(hostname); if (!isProduction) { return 'http://localhost:5001'; } const backendHostname = 'photobackend.beachpartyballoons.com'; return `${protocol}//${backendHostname}`; // No explicit port needed as it's on 443 })(); const LAST_TAGS_KEY = 'bpb-last-tags'; const DEFAULT_MAX_TAGS = 8; let tagMeta = { tags: [], main: [], other: [], aliases: {}, presets: [], labels: {}, maxTags: DEFAULT_MAX_TAGS, existing: [], tagCounts: {} }; let adminPassword = ''; const storedPassword = localStorage.getItem('bpb-admin-password'); const getAdminPassword = () => adminPassword || localStorage.getItem('bpb-admin-password') || ''; const slugifyTag = (tag) => String(tag || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').trim(); const canonicalizeTag = (tag) => { const slug = slugifyTag(tag); const mapped = tagMeta.aliases?.[slug] || slug; return mapped; }; const resolveSearchTag = (value) => { const slug = slugifyTag(value); if (!slug) return ''; return tagMeta.aliases?.[slug] || slug; }; const displayTag = (slug) => { if (!slug) return ''; if (tagMeta.labels && tagMeta.labels[slug]) return tagMeta.labels[slug]; return slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); }; const canonicalToDisplayString = (canonicalArr) => canonicalArr.map(displayTag).join(', '); const normalizeTagsInput = (value) => { const raw = String(value || '') .split(',') .map(t => t.trim()) .filter(Boolean); const seen = new Set(); const canonical = []; raw.forEach(tag => { const mapped = canonicalizeTag(tag); if (mapped && !seen.has(mapped) && canonical.length < (tagMeta.maxTags || DEFAULT_MAX_TAGS)) { seen.add(mapped); canonical.push(mapped); } }); return canonical; }; const showAdmin = () => { adminContent.style.display = 'block'; loginModal.classList.remove('is-active'); }; const showLogin = (message) => { if (message) { passwordInput.value = ''; passwordInput.placeholder = message; } loginModal.classList.add('is-active'); }; const handleUnauthorized = () => { localStorage.removeItem('bpb-admin-password'); adminPassword = ''; showLogin('Enter password to continue'); }; // --- Password Protection --- function login(event) { event.preventDefault(); const passwordVal = passwordInput.value.trim(); if (!passwordVal) return; adminPassword = passwordVal; localStorage.setItem('bpb-admin-password', adminPassword); showAdmin(); fetchTagMeta(); fetchPhotos(); fetchStatus(); preloadLastTags(); } loginForm.addEventListener('submit', login); loginButton.addEventListener('click', login); if (storedPassword) { adminPassword = storedPassword; passwordInput.value = storedPassword; showAdmin(); fetchTagMeta(); fetchPhotos(); fetchStatus(); preloadLastTags(); } else { showLogin(); } async function fetchTagMeta() { try { const response = await fetch(`${backendUrl}/photos/tags`); if (!response.ok) return; const data = await response.json(); tagMeta = { tags: [], main: [], other: [], aliases: {}, presets: [], labels: {}, maxTags: DEFAULT_MAX_TAGS, existing: [], tagCounts: {}, ...data }; updateTagSuggestions(); updateQuickTags(); preloadLastTags(); } catch (error) { console.error('Error fetching tag metadata:', error); } } // --- Tab Switching --- tabs.forEach(tab => { tab.addEventListener('click', () => { tabs.forEach(item => item.classList.remove('is-active')); tab.classList.add('is-active'); const target = document.getElementById(tab.dataset.tab); tabContents.forEach(content => content.style.display = 'none'); target.style.display = 'block'; }); }); // --- Photo Management --- async function fetchPhotos() { try { const response = await fetch(`${backendUrl}/photos`); if (response.status === 401) { handleUnauthorized(); return; } photos = await response.json(); const validIds = new Set(photos.map(p => p._id)); selectedPhotoIds = new Set(Array.from(selectedPhotoIds).filter(id => validIds.has(id))); updateTagSuggestions(); updateQuickTags(); renderManageGallery(); updateBulkUI(); } catch (error) { console.error('Error fetching photos:', error); } } function renderManageGallery() { manageGallery.innerHTML = ''; const query = String(manageSearchInput?.value || '').trim().toLowerCase(); const normalizedQuery = resolveSearchTag(query); const filtered = query ? photos.filter(photo => { const caption = String(photo.caption || '').toLowerCase(); const tags = Array.isArray(photo.tags) ? photo.tags : []; const tagText = tags.map(displayTag).join(' ').toLowerCase(); return caption.includes(query) || tags.some(tag => String(tag || '').toLowerCase().includes(query)) || (normalizedQuery && tags.some(tag => String(tag || '').toLowerCase() === normalizedQuery)) || tagText.includes(query); }) : photos; if (!filtered.length) { const message = query ? 'No photos match your search.' : 'No photos yet. Upload a photo to get started.'; manageGallery.innerHTML = `

${message}

`; return; } filtered.forEach(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 readableTags = (Array.isArray(photo.tags) ? photo.tags : []).map(displayTag); const photoCard = `
${tagCount} tag${tagCount === 1 ? '' : 's'}${tagCount <= 2 ? ' • add more' : ''}
${photo.caption}

Caption: ${photo.caption}

Tags: ${readableTags.join(', ')}

`; manageGallery.innerHTML += photoCard; }); } function openEditModal(photoId) { const photo = photos.find(p => p._id === photoId); if (photo) { editPhotoId.value = photo._id; editCaption.value = photo.caption; const readable = (Array.isArray(photo.tags) ? photo.tags : []).map(displayTag).join(', '); editTags.value = readable; editModal.classList.add('is-active'); } } function closeEditModal() { editModal.classList.remove('is-active'); } function updateBulkUI() { const count = selectedPhotoIds.size; selectedCountEl.textContent = `${count} selected`; const disabled = count === 0; applyBulkEdits.disabled = disabled; bulkDelete.disabled = disabled; if (bulkPanel) { bulkPanel.style.display = count ? 'block' : 'none'; } } function toggleSelectAll() { if (selectedPhotoIds.size === photos.length) { selectedPhotoIds.clear(); } else { photos.forEach(p => selectedPhotoIds.add(p._id)); } renderManageGallery(); updateBulkUI(); } function clearSelection() { selectedPhotoIds.clear(); renderManageGallery(); updateBulkUI(); } async function handleSaveChanges() { const photoId = editPhotoId.value; const canonicalTags = normalizeTagsInput(editTags.value); if (!canonicalTags.length) { alert('Please include at least one valid tag.'); return; } if (canonicalTags.length > (tagMeta.maxTags || DEFAULT_MAX_TAGS)) { alert(`Keep tags under ${tagMeta.maxTags || DEFAULT_MAX_TAGS}.`); return; } const updatedPhoto = { caption: editCaption.value.trim(), tags: canonicalTags.join(', ') }; try { const response = await fetch(`${backendUrl}/photos/update/${photoId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(updatedPhoto) }); if (response.ok) { closeEditModal(); fetchPhotos(); // Refresh the gallery fetchTagMeta(); } else { alert('Failed to save changes.'); } } catch (error) { console.error('Error saving changes:', error); alert('An error occurred while saving. Please try again.'); } } async function deletePhoto(id) { if (confirm('Are you sure you want to delete this photo?')) { try { await fetch(`${backendUrl}/photos/${id}`, { method: 'DELETE' }); fetchPhotos(); } catch (error) { console.error('Error deleting photo:', error); } } } function openBulkDeleteModal() { const count = selectedPhotoIds.size; if (count === 0) return; bulkDeleteCountEl.textContent = `You are about to delete ${count} photo(s).`; bulkDeleteModal.classList.add('is-active'); } function closeBulkDeleteModal() { bulkDeleteModal.classList.remove('is-active'); } async function handleConfirmBulkDelete() { const ids = Array.from(selectedPhotoIds); if (ids.length === 0) { closeBulkDeleteModal(); return; } confirmBulkDeleteBtn.classList.add('is-loading'); try { await Promise.all(ids.map(id => fetch(`${backendUrl}/photos/${id}`, { method: 'DELETE' }))); 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'); } } function bulkDeletePhotos() { openBulkDeleteModal(); } async function bulkApplyEdits() { if (!selectedPhotoIds.size) return; const newCaption = bulkCaption.value.trim(); const tagStr = bulkTags.value.trim(); const hasCaption = newCaption.length > 0; const hasTags = tagStr.length > 0; 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.'); return; } if (incomingCanonical.length > maxTagsAllowed) { alert(`Please keep bulk tags under ${maxTagsAllowed}.`); return; } if (!hasCaption && !hasTags) { alert('Enter a caption and/or tags to apply.'); return; } const ids = Array.from(selectedPhotoIds); const append = bulkAppendTags.checked; try { await Promise.all(ids.map(async (id) => { const photo = photos.find(p => p._id === id); if (!photo) return; const existingTags = Array.isArray(photo.tags) ? photo.tags : []; let finalTags = existingTags; if (hasTags) { const merged = append ? Array.from(new Set([...existingTags, ...incomingCanonical])) : incomingCanonical; if (!merged.length || merged.length > maxTagsAllowed) { throw new Error('Tag limit exceeded or invalid.'); } finalTags = merged; } const payload = { caption: hasCaption ? newCaption : photo.caption, tags: (hasTags ? finalTags : existingTags).join(', ') }; await fetch(`${backendUrl}/photos/update/${id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); })); fetchPhotos(); fetchTagMeta(); clearSelection(); bulkCaption.value = ''; bulkTags.value = ''; bulkAppendTags.checked = false; } catch (error) { console.error('Error applying bulk edits:', error); alert('Some edits may have failed. Please refresh and verify.'); } } manageGallery.addEventListener('click', (e) => { if (e.target.classList.contains('edit-button')) { e.preventDefault(); const photoId = e.target.closest('.card').dataset.photoId; openEditModal(photoId); } if (e.target.classList.contains('delete-button')) { e.preventDefault(); const photoId = e.target.closest('.card').dataset.photoId; deletePhoto(photoId); } 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); } 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); } updateBulkUI(); } }); if (manageSearchInput) { manageSearchInput.addEventListener('input', () => renderManageGallery()); } selectAllPhotosBtn.addEventListener('click', (e) => { e.preventDefault(); toggleSelectAll(); }); clearSelectionBtn.addEventListener('click', (e) => { e.preventDefault(); clearSelection(); }); uploadForm.addEventListener('submit', (e) => { e.preventDefault(); const photoInput = document.getElementById('photoInput'); const captionInput = document.getElementById('captionInput'); uploadStatus.textContent = ''; uploadStatus.className = 'help mt-3'; uploadProgress.style.display = 'none'; uploadProgress.value = 0; const files = photoInput.files ? Array.from(photoInput.files) : []; if (!files.length) { uploadStatus.textContent = 'Please choose an image before uploading.'; uploadStatus.classList.add('has-text-danger'); return; } const formData = new FormData(); files.forEach(file => formData.append('photos', file)); formData.append('caption', captionInput.value); const canonicalTags = normalizeTagsInput(tagsInput.value); if (!canonicalTags.length) { uploadStatus.textContent = 'Please choose at least one tag from the suggestions.'; uploadStatus.classList.add('has-text-danger'); return; } if (canonicalTags.length > (tagMeta.maxTags || DEFAULT_MAX_TAGS)) { uploadStatus.textContent = `Use ${tagMeta.maxTags || DEFAULT_MAX_TAGS} tags or fewer.`; uploadStatus.classList.add('has-text-danger'); return; } tagsInput.value = canonicalToDisplayString(canonicalTags); formData.append('tags', canonicalTags.join(', ')); const xhr = new XMLHttpRequest(); xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable) { const percentComplete = (event.loaded / event.total) * 100; uploadProgress.value = percentComplete; } }); xhr.addEventListener('load', () => { uploadButton.classList.remove('is-loading'); uploadProgress.style.display = 'none'; if (xhr.status === 401) { handleUnauthorized(); uploadStatus.textContent = 'Session expired. Please log in again.'; uploadStatus.classList.add('has-text-danger'); return; } if (xhr.status < 200 || xhr.status >= 300) { const errorText = xhr.responseText; uploadStatus.textContent = `Upload failed: ${errorText || xhr.statusText}`; uploadStatus.classList.add('has-text-danger'); return; } try { const result = JSON.parse(xhr.responseText); const uploadedCount = Array.isArray(result?.uploaded) ? result.uploaded.length : 0; const skippedCount = Array.isArray(result?.skipped) ? result.skipped.length : 0; if (result?.success === false) { uploadStatus.textContent = result?.error || 'Upload failed.'; uploadStatus.classList.add('has-text-danger'); return; } uploadStatus.textContent = result?.message || `Uploaded ${uploadedCount || files.length} photo${(uploadedCount || files.length) === 1 ? '' : 's'} successfully!` + (skippedCount ? ` Skipped ${skippedCount} duplicate${skippedCount === 1 ? '' : 's'}.` : ''); uploadStatus.classList.add('has-text-success'); localStorage.setItem(LAST_TAGS_KEY, canonicalTags.join(', ')); fetchPhotos(); fetchTagMeta(); uploadForm.reset(); preloadLastTags(); } catch (jsonError) { console.error('Error parsing upload response:', jsonError); uploadStatus.textContent = 'Received an invalid response from the server.'; uploadStatus.classList.add('has-text-danger'); } }); xhr.addEventListener('error', () => { uploadButton.classList.remove('is-loading'); uploadProgress.style.display = 'none'; uploadStatus.textContent = 'An unexpected error occurred during upload.'; uploadStatus.classList.add('has-text-danger'); }); xhr.addEventListener('abort', () => { uploadButton.classList.remove('is-loading'); uploadProgress.style.display = 'none'; uploadStatus.textContent = 'Upload cancelled.'; uploadStatus.classList.add('has-text-grey'); }); xhr.open('POST', `${backendUrl}/photos/upload`); uploadButton.classList.add('is-loading'); uploadProgress.style.display = 'block'; xhr.send(formData); }); const getTagCount = (slug) => Number(tagMeta.tagCounts?.[slug] || 0); const sortTagsByCount = (a, b) => { const countDiff = getTagCount(b.slug) - getTagCount(a.slug); if (countDiff !== 0) return countDiff; return (a.label || '').localeCompare(b.label || ''); }; const sortSlugsByCount = (a, b) => { const countDiff = getTagCount(b) - getTagCount(a); if (countDiff !== 0) return countDiff; return displayTag(a).localeCompare(displayTag(b)); }; function updateTagSuggestions() { if (!tagSuggestions) return; tagSuggestions.innerHTML = ''; const mainSorted = [...(tagMeta.main || [])].sort(sortTagsByCount); const otherSorted = [...(tagMeta.other || [])].sort(sortTagsByCount); const existingSorted = [...(tagMeta.existing || [])].sort(sortSlugsByCount); const suggestions = [ ...mainSorted, ...otherSorted, ...existingSorted.map(slug => ({ slug, label: displayTag(slug) })) ]; const seen = new Set(); suggestions.forEach(tag => { if (!tag || !tag.slug || seen.has(tag.slug)) return; seen.add(tag.slug); const option = document.createElement('option'); option.value = tag.label; option.dataset.slug = tag.slug; tagSuggestions.appendChild(option); }); } function updateQuickTags() { if (!quickTagButtons) return; const presetButtons = (tagMeta.presets || []).map(preset => ``); const mainButtons = [...(tagMeta.main || [])] .sort(sortTagsByCount) .map(tag => ``); const otherButtons = [...(tagMeta.other || [])] .sort(sortTagsByCount) .map(tag => ``); quickTagButtons.innerHTML = [...presetButtons, ...mainButtons, ...otherButtons].join(''); } function addTagToInput(tag) { const canonical = canonicalizeTag(tag); if (!canonical) return; const existing = normalizeTagsInput(tagsInput.value); if (!existing.includes(canonical)) { existing.push(canonical); } tagsInput.value = canonicalToDisplayString(existing); } function preloadLastTags() { const last = localStorage.getItem(LAST_TAGS_KEY); if (last && tagsInput && !tagsInput.value) { const canonical = normalizeTagsInput(last); tagsInput.value = canonicalToDisplayString(canonical); } } function applyPresetTags(presetName) { const preset = (tagMeta.presets || []).find(p => p.name === presetName); if (!preset) return; const canonical = normalizeTagsInput((preset.tags || []).join(',')); tagsInput.value = canonicalToDisplayString(canonical); } if (quickTagButtons) { quickTagButtons.addEventListener('click', (e) => { const presetBtn = e.target.closest('button[data-preset]'); const tagBtn = e.target.closest('button[data-tag]'); if (presetBtn) { applyPresetTags(presetBtn.dataset.preset); return; } if (tagBtn) { addTagToInput(tagBtn.dataset.tag); } }); } if (captionToTagsButton) { captionToTagsButton.addEventListener('click', () => { const caption = captionInput.value || ''; const words = (caption.match(/[A-Za-z0-9]+/g) || []) .map(w => w.toLowerCase()) .filter(w => w.length > 2); const unique = Array.from(new Set(words)); unique.forEach(addTagToInput); uploadStatus.textContent = unique.length ? 'Tags pulled from caption.' : 'No words found to convert to tags.'; uploadStatus.className = 'help mt-3 ' + (unique.length ? 'has-text-success' : 'has-text-grey'); }); } updateBulkUI(); saveChanges.addEventListener('click', handleSaveChanges); modalCloseButton.addEventListener('click', closeEditModal); modalCancelButton.addEventListener('click', closeEditModal); applyBulkEdits.addEventListener('click', bulkApplyEdits); bulkDelete.addEventListener('click', bulkDeletePhotos); confirmBulkDeleteBtn.addEventListener('click', handleConfirmBulkDelete); cancelBulkDeleteBtn.addEventListener('click', closeBulkDeleteModal); bulkDeleteModalCloseBtn.addEventListener('click', closeBulkDeleteModal); // --- Store Status Management --- async function fetchStatus() { try { const response = await fetch('../update.json'); const data = await response.json(); const currentStatus = data[0]; messageInput.value = currentStatus.message; isClosedCheckbox.checked = currentStatus.isClosed; closedMessageInput.value = currentStatus.closedMessage; } catch (error) { console.error('Error fetching current status:', error); responseDiv.textContent = 'Error fetching current status.'; responseDiv.classList.add('is-danger'); } } updateButton.addEventListener('click', async () => { const data = [ { message: messageInput.value, isClosed: isClosedCheckbox.checked, closedMessage: closedMessageInput.value } ]; try { const response = await fetch('/api/update-status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ data }) }); const result = await response.json(); if (result.success) { responseDiv.textContent = 'Status updated successfully!'; responseDiv.classList.remove('is-danger'); responseDiv.classList.add('is-success'); } else { responseDiv.textContent = `Error: ${result.message}`; responseDiv.classList.remove('is-success'); responseDiv.classList.add('is-danger'); } } catch (error) { console.error('Error updating status:', error); responseDiv.textContent = 'An unexpected error occurred.'; responseDiv.classList.remove('is-success'); responseDiv.classList.add('is-danger'); } }); });