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 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');
|
||||
@ -47,6 +64,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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');
|
||||
@ -225,12 +244,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function renderManageGallery() {
|
||||
manageGallery.innerHTML = '';
|
||||
function getFilteredPhotos() {
|
||||
const query = String(manageSearchInput?.value || '').trim().toLowerCase();
|
||||
const normalizedQuery = resolveSearchTag(query);
|
||||
const filtered = query
|
||||
? photos.filter(photo => {
|
||||
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();
|
||||
@ -238,8 +263,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|| tags.some(tag => String(tag || '').toLowerCase().includes(query))
|
||||
|| (normalizedQuery && tags.some(tag => String(tag || '').toLowerCase() === normalizedQuery))
|
||||
|| tagText.includes(query);
|
||||
})
|
||||
: photos;
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function renderManageGallery() {
|
||||
manageGallery.innerHTML = '';
|
||||
const filtered = getFilteredPhotos();
|
||||
if (!filtered.length) {
|
||||
const message = query
|
||||
? 'No photos match your search.'
|
||||
@ -282,18 +313,46 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
function openEditModal(photoId) {
|
||||
const photo = photos.find(p => p._id === photoId);
|
||||
if (photo) {
|
||||
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;
|
||||
editModal.classList.add('is-active');
|
||||
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() {
|
||||
@ -323,7 +382,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
updateBulkUI();
|
||||
}
|
||||
|
||||
async function handleSaveChanges() {
|
||||
async function handleSaveChanges(closeAfter = true) {
|
||||
const photoId = editPhotoId.value;
|
||||
const canonicalTags = normalizeTagsInput(editTags.value);
|
||||
if (!canonicalTags.length) {
|
||||
@ -351,9 +410,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
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();
|
||||
fetchPhotos();
|
||||
renderManageGallery();
|
||||
fetchTagMeta();
|
||||
}
|
||||
} else {
|
||||
alert('Failed to save changes.');
|
||||
}
|
||||
@ -667,24 +733,98 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
function updateQuickTags() {
|
||||
if (!quickTagButtons) return;
|
||||
const presetButtons = (tagMeta.presets || []).map(preset => `<button type="button" class="button is-light is-rounded" data-preset="${preset.name}">${preset.name} preset</button>`);
|
||||
const mainButtons = [...(tagMeta.main || [])]
|
||||
.sort(sortTagsByCount)
|
||||
.map(tag => `<button type="button" class="button is-link is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
|
||||
.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-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
|
||||
quickTagButtons.innerHTML = [...presetButtons, ...mainButtons, ...otherButtons].join('');
|
||||
.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 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);
|
||||
if (!canonical) return;
|
||||
const existing = normalizeTagsInput(tagsInput.value);
|
||||
if (!existing.includes(canonical)) {
|
||||
existing.push(canonical);
|
||||
const existing = normalizeTagsInput(target.value);
|
||||
if (!existing.includes(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() {
|
||||
@ -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) {
|
||||
quickTagButtons.addEventListener('click', (e) => {
|
||||
const presetBtn = e.target.closest('button[data-preset]');
|
||||
const tagBtn = e.target.closest('button[data-tag]');
|
||||
if (presetBtn) {
|
||||
applyPresetTags(presetBtn.dataset.preset);
|
||||
return;
|
||||
if (tagBtn) addTagToInput(tagBtn.dataset.tag);
|
||||
});
|
||||
}
|
||||
if (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));
|
||||
});
|
||||
}
|
||||
|
||||
@ -730,15 +904,44 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
updateBulkUI();
|
||||
saveChanges.addEventListener('click', handleSaveChanges);
|
||||
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() {
|
||||
|
||||
@ -108,6 +108,34 @@
|
||||
<div class="buttons are-small mt-2" id="quickTagButtons" aria-label="Quick tag suggestions">
|
||||
</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">
|
||||
<button class="button is-primary is-fullwidth" id="uploadButton">
|
||||
<span class="icon"><i class="fas fa-upload"></i></span>
|
||||
@ -127,11 +155,19 @@
|
||||
<p class="is-size-7 has-text-grey">Edit captions/tags or delete images.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field mb-4">
|
||||
<div class="control has-icons-left">
|
||||
<div class="field mb-3">
|
||||
<div class="is-flex" style="gap:0.5rem;">
|
||||
<div class="control has-icons-left is-expanded">
|
||||
<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 class="box has-background-light mb-4" id="bulkPanel" style="display: none;">
|
||||
<div class="columns is-vcentered is-mobile">
|
||||
@ -237,11 +273,14 @@
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card has-background-light">
|
||||
<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>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<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">
|
||||
<label class="label">Caption</label>
|
||||
<div class="control">
|
||||
@ -251,13 +290,20 @@
|
||||
<div class="field">
|
||||
<label class="label">Tags</label>
|
||||
<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 class="buttons are-small mt-2" id="editPresetButtons"></div>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<button id="saveChanges" class="button is-success">Save changes</button>
|
||||
<button class="button">Cancel</button>
|
||||
<footer class="modal-card-foot is-justify-content-space-between">
|
||||
<div class="buttons">
|
||||
<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>
|
||||
</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 = [
|
||||
{ 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: 'centerpiece', label: 'Centerpiece', aliases: ['table', 'tablescape'] },
|
||||
{ slug: 'sculpture', label: 'Sculpture', aliases: ['sculpt'] },
|
||||
{ slug: 'birthday', label: 'Birthday', aliases: ['bday', 'birthday-party'] },
|
||||
{ slug: 'baby-shower', label: 'Baby Shower', aliases: ['baby', 'shower'] },
|
||||
{ slug: 'gifts', label: 'Gifts', aliases: ['presents'] },
|
||||
{ slug: 'graduation', label: 'Graduation', aliases: ['grad', 'commencement'] },
|
||||
];
|
||||
|
||||
@ -14,8 +13,14 @@ const OTHER_TAGS = [
|
||||
{ slug: 'classic', label: 'Classic', aliases: [] },
|
||||
{ slug: 'organic', label: 'Organic', aliases: [] },
|
||||
{ slug: 'hoop', label: 'Hoop', aliases: ['ring'] },
|
||||
{ slug: 'signature', label: 'Signature', aliases: [] },
|
||||
{ slug: 'helium', label: 'Helium', aliases: [] },
|
||||
{ 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: 'corporate', label: 'Corporate', aliases: ['business', 'office'] },
|
||||
{ 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: 'mothers-day', label: "Mother's Day", aliases: ['mothers', 'mom-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: 'neon', label: 'Neon', aliases: ['led', 'light', 'lights'] },
|
||||
];
|
||||
|
||||
const TAG_DEFINITIONS = [...MAIN_TAGS, ...OTHER_TAGS];
|
||||
|
||||
// Default presets — overridden at runtime by data/presets.json if present
|
||||
const TAG_PRESETS = [
|
||||
{ name: 'Arch - Classic', tags: ['arch', 'classic'] },
|
||||
{ name: 'Arch - Organic', tags: ['arch', 'organic'] },
|
||||
{ name: 'Arch - Hoop', tags: ['arch', 'hoop'] },
|
||||
{ name: 'Garland - Organic', tags: ['garland', 'organic'] },
|
||||
{ name: 'Classic', tags: ['classic'] },
|
||||
{ name: 'Organic', tags: ['organic'] },
|
||||
{ name: 'Arch', tags: ['arch'] },
|
||||
{ name: 'Garland', tags: ['garland'] },
|
||||
{ name: 'Columns', tags: ['columns'] },
|
||||
{ name: 'Centerpiece - Helium', tags: ['centerpiece', 'helium'] },
|
||||
{ name: 'Centerpiece - Air-filled', tags: ['centerpiece', 'air-filled'] },
|
||||
{ name: 'Sculpture', tags: ['sculpture'] },
|
||||
{ name: 'Marquee', tags: ['marquee'] }
|
||||
{ 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'] },
|
||||
];
|
||||
|
||||
const MAX_TAGS = 8;
|
||||
|
||||
@ -80,6 +80,20 @@ async function applyInvisibleWatermark(buffer, payload, filename) {
|
||||
const storage = multer.memoryStorage();
|
||||
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
|
||||
router.route('/').get((req, res) => {
|
||||
Photo.find().sort({ createdAt: -1 }) // Sort by newest first
|
||||
@ -103,7 +117,7 @@ router.route('/tags').get(async (_req, res) => {
|
||||
main: MAIN_TAGS,
|
||||
other: OTHER_TAGS,
|
||||
aliases: aliasMap,
|
||||
presets: TAG_PRESETS,
|
||||
presets: loadPresets(),
|
||||
maxTags: MAX_TAGS,
|
||||
labels: labelLookup,
|
||||
existing: existing || [],
|
||||
@ -116,7 +130,7 @@ router.route('/tags').get(async (_req, res) => {
|
||||
main: MAIN_TAGS,
|
||||
other: OTHER_TAGS,
|
||||
aliases: aliasMap,
|
||||
presets: TAG_PRESETS,
|
||||
presets: loadPresets(),
|
||||
maxTags: MAX_TAGS,
|
||||
labels: labelLookup,
|
||||
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 rawList = Array.isArray(tagsInput)
|
||||
? tagsInput
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user