chris 1435964f6f feat: drag-to-select and full-header click in admin gallery
- Click anywhere on the top bar of a photo card to toggle selection
  (replaces the tiny checkbox)
- Drag across the grid to rubber-band select multiple photos at once
- Selected cards show a blue ring + tinted header + solid checkmark icon
- Cards swept during drag show a green ring preview before releasing
- Fixed innerHTML += perf issue (now builds all cards then sets once)
- Thumbnails used in grid so page loads faster

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:49:31 -04:00

1056 lines
44 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 needsTaggingBtn = document.getElementById('needsTaggingBtn');
const editModal = document.getElementById('editModal');
const editPhotoId = document.getElementById('editPhotoId');
const editCaption = document.getElementById('editCaption');
const editTags = document.getElementById('editTags');
const editModalImg = document.getElementById('editModalImg');
const editModalTitle = document.getElementById('editModalTitle');
const editPresetButtons = document.getElementById('editPresetButtons');
const editPrevBtn = document.getElementById('editPrevBtn');
const editNextBtn = document.getElementById('editNextBtn');
const saveChanges = document.getElementById('saveChanges');
const modalCloseButton = editModal.querySelector('.delete');
const modalCancelButton = editModal.querySelector('.modal-card-foot .button:not(.is-success)');
// Preset management elements
const presetButtons = document.getElementById('presetButtons');
const toggleManagePresets = document.getElementById('toggleManagePresets');
const managePresetsPanel = document.getElementById('managePresetsPanel');
const presetList = document.getElementById('presetList');
const newPresetName = document.getElementById('newPresetName');
const newPresetTags = document.getElementById('newPresetTags');
const savePresetBtn = document.getElementById('savePresetBtn');
const cancelPresetEdit = document.getElementById('cancelPresetEdit');
const editPresetIndex = document.getElementById('editPresetIndex');
const presetFormLabel = document.getElementById('presetFormLabel');
// 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 = [];
let needsTaggingFilter = false;
let currentEditIndex = -1;
// 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 getFilteredPhotos() {
const query = String(manageSearchInput?.value || '').trim().toLowerCase();
const normalizedQuery = resolveSearchTag(query);
let list = photos;
if (needsTaggingFilter) {
list = list.filter(photo => {
const tags = Array.isArray(photo.tags) ? photo.tags : [];
return tags.includes('uncategorized') || tags.length <= 1;
});
}
if (query) {
list = list.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);
});
}
return list;
}
function renderManageGallery() {
manageGallery.innerHTML = '';
const filtered = getFilteredPhotos();
if (!filtered.length) {
const message = needsTaggingFilter
? 'All photos are tagged!'
: (getFilteredPhotos().length === 0 && manageSearchInput?.value)
? '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;
}
const cards = filtered.map(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 isSelected = selectedPhotoIds.has(photo._id);
const readableTags = (Array.isArray(photo.tags) ? photo.tags : []).map(displayTag);
const imgSrc = photo.variants?.thumb ? `${backendUrl}/${photo.variants.thumb}` : `${backendUrl}/${photo.path}`;
return `
<div class="column is-half-tablet is-one-third-desktop is-one-quarter-widescreen">
<div class="card has-background-light ${lowTagClass}${isSelected ? ' is-selected' : ''}" data-photo-id="${photo._id}">
<div class="select-header card-content py-2 px-3 is-flex is-align-items-center is-justify-content-space-between" data-photo-id="${photo._id}">
<span class="select-check">
<i class="fa-${isSelected ? 'solid fa-circle-check has-text-link' : 'regular fa-circle has-text-grey-light'}"></i>
</span>
<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 loading="lazy" src="${imgSrc}" alt="${photo.caption}">
</figure>
</div>
<div class="card-content">
<p class="has-text-dark is-size-7"><strong class="has-text-dark">Caption:</strong> ${photo.caption}</p>
<p class="has-text-dark is-size-7"><strong class="has-text-dark">Tags:</strong> ${readableTags.join(', ')}</p>
</div>
<footer class="card-footer">
<a href="#" class="card-footer-item edit-button"><i class="fas fa-pencil mr-1"></i>Edit</a>
<a href="#" class="card-footer-item delete-button"><i class="fas fa-trash mr-1"></i>Delete</a>
</footer>
</div>
</div>`;
});
manageGallery.innerHTML = cards.join('');
}
function openEditModal(photoId) {
const filtered = getFilteredPhotos();
const idx = filtered.findIndex(p => p._id === photoId);
if (idx === -1) return;
currentEditIndex = idx;
loadEditModalPhoto(filtered[idx], idx, filtered.length);
editModal.classList.add('is-active');
setTimeout(() => editCaption.focus(), 50);
}
function loadEditModalPhoto(photo, idx, total) {
editPhotoId.value = photo._id;
editCaption.value = photo.caption;
const readable = (Array.isArray(photo.tags) ? photo.tags : []).map(displayTag).join(', ');
editTags.value = readable;
if (editModalImg) {
const src = photo.variants?.thumb
? `${backendUrl}/${photo.variants.thumb}`
: `${backendUrl}/${photo.path}`;
editModalImg.src = src;
editModalImg.alt = photo.caption;
}
if (editModalTitle) editModalTitle.textContent = `Photo ${idx + 1} of ${total}`;
if (editPrevBtn) editPrevBtn.disabled = idx === 0;
if (editNextBtn) editNextBtn.disabled = idx === total - 1;
renderEditPresetButtons();
}
function navigateEditModal(delta) {
const filtered = getFilteredPhotos();
const newIdx = currentEditIndex + delta;
if (newIdx < 0 || newIdx >= filtered.length) return;
handleSaveChanges(false).then(() => {
currentEditIndex = newIdx;
loadEditModalPhoto(filtered[newIdx], newIdx, filtered.length);
});
}
function closeEditModal() {
editModal.classList.remove('is-active');
currentEditIndex = -1;
}
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(closeAfter = true) {
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',
'Authorization': `Bearer ${getAdminPassword()}`,
},
body: JSON.stringify(updatedPhoto)
});
if (response.status === 401) { handleUnauthorized(); return; }
if (response.ok) {
const idx = photos.findIndex(p => p._id === photoId);
if (idx !== -1) {
photos[idx].caption = updatedPhoto.caption;
photos[idx].tags = canonicalTags;
}
if (closeAfter) {
closeEditModal();
renderManageGallery();
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',
headers: { 'Authorization': `Bearer ${getAdminPassword()}` }
});
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',
headers: { 'Authorization': `Bearer ${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');
}
}
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',
'Authorization': `Bearer ${getAdminPassword()}`,
},
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 (dragMoved) return; // suppress click after a drag
const editBtn = e.target.closest('.edit-button');
const deleteBtn = e.target.closest('.delete-button');
const header = e.target.closest('.select-header');
if (editBtn) {
e.preventDefault();
openEditModal(editBtn.closest('.card').dataset.photoId);
return;
}
if (deleteBtn) {
e.preventDefault();
deletePhoto(deleteBtn.closest('.card').dataset.photoId);
return;
}
if (header) {
const id = header.dataset.photoId;
if (selectedPhotoIds.has(id)) selectedPhotoIds.delete(id);
else selectedPhotoIds.add(id);
renderManageGallery();
updateBulkUI();
}
});
// ── Drag-to-select ────────────────────────────────────────────────────────
let dragRect = null;
let dragOrigin = { x: 0, y: 0 };
let dragMoved = false;
manageGallery.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
if (e.target.closest('.card-footer-item') || e.target.closest('.card-image')) return;
dragOrigin = { x: e.clientX, y: e.clientY };
dragMoved = false;
dragRect = null;
const onMove = (ev) => {
const dx = ev.clientX - dragOrigin.x;
const dy = ev.clientY - dragOrigin.y;
if (!dragMoved && Math.abs(dx) < 6 && Math.abs(dy) < 6) return;
dragMoved = true;
if (!dragRect) {
dragRect = document.createElement('div');
dragRect.id = 'drag-select-rect';
document.body.appendChild(dragRect);
}
const x = Math.min(ev.clientX, dragOrigin.x);
const y = Math.min(ev.clientY, dragOrigin.y);
const w = Math.abs(dx);
const h = Math.abs(dy);
Object.assign(dragRect.style, { left: x + 'px', top: y + 'px', width: w + 'px', height: h + 'px' });
const selBounds = { left: x, top: y, right: x + w, bottom: y + h };
manageGallery.querySelectorAll('[data-photo-id]').forEach(card => {
const b = card.getBoundingClientRect();
const hit = b.right > selBounds.left && b.left < selBounds.right &&
b.bottom > selBounds.top && b.top < selBounds.bottom;
card.classList.toggle('drag-over', hit);
});
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (dragRect) { dragRect.remove(); dragRect = null; }
if (dragMoved) {
manageGallery.querySelectorAll('.card.drag-over').forEach(card => {
selectedPhotoIds.add(card.dataset.photoId);
card.classList.remove('drag-over');
});
renderManageGallery();
updateBulkUI();
}
setTimeout(() => { dragMoved = false; }, 0);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
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`);
xhr.setRequestHeader('Authorization', `Bearer ${getAdminPassword()}`);
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 mainButtons = [...(tagMeta.main || [])]
.sort(sortTagsByCount)
.map(tag => `<button type="button" class="button is-link is-light is-small 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-small is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
quickTagButtons.innerHTML = [...mainButtons, ...otherButtons].join('');
renderPresetButtons();
}
function renderPresetButtons() {
if (!presetButtons) return;
presetButtons.innerHTML = (tagMeta.presets || [])
.map(p => `<button type="button" class="button is-info is-light is-small is-rounded" data-preset-tags="${(p.tags||[]).join(',')}">${p.name}</button>`)
.join('');
}
function renderEditPresetButtons() {
if (!editPresetButtons) return;
editPresetButtons.innerHTML = (tagMeta.presets || [])
.map(p => `<button type="button" class="button is-info is-light is-small is-rounded" data-edit-preset-tags="${(p.tags||[]).join(',')}">${p.name}</button>`)
.join('');
}
function renderPresetManagementList() {
if (!presetList) return;
const presets = tagMeta.presets || [];
if (!presets.length) {
presetList.innerHTML = '<p class="is-size-7 has-text-grey">No presets yet.</p>';
return;
}
presetList.innerHTML = presets.map((p, i) => `
<div class="is-flex is-align-items-center mb-1" style="gap:0.4rem;">
<span class="tag is-info is-light">${p.name}</span>
<span class="is-size-7 has-text-grey">${(p.tags||[]).join(', ')}</span>
<button type="button" class="button is-ghost is-small p-0 ml-auto edit-preset-btn" data-idx="${i}" title="Edit"><i class="fas fa-pencil fa-xs"></i></button>
<button type="button" class="button is-ghost is-small p-0 has-text-danger delete-preset-btn" data-idx="${i}" title="Delete"><i class="fas fa-times fa-xs"></i></button>
</div>`).join('');
}
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; }
const idx = editPresetIndex.value;
const method = idx !== '' ? 'PUT' : 'POST';
const url = idx !== '' ? `${backendUrl}/photos/presets/${idx}` : `${backendUrl}/photos/presets`;
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getAdminPassword()}` },
body: JSON.stringify({ name, tags })
});
if (res.ok) {
const data = await res.json();
tagMeta.presets = data.presets;
newPresetName.value = '';
newPresetTags.value = '';
editPresetIndex.value = '';
presetFormLabel.textContent = 'Add preset';
cancelPresetEdit.style.display = 'none';
renderPresetButtons();
renderPresetManagementList();
renderEditPresetButtons();
}
}
async function deletePreset(idx) {
if (!confirm('Delete this preset?')) return;
const res = await fetch(`${backendUrl}/photos/presets/${idx}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${getAdminPassword()}` }
});
if (res.ok) {
const data = await res.json();
tagMeta.presets = data.presets;
renderPresetButtons();
renderPresetManagementList();
renderEditPresetButtons();
}
}
function addTagToInput(tag, inputEl) {
const target = inputEl || tagsInput;
const canonical = canonicalizeTag(tag);
if (!canonical) return;
const existing = normalizeTagsInput(target.value);
if (!existing.includes(canonical)) existing.push(canonical);
target.value = canonicalToDisplayString(existing);
}
function applyPresetToInput(tagsStr, inputEl) {
tagsStr.split(',').forEach(t => addTagToInput(t.trim(), inputEl));
}
function preloadLastTags() {
const last = localStorage.getItem(LAST_TAGS_KEY);
if (last && tagsInput && !tagsInput.value) {
const canonical = normalizeTagsInput(last);
tagsInput.value = canonicalToDisplayString(canonical);
}
}
if (quickTagButtons) {
quickTagButtons.addEventListener('click', (e) => {
const tagBtn = e.target.closest('button[data-tag]');
if (tagBtn) addTagToInput(tagBtn.dataset.tag);
});
}
if (presetButtons) {
presetButtons.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-preset-tags]');
if (btn) applyPresetToInput(btn.dataset.presetTags, tagsInput);
});
}
if (editPresetButtons) {
editPresetButtons.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-edit-preset-tags]');
if (btn) applyPresetToInput(btn.dataset.editPresetTags, editTags);
});
}
if (toggleManagePresets) {
toggleManagePresets.addEventListener('click', () => {
const open = managePresetsPanel.style.display === 'none';
managePresetsPanel.style.display = open ? '' : 'none';
toggleManagePresets.textContent = open ? 'Done' : 'Manage';
if (open) renderPresetManagementList();
});
}
if (savePresetBtn) savePresetBtn.addEventListener('click', savePreset);
if (cancelPresetEdit) {
cancelPresetEdit.addEventListener('click', () => {
newPresetName.value = '';
newPresetTags.value = '';
editPresetIndex.value = '';
presetFormLabel.textContent = 'Add preset';
cancelPresetEdit.style.display = 'none';
});
}
if (presetList) {
presetList.addEventListener('click', (e) => {
const editBtn = e.target.closest('.edit-preset-btn');
const delBtn = e.target.closest('.delete-preset-btn');
if (editBtn) {
const idx = parseInt(editBtn.dataset.idx, 10);
const preset = (tagMeta.presets || [])[idx];
if (!preset) return;
editPresetIndex.value = idx;
newPresetName.value = preset.name;
newPresetTags.value = (preset.tags || []).join(', ');
presetFormLabel.textContent = 'Edit preset';
cancelPresetEdit.style.display = '';
newPresetName.focus();
}
if (delBtn) deletePreset(parseInt(delBtn.dataset.idx, 10));
});
}
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(true));
modalCloseButton.addEventListener('click', closeEditModal);
modalCancelButton.addEventListener('click', closeEditModal);
if (editPrevBtn) editPrevBtn.addEventListener('click', () => navigateEditModal(-1));
if (editNextBtn) editNextBtn.addEventListener('click', () => navigateEditModal(1));
applyBulkEdits.addEventListener('click', bulkApplyEdits);
bulkDelete.addEventListener('click', bulkDeletePhotos);
confirmBulkDeleteBtn.addEventListener('click', handleConfirmBulkDelete);
cancelBulkDeleteBtn.addEventListener('click', closeBulkDeleteModal);
bulkDeleteModalCloseBtn.addEventListener('click', closeBulkDeleteModal);
if (needsTaggingBtn) {
needsTaggingBtn.addEventListener('click', () => {
needsTaggingFilter = !needsTaggingFilter;
needsTaggingBtn.classList.toggle('is-warning', !needsTaggingFilter);
needsTaggingBtn.classList.toggle('is-warning is-light', !needsTaggingFilter);
needsTaggingBtn.classList.toggle('is-warning', needsTaggingFilter);
needsTaggingBtn.style.fontWeight = needsTaggingFilter ? 'bold' : '';
renderManageGallery();
});
}
// Keyboard shortcuts in edit modal
document.addEventListener('keydown', (e) => {
if (!editModal.classList.contains('is-active')) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSaveChanges(true);
}
return;
}
if (e.key === 'ArrowRight') { e.preventDefault(); navigateEditModal(1); }
if (e.key === 'ArrowLeft') { e.preventDefault(); navigateEditModal(-1); }
if (e.key === 'Escape') { e.preventDefault(); closeEditModal(); }
if (e.key === 'Enter') { e.preventDefault(); handleSaveChanges(true); }
});
// --- 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;
isClosedCheckbox.dispatchEvent(new Event('change'));
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',
'Authorization': `Bearer ${getAdminPassword()}`,
},
body: JSON.stringify({ data })
});
if (response.status === 401) {
handleUnauthorized();
return;
}
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');
}
});
});