785 lines
31 KiB
JavaScript
785 lines
31 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 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 = `<div class="column"><p class="has-text-grey">${message}</p></div>`;
|
|
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 = `
|
|
<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();
|
|
}
|
|
});
|
|
|
|
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 => `<button type="button" class="button is-light is-rounded" data-preset="${preset.name}">${preset.name} preset</button>`);
|
|
const mainButtons = [...(tagMeta.main || [])]
|
|
.sort(sortTagsByCount)
|
|
.map(tag => `<button type="button" class="button is-link is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
|
|
const otherButtons = [...(tagMeta.other || [])]
|
|
.sort(sortTagsByCount)
|
|
.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');
|
|
}
|
|
});
|
|
});
|