- 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>
1056 lines
44 KiB
JavaScript
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');
|
|
}
|
|
});
|
|
});
|