feat: editable tag presets, next/prev modal nav, needs-tagging filter
- Restructured presets as single-tag accumulators (click multiple to build up tags) - Added 6 new tags: bridal-shower, cocktail, signature, indoor, outdoor, mitzvah - Fixed organic/garland alias conflict - Presets stored in data/presets.json with full CRUD API (add, edit, delete from admin) - Edit modal shows photo thumbnail, prev/next navigation, preset buttons - Keyboard shortcuts: Ctrl+Enter to save, arrow keys to navigate, Esc to close - "Needs tagging" filter in manage view shows only uncategorized/low-tag photos Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0e4461e957
commit
7fce1632be
@ -22,13 +22,30 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const captionToTagsButton = document.getElementById('captionToTags');
|
const captionToTagsButton = document.getElementById('captionToTags');
|
||||||
const manageGallery = document.getElementById('manage-gallery');
|
const manageGallery = document.getElementById('manage-gallery');
|
||||||
const manageSearchInput = document.getElementById('manageSearchInput');
|
const manageSearchInput = document.getElementById('manageSearchInput');
|
||||||
|
const needsTaggingBtn = document.getElementById('needsTaggingBtn');
|
||||||
const editModal = document.getElementById('editModal');
|
const editModal = document.getElementById('editModal');
|
||||||
const editPhotoId = document.getElementById('editPhotoId');
|
const editPhotoId = document.getElementById('editPhotoId');
|
||||||
const editCaption = document.getElementById('editCaption');
|
const editCaption = document.getElementById('editCaption');
|
||||||
const editTags = document.getElementById('editTags');
|
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 saveChanges = document.getElementById('saveChanges');
|
||||||
const modalCloseButton = editModal.querySelector('.delete');
|
const modalCloseButton = editModal.querySelector('.delete');
|
||||||
const modalCancelButton = editModal.querySelector('.modal-card-foot .button:not(.is-success)');
|
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
|
// Bulk Delete Modal
|
||||||
const bulkDeleteModal = document.getElementById('bulkDeleteModal');
|
const bulkDeleteModal = document.getElementById('bulkDeleteModal');
|
||||||
@ -47,6 +64,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const bulkPanel = document.getElementById('bulkPanel');
|
const bulkPanel = document.getElementById('bulkPanel');
|
||||||
let selectedPhotoIds = new Set();
|
let selectedPhotoIds = new Set();
|
||||||
let photos = [];
|
let photos = [];
|
||||||
|
let needsTaggingFilter = false;
|
||||||
|
let currentEditIndex = -1;
|
||||||
|
|
||||||
// Store Status Elements
|
// Store Status Elements
|
||||||
const messageInput = document.getElementById('scrollingMessageInput');
|
const messageInput = document.getElementById('scrollingMessageInput');
|
||||||
@ -225,12 +244,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderManageGallery() {
|
function getFilteredPhotos() {
|
||||||
manageGallery.innerHTML = '';
|
|
||||||
const query = String(manageSearchInput?.value || '').trim().toLowerCase();
|
const query = String(manageSearchInput?.value || '').trim().toLowerCase();
|
||||||
const normalizedQuery = resolveSearchTag(query);
|
const normalizedQuery = resolveSearchTag(query);
|
||||||
const filtered = query
|
let list = photos;
|
||||||
? photos.filter(photo => {
|
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 caption = String(photo.caption || '').toLowerCase();
|
||||||
const tags = Array.isArray(photo.tags) ? photo.tags : [];
|
const tags = Array.isArray(photo.tags) ? photo.tags : [];
|
||||||
const tagText = tags.map(displayTag).join(' ').toLowerCase();
|
const tagText = tags.map(displayTag).join(' ').toLowerCase();
|
||||||
@ -238,8 +263,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|| tags.some(tag => String(tag || '').toLowerCase().includes(query))
|
|| tags.some(tag => String(tag || '').toLowerCase().includes(query))
|
||||||
|| (normalizedQuery && tags.some(tag => String(tag || '').toLowerCase() === normalizedQuery))
|
|| (normalizedQuery && tags.some(tag => String(tag || '').toLowerCase() === normalizedQuery))
|
||||||
|| tagText.includes(query);
|
|| tagText.includes(query);
|
||||||
})
|
});
|
||||||
: photos;
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderManageGallery() {
|
||||||
|
manageGallery.innerHTML = '';
|
||||||
|
const filtered = getFilteredPhotos();
|
||||||
if (!filtered.length) {
|
if (!filtered.length) {
|
||||||
const message = query
|
const message = query
|
||||||
? 'No photos match your search.'
|
? 'No photos match your search.'
|
||||||
@ -282,18 +313,46 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openEditModal(photoId) {
|
function openEditModal(photoId) {
|
||||||
const photo = photos.find(p => p._id === photoId);
|
const filtered = getFilteredPhotos();
|
||||||
if (photo) {
|
const idx = filtered.findIndex(p => p._id === photoId);
|
||||||
editPhotoId.value = photo._id;
|
if (idx === -1) return;
|
||||||
editCaption.value = photo.caption;
|
currentEditIndex = idx;
|
||||||
const readable = (Array.isArray(photo.tags) ? photo.tags : []).map(displayTag).join(', ');
|
loadEditModalPhoto(filtered[idx], idx, filtered.length);
|
||||||
editTags.value = readable;
|
editModal.classList.add('is-active');
|
||||||
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() {
|
function closeEditModal() {
|
||||||
editModal.classList.remove('is-active');
|
editModal.classList.remove('is-active');
|
||||||
|
currentEditIndex = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBulkUI() {
|
function updateBulkUI() {
|
||||||
@ -323,7 +382,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
updateBulkUI();
|
updateBulkUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveChanges() {
|
async function handleSaveChanges(closeAfter = true) {
|
||||||
const photoId = editPhotoId.value;
|
const photoId = editPhotoId.value;
|
||||||
const canonicalTags = normalizeTagsInput(editTags.value);
|
const canonicalTags = normalizeTagsInput(editTags.value);
|
||||||
if (!canonicalTags.length) {
|
if (!canonicalTags.length) {
|
||||||
@ -351,9 +410,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
if (response.status === 401) { handleUnauthorized(); return; }
|
if (response.status === 401) { handleUnauthorized(); return; }
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
closeEditModal();
|
const idx = photos.findIndex(p => p._id === photoId);
|
||||||
fetchPhotos();
|
if (idx !== -1) {
|
||||||
fetchTagMeta();
|
photos[idx].caption = updatedPhoto.caption;
|
||||||
|
photos[idx].tags = canonicalTags;
|
||||||
|
}
|
||||||
|
if (closeAfter) {
|
||||||
|
closeEditModal();
|
||||||
|
renderManageGallery();
|
||||||
|
fetchTagMeta();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to save changes.');
|
alert('Failed to save changes.');
|
||||||
}
|
}
|
||||||
@ -667,24 +733,98 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
function updateQuickTags() {
|
function updateQuickTags() {
|
||||||
if (!quickTagButtons) return;
|
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 || [])]
|
const mainButtons = [...(tagMeta.main || [])]
|
||||||
.sort(sortTagsByCount)
|
.sort(sortTagsByCount)
|
||||||
.map(tag => `<button type="button" class="button is-link is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
|
.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 || [])]
|
const otherButtons = [...(tagMeta.other || [])]
|
||||||
.sort(sortTagsByCount)
|
.sort(sortTagsByCount)
|
||||||
.map(tag => `<button type="button" class="button is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
|
.map(tag => `<button type="button" class="button is-light is-small is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
|
||||||
quickTagButtons.innerHTML = [...presetButtons, ...mainButtons, ...otherButtons].join('');
|
quickTagButtons.innerHTML = [...mainButtons, ...otherButtons].join('');
|
||||||
|
renderPresetButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTagToInput(tag) {
|
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);
|
const canonical = canonicalizeTag(tag);
|
||||||
if (!canonical) return;
|
if (!canonical) return;
|
||||||
const existing = normalizeTagsInput(tagsInput.value);
|
const existing = normalizeTagsInput(target.value);
|
||||||
if (!existing.includes(canonical)) {
|
if (!existing.includes(canonical)) existing.push(canonical);
|
||||||
existing.push(canonical);
|
target.value = canonicalToDisplayString(existing);
|
||||||
}
|
}
|
||||||
tagsInput.value = canonicalToDisplayString(existing);
|
|
||||||
|
function applyPresetToInput(tagsStr, inputEl) {
|
||||||
|
tagsStr.split(',').forEach(t => addTagToInput(t.trim(), inputEl));
|
||||||
}
|
}
|
||||||
|
|
||||||
function preloadLastTags() {
|
function preloadLastTags() {
|
||||||
@ -695,24 +835,58 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if (quickTagButtons) {
|
||||||
quickTagButtons.addEventListener('click', (e) => {
|
quickTagButtons.addEventListener('click', (e) => {
|
||||||
const presetBtn = e.target.closest('button[data-preset]');
|
|
||||||
const tagBtn = e.target.closest('button[data-tag]');
|
const tagBtn = e.target.closest('button[data-tag]');
|
||||||
if (presetBtn) {
|
if (tagBtn) addTagToInput(tagBtn.dataset.tag);
|
||||||
applyPresetTags(presetBtn.dataset.preset);
|
});
|
||||||
return;
|
}
|
||||||
}
|
if (presetButtons) {
|
||||||
if (tagBtn) {
|
presetButtons.addEventListener('click', (e) => {
|
||||||
addTagToInput(tagBtn.dataset.tag);
|
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));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -730,15 +904,44 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateBulkUI();
|
updateBulkUI();
|
||||||
saveChanges.addEventListener('click', handleSaveChanges);
|
saveChanges.addEventListener('click', () => handleSaveChanges(true));
|
||||||
modalCloseButton.addEventListener('click', closeEditModal);
|
modalCloseButton.addEventListener('click', closeEditModal);
|
||||||
modalCancelButton.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);
|
applyBulkEdits.addEventListener('click', bulkApplyEdits);
|
||||||
bulkDelete.addEventListener('click', bulkDeletePhotos);
|
bulkDelete.addEventListener('click', bulkDeletePhotos);
|
||||||
confirmBulkDeleteBtn.addEventListener('click', handleConfirmBulkDelete);
|
confirmBulkDeleteBtn.addEventListener('click', handleConfirmBulkDelete);
|
||||||
cancelBulkDeleteBtn.addEventListener('click', closeBulkDeleteModal);
|
cancelBulkDeleteBtn.addEventListener('click', closeBulkDeleteModal);
|
||||||
bulkDeleteModalCloseBtn.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 ---
|
// --- Store Status Management ---
|
||||||
async function fetchStatus() {
|
async function fetchStatus() {
|
||||||
|
|||||||
@ -108,6 +108,34 @@
|
|||||||
<div class="buttons are-small mt-2" id="quickTagButtons" aria-label="Quick tag suggestions">
|
<div class="buttons are-small mt-2" id="quickTagButtons" aria-label="Quick tag suggestions">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-center mb-2">
|
||||||
|
<label class="label mb-0">Presets</label>
|
||||||
|
<button type="button" class="button is-ghost is-small p-0" id="toggleManagePresets">Manage</button>
|
||||||
|
</div>
|
||||||
|
<div class="buttons are-small" id="presetButtons" aria-label="Tag presets"></div>
|
||||||
|
<div id="managePresetsPanel" style="display:none;" class="box has-background-white-bis p-3 mt-2">
|
||||||
|
<p class="is-size-7 has-text-weight-semibold mb-2">Current presets</p>
|
||||||
|
<div id="presetList" class="mb-3"></div>
|
||||||
|
<hr class="my-2">
|
||||||
|
<p class="is-size-7 has-text-weight-semibold mb-2" id="presetFormLabel">Add preset</p>
|
||||||
|
<div class="field is-grouped">
|
||||||
|
<input type="hidden" id="editPresetIndex" value="">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input class="input is-small" type="text" id="newPresetName" placeholder="Name (e.g. Pastel)">
|
||||||
|
</div>
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input class="input is-small" type="text" id="newPresetTags" placeholder="Tags (e.g. pastel, balloon)">
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button type="button" class="button is-primary is-small" id="savePresetBtn">Save</button>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button type="button" class="button is-light is-small" id="cancelPresetEdit" style="display:none;">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-primary is-fullwidth" id="uploadButton">
|
<button class="button is-primary is-fullwidth" id="uploadButton">
|
||||||
<span class="icon"><i class="fas fa-upload"></i></span>
|
<span class="icon"><i class="fas fa-upload"></i></span>
|
||||||
@ -127,10 +155,18 @@
|
|||||||
<p class="is-size-7 has-text-grey">Edit captions/tags or delete images.</p>
|
<p class="is-size-7 has-text-grey">Edit captions/tags or delete images.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field mb-4">
|
<div class="field mb-3">
|
||||||
<div class="control has-icons-left">
|
<div class="is-flex" style="gap:0.5rem;">
|
||||||
<input class="input is-small has-background-light has-text-dark" type="text" id="manageSearchInput" placeholder="Search by caption or tag…">
|
<div class="control has-icons-left is-expanded">
|
||||||
<span class="icon is-small is-left"><i class="fas fa-search"></i></span>
|
<input class="input is-small has-background-light has-text-dark" type="text" id="manageSearchInput" placeholder="Search by caption or tag…">
|
||||||
|
<span class="icon is-small is-left"><i class="fas fa-search"></i></span>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button type="button" class="button is-small is-warning is-light" id="needsTaggingBtn" title="Show only photos that need tagging">
|
||||||
|
<span class="icon"><i class="fas fa-tag"></i></span>
|
||||||
|
<span>Needs tagging</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="box has-background-light mb-4" id="bulkPanel" style="display: none;">
|
<div class="box has-background-light mb-4" id="bulkPanel" style="display: none;">
|
||||||
@ -237,11 +273,14 @@
|
|||||||
<div class="modal-background"></div>
|
<div class="modal-background"></div>
|
||||||
<div class="modal-card has-background-light">
|
<div class="modal-card has-background-light">
|
||||||
<header class="modal-card-head">
|
<header class="modal-card-head">
|
||||||
<p class="modal-card-title has-text-dark has-background-light">Edit Photo</p>
|
<p class="modal-card-title has-text-dark has-background-light" id="editModalTitle">Edit Photo</p>
|
||||||
<button class="delete" aria-label="close"></button>
|
<button class="delete" aria-label="close"></button>
|
||||||
</header>
|
</header>
|
||||||
<section class="modal-card-body">
|
<section class="modal-card-body">
|
||||||
<input type="hidden" id="editPhotoId">
|
<input type="hidden" id="editPhotoId">
|
||||||
|
<figure class="image mb-3" style="max-height:260px;overflow:hidden;border-radius:6px;">
|
||||||
|
<img id="editModalImg" src="" alt="" style="object-fit:contain;max-height:260px;width:100%;">
|
||||||
|
</figure>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Caption</label>
|
<label class="label">Caption</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
@ -251,13 +290,20 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Tags</label>
|
<label class="label">Tags</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input class="input has-background-light has-text-black" type="text" id="editTags">
|
<input class="input has-background-light has-text-black" type="text" id="editTags" placeholder="e.g. Arch, Classic, Birthday">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="buttons are-small mt-2" id="editPresetButtons"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<footer class="modal-card-foot">
|
<footer class="modal-card-foot is-justify-content-space-between">
|
||||||
<button id="saveChanges" class="button is-success">Save changes</button>
|
<div class="buttons">
|
||||||
<button class="button">Cancel</button>
|
<button id="editPrevBtn" class="button" title="Previous photo (←)"><span class="icon"><i class="fas fa-chevron-left"></i></span><span>Prev</span></button>
|
||||||
|
<button id="editNextBtn" class="button" title="Next photo (→)"><span>Next</span><span class="icon"><i class="fas fa-chevron-right"></i></span></button>
|
||||||
|
</div>
|
||||||
|
<div class="buttons">
|
||||||
|
<button id="saveChanges" class="button is-success" title="Save (Enter)">Save</button>
|
||||||
|
<button class="button" title="Cancel (Esc)">Cancel</button>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
16
main-site/photo-gallery-app/backend/data/presets.json
Normal file
16
main-site/photo-gallery-app/backend/data/presets.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{ "name": "Classic", "tags": ["classic"] },
|
||||||
|
{ "name": "Organic", "tags": ["organic"] },
|
||||||
|
{ "name": "Arch", "tags": ["arch"] },
|
||||||
|
{ "name": "Garland", "tags": ["garland"] },
|
||||||
|
{ "name": "Columns", "tags": ["columns"] },
|
||||||
|
{ "name": "Birthday", "tags": ["birthday"] },
|
||||||
|
{ "name": "Baby Shower", "tags": ["baby-shower"] },
|
||||||
|
{ "name": "Bridal Shower", "tags": ["bridal-shower"] },
|
||||||
|
{ "name": "Graduation", "tags": ["graduation"] },
|
||||||
|
{ "name": "Cocktail", "tags": ["cocktail"] },
|
||||||
|
{ "name": "Signature", "tags": ["signature"] },
|
||||||
|
{ "name": "Indoor", "tags": ["indoor"] },
|
||||||
|
{ "name": "Outdoor", "tags": ["outdoor"] },
|
||||||
|
{ "name": "Mitzvah", "tags": ["mitzvah"] }
|
||||||
|
]
|
||||||
@ -1,12 +1,11 @@
|
|||||||
const MAIN_TAGS = [
|
const MAIN_TAGS = [
|
||||||
{ slug: 'arch', label: 'Arch', aliases: ['arches', 'archway'] },
|
{ slug: 'arch', label: 'Arch', aliases: ['arches', 'archway'] },
|
||||||
{ slug: 'garland', label: 'Garland', aliases: ['organic', 'organic-garland'] },
|
{ slug: 'garland', label: 'Garland', aliases: ['organic-garland'] },
|
||||||
{ slug: 'columns', label: 'Columns', aliases: ['pillars'] },
|
{ slug: 'columns', label: 'Columns', aliases: ['pillars'] },
|
||||||
{ slug: 'centerpiece', label: 'Centerpiece', aliases: ['table', 'tablescape'] },
|
{ slug: 'centerpiece', label: 'Centerpiece', aliases: ['table', 'tablescape'] },
|
||||||
{ slug: 'sculpture', label: 'Sculpture', aliases: ['sculpt'] },
|
{ slug: 'sculpture', label: 'Sculpture', aliases: ['sculpt'] },
|
||||||
{ slug: 'birthday', label: 'Birthday', aliases: ['bday', 'birthday-party'] },
|
{ slug: 'birthday', label: 'Birthday', aliases: ['bday', 'birthday-party'] },
|
||||||
{ slug: 'baby-shower', label: 'Baby Shower', aliases: ['baby', 'shower'] },
|
{ slug: 'baby-shower', label: 'Baby Shower', aliases: ['baby', 'shower'] },
|
||||||
{ slug: 'gifts', label: 'Gifts', aliases: ['presents'] },
|
|
||||||
{ slug: 'graduation', label: 'Graduation', aliases: ['grad', 'commencement'] },
|
{ slug: 'graduation', label: 'Graduation', aliases: ['grad', 'commencement'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -14,8 +13,14 @@ const OTHER_TAGS = [
|
|||||||
{ slug: 'classic', label: 'Classic', aliases: [] },
|
{ slug: 'classic', label: 'Classic', aliases: [] },
|
||||||
{ slug: 'organic', label: 'Organic', aliases: [] },
|
{ slug: 'organic', label: 'Organic', aliases: [] },
|
||||||
{ slug: 'hoop', label: 'Hoop', aliases: ['ring'] },
|
{ slug: 'hoop', label: 'Hoop', aliases: ['ring'] },
|
||||||
|
{ slug: 'signature', label: 'Signature', aliases: [] },
|
||||||
{ slug: 'helium', label: 'Helium', aliases: [] },
|
{ slug: 'helium', label: 'Helium', aliases: [] },
|
||||||
{ slug: 'air-filled', label: 'Air-filled', aliases: ['airfilled', 'air'] },
|
{ slug: 'air-filled', label: 'Air-filled', aliases: ['airfilled', 'air'] },
|
||||||
|
{ slug: 'indoor', label: 'Indoor', aliases: ['inside'] },
|
||||||
|
{ slug: 'outdoor', label: 'Outdoor', aliases: ['outside'] },
|
||||||
|
{ slug: 'cocktail', label: 'Cocktail', aliases: ['cocktail-party', 'reception'] },
|
||||||
|
{ slug: 'bridal-shower', label: 'Bridal Shower', aliases: ['bridal', 'bride-shower'] },
|
||||||
|
{ slug: 'mitzvah', label: 'Mitzvah', aliases: ['bar-mitzvah', 'bat-mitzvah', 'bnai-mitzvah'] },
|
||||||
{ slug: 'reunion', label: 'Reunion', aliases: [] },
|
{ slug: 'reunion', label: 'Reunion', aliases: [] },
|
||||||
{ slug: 'corporate', label: 'Corporate', aliases: ['business', 'office'] },
|
{ slug: 'corporate', label: 'Corporate', aliases: ['business', 'office'] },
|
||||||
{ slug: 'holiday', label: 'Holiday', aliases: ['holidays'] },
|
{ slug: 'holiday', label: 'Holiday', aliases: ['holidays'] },
|
||||||
@ -29,23 +34,28 @@ const OTHER_TAGS = [
|
|||||||
{ slug: 'st-patricks', label: "St. Patrick's Day", aliases: ['st-pattys', 'st-paddys', 'st-patricks-day'] },
|
{ slug: 'st-patricks', label: "St. Patrick's Day", aliases: ['st-pattys', 'st-paddys', 'st-patricks-day'] },
|
||||||
{ slug: 'mothers-day', label: "Mother's Day", aliases: ['mothers', 'mom-day'] },
|
{ slug: 'mothers-day', label: "Mother's Day", aliases: ['mothers', 'mom-day'] },
|
||||||
{ slug: 'fathers-day', label: "Father's Day", aliases: ['fathers', 'dad-day'] },
|
{ slug: 'fathers-day', label: "Father's Day", aliases: ['fathers', 'dad-day'] },
|
||||||
{ slug: 'graduation-party', label: 'Graduation Party', aliases: ['grad-party', 'graduation-party'] },
|
|
||||||
{ slug: 'marquee', label: 'Marquee Letters', aliases: ['letters', 'marquee-letters'] },
|
{ slug: 'marquee', label: 'Marquee Letters', aliases: ['letters', 'marquee-letters'] },
|
||||||
{ slug: 'neon', label: 'Neon', aliases: ['led', 'light', 'lights'] },
|
{ slug: 'neon', label: 'Neon', aliases: ['led', 'light', 'lights'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TAG_DEFINITIONS = [...MAIN_TAGS, ...OTHER_TAGS];
|
const TAG_DEFINITIONS = [...MAIN_TAGS, ...OTHER_TAGS];
|
||||||
|
|
||||||
|
// Default presets — overridden at runtime by data/presets.json if present
|
||||||
const TAG_PRESETS = [
|
const TAG_PRESETS = [
|
||||||
{ name: 'Arch - Classic', tags: ['arch', 'classic'] },
|
{ name: 'Classic', tags: ['classic'] },
|
||||||
{ name: 'Arch - Organic', tags: ['arch', 'organic'] },
|
{ name: 'Organic', tags: ['organic'] },
|
||||||
{ name: 'Arch - Hoop', tags: ['arch', 'hoop'] },
|
{ name: 'Arch', tags: ['arch'] },
|
||||||
{ name: 'Garland - Organic', tags: ['garland', 'organic'] },
|
{ name: 'Garland', tags: ['garland'] },
|
||||||
{ name: 'Columns', tags: ['columns'] },
|
{ name: 'Columns', tags: ['columns'] },
|
||||||
{ name: 'Centerpiece - Helium', tags: ['centerpiece', 'helium'] },
|
{ name: 'Birthday', tags: ['birthday'] },
|
||||||
{ name: 'Centerpiece - Air-filled', tags: ['centerpiece', 'air-filled'] },
|
{ name: 'Baby Shower', tags: ['baby-shower'] },
|
||||||
{ name: 'Sculpture', tags: ['sculpture'] },
|
{ name: 'Bridal Shower', tags: ['bridal-shower'] },
|
||||||
{ name: 'Marquee', tags: ['marquee'] }
|
{ name: 'Graduation', tags: ['graduation'] },
|
||||||
|
{ name: 'Cocktail', tags: ['cocktail'] },
|
||||||
|
{ name: 'Signature', tags: ['signature'] },
|
||||||
|
{ name: 'Indoor', tags: ['indoor'] },
|
||||||
|
{ name: 'Outdoor', tags: ['outdoor'] },
|
||||||
|
{ name: 'Mitzvah', tags: ['mitzvah'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MAX_TAGS = 8;
|
const MAX_TAGS = 8;
|
||||||
|
|||||||
@ -80,6 +80,20 @@ async function applyInvisibleWatermark(buffer, payload, filename) {
|
|||||||
const storage = multer.memoryStorage();
|
const storage = multer.memoryStorage();
|
||||||
const upload = multer({ storage: storage });
|
const upload = multer({ storage: storage });
|
||||||
|
|
||||||
|
// Preset file helpers
|
||||||
|
const PRESETS_FILE = path.join(__dirname, '..', 'data', 'presets.json');
|
||||||
|
function loadPresets() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(PRESETS_FILE, 'utf8'));
|
||||||
|
} catch (_e) {
|
||||||
|
return TAG_PRESETS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function savePresets(presets) {
|
||||||
|
fs.mkdirSync(path.dirname(PRESETS_FILE), { recursive: true });
|
||||||
|
fs.writeFileSync(PRESETS_FILE, JSON.stringify(presets, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
// GET all photos
|
// GET all photos
|
||||||
router.route('/').get((req, res) => {
|
router.route('/').get((req, res) => {
|
||||||
Photo.find().sort({ createdAt: -1 }) // Sort by newest first
|
Photo.find().sort({ createdAt: -1 }) // Sort by newest first
|
||||||
@ -103,7 +117,7 @@ router.route('/tags').get(async (_req, res) => {
|
|||||||
main: MAIN_TAGS,
|
main: MAIN_TAGS,
|
||||||
other: OTHER_TAGS,
|
other: OTHER_TAGS,
|
||||||
aliases: aliasMap,
|
aliases: aliasMap,
|
||||||
presets: TAG_PRESETS,
|
presets: loadPresets(),
|
||||||
maxTags: MAX_TAGS,
|
maxTags: MAX_TAGS,
|
||||||
labels: labelLookup,
|
labels: labelLookup,
|
||||||
existing: existing || [],
|
existing: existing || [],
|
||||||
@ -116,7 +130,7 @@ router.route('/tags').get(async (_req, res) => {
|
|||||||
main: MAIN_TAGS,
|
main: MAIN_TAGS,
|
||||||
other: OTHER_TAGS,
|
other: OTHER_TAGS,
|
||||||
aliases: aliasMap,
|
aliases: aliasMap,
|
||||||
presets: TAG_PRESETS,
|
presets: loadPresets(),
|
||||||
maxTags: MAX_TAGS,
|
maxTags: MAX_TAGS,
|
||||||
labels: labelLookup,
|
labels: labelLookup,
|
||||||
existing: [],
|
existing: [],
|
||||||
@ -125,6 +139,40 @@ router.route('/tags').get(async (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Preset CRUD
|
||||||
|
router.route('/presets')
|
||||||
|
.get((_req, res) => res.json(loadPresets()))
|
||||||
|
.post(requireAuth, (req, res) => {
|
||||||
|
const { name, tags } = req.body;
|
||||||
|
if (!name || !Array.isArray(tags) || !tags.length) {
|
||||||
|
return res.status(400).json({ error: 'name and tags[] required' });
|
||||||
|
}
|
||||||
|
const presets = loadPresets();
|
||||||
|
presets.push({ name: String(name).trim(), tags });
|
||||||
|
savePresets(presets);
|
||||||
|
res.json({ success: true, presets });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.route('/presets/:index')
|
||||||
|
.put(requireAuth, (req, res) => {
|
||||||
|
const idx = parseInt(req.params.index, 10);
|
||||||
|
const { name, tags } = req.body;
|
||||||
|
const presets = loadPresets();
|
||||||
|
if (idx < 0 || idx >= presets.length) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (name) presets[idx].name = String(name).trim();
|
||||||
|
if (Array.isArray(tags)) presets[idx].tags = tags;
|
||||||
|
savePresets(presets);
|
||||||
|
res.json({ success: true, presets });
|
||||||
|
})
|
||||||
|
.delete(requireAuth, (req, res) => {
|
||||||
|
const idx = parseInt(req.params.index, 10);
|
||||||
|
const presets = loadPresets();
|
||||||
|
if (idx < 0 || idx >= presets.length) return res.status(404).json({ error: 'Not found' });
|
||||||
|
presets.splice(idx, 1);
|
||||||
|
savePresets(presets);
|
||||||
|
res.json({ success: true, presets });
|
||||||
|
});
|
||||||
|
|
||||||
const parseIncomingTags = (tagsInput) => {
|
const parseIncomingTags = (tagsInput) => {
|
||||||
const rawList = Array.isArray(tagsInput)
|
const rawList = Array.isArray(tagsInput)
|
||||||
? tagsInput
|
? tagsInput
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user