bpb-website/admin/admin.js

728 lines
29 KiB
JavaScript

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 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 } = window.location;
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: []
};
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 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: [],
...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 = '';
if (!photos.length) {
manageGallery.innerHTML = '<div class="column"><p class="has-text-grey">No photos yet. Upload a photo to get started.</p></div>';
return;
}
photos.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 = `
<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-content py-2 px-3 is-flex is-align-items-center is-justify-content-space-between">
<label class="checkbox is-size-7">
<input type="checkbox" class="select-photo-checkbox" data-photo-id="${photo._id}" ${selectedPhotoIds.has(photo._id) ? 'checked' : ''}>
Select
</label>
<span class="tag ${tagStatusClass}">${tagCount} tag${tagCount === 1 ? '' : 's'}${tagCount <= 2 ? ' • add more' : ''}</span>
</div>
<div class="card-image">
<figure class="image is-3by2">
<img src="${backendUrl}/${photo.path}" alt="${photo.caption}">
</figure>
</div>
<div class="card-content">
<p class="has-text-dark"><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>
</div>
<footer class="card-footer">
<a href="#" class="card-footer-item edit-button">Edit</a>
<a href="#" class="card-footer-item delete-button">Delete</a>
</footer>
</div>
</div>
`;
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();
}
});
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);
});
function updateTagSuggestions() {
if (!tagSuggestions) return;
tagSuggestions.innerHTML = '';
const suggestions = [
...(tagMeta.main || []),
...(tagMeta.other || []),
...((tagMeta.existing || []).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 => `<button type="button" class="button is-light is-rounded" data-preset="${preset.name}">${preset.name} preset</button>`);
const mainButtons = (tagMeta.main || []).map(tag => `<button type="button" class="button is-link is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
const otherButtons = (tagMeta.other || []).map(tag => `<button type="button" class="button is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
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');
}
});
});