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:
chris 2026-05-21 11:42:32 -04:00
parent 0e4461e957
commit 7fce1632be
5 changed files with 387 additions and 64 deletions

View File

@ -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() {

View File

@ -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>

View 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"] }
]

View File

@ -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;

View File

@ -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