diff --git a/main-site/admin/admin.js b/main-site/admin/admin.js index 42933de..97f1a49 100644 --- a/main-site/admin/admin.js +++ b/main-site/admin/admin.js @@ -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) { - 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'); + const filtered = getFilteredPhotos(); + const idx = filtered.findIndex(p => p._id === photoId); + if (idx === -1) return; + currentEditIndex = idx; + loadEditModalPhoto(filtered[idx], idx, filtered.length); + editModal.classList.add('is-active'); + setTimeout(() => editCaption.focus(), 50); + } + + function loadEditModalPhoto(photo, idx, total) { + editPhotoId.value = photo._id; + editCaption.value = photo.caption; + const readable = (Array.isArray(photo.tags) ? photo.tags : []).map(displayTag).join(', '); + editTags.value = readable; + if (editModalImg) { + const src = photo.variants?.thumb + ? `${backendUrl}/${photo.variants.thumb}` + : `${backendUrl}/${photo.path}`; + editModalImg.src = src; + editModalImg.alt = photo.caption; } + if (editModalTitle) editModalTitle.textContent = `Photo ${idx + 1} of ${total}`; + if (editPrevBtn) editPrevBtn.disabled = idx === 0; + if (editNextBtn) editNextBtn.disabled = idx === total - 1; + renderEditPresetButtons(); + } + + function navigateEditModal(delta) { + const filtered = getFilteredPhotos(); + const newIdx = currentEditIndex + delta; + if (newIdx < 0 || newIdx >= filtered.length) return; + handleSaveChanges(false).then(() => { + currentEditIndex = newIdx; + loadEditModalPhoto(filtered[newIdx], newIdx, filtered.length); + }); } function closeEditModal() { editModal.classList.remove('is-active'); + currentEditIndex = -1; } function updateBulkUI() { @@ -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) { - closeEditModal(); - fetchPhotos(); - fetchTagMeta(); + const idx = photos.findIndex(p => p._id === photoId); + if (idx !== -1) { + photos[idx].caption = updatedPhoto.caption; + photos[idx].tags = canonicalTags; + } + if (closeAfter) { + closeEditModal(); + renderManageGallery(); + fetchTagMeta(); + } } else { alert('Failed to save changes.'); } @@ -667,24 +733,98 @@ document.addEventListener('DOMContentLoaded', () => { function updateQuickTags() { if (!quickTagButtons) return; - const presetButtons = (tagMeta.presets || []).map(preset => ``); const mainButtons = [...(tagMeta.main || [])] .sort(sortTagsByCount) - .map(tag => ``); + .map(tag => ``); const otherButtons = [...(tagMeta.other || [])] .sort(sortTagsByCount) - .map(tag => ``); - quickTagButtons.innerHTML = [...presetButtons, ...mainButtons, ...otherButtons].join(''); + .map(tag => ``); + quickTagButtons.innerHTML = [...mainButtons, ...otherButtons].join(''); + renderPresetButtons(); } - function addTagToInput(tag) { + function renderPresetButtons() { + if (!presetButtons) return; + presetButtons.innerHTML = (tagMeta.presets || []) + .map(p => ``) + .join(''); + } + + function renderEditPresetButtons() { + if (!editPresetButtons) return; + editPresetButtons.innerHTML = (tagMeta.presets || []) + .map(p => ``) + .join(''); + } + + function renderPresetManagementList() { + if (!presetList) return; + const presets = tagMeta.presets || []; + if (!presets.length) { + presetList.innerHTML = '

No presets yet.

'; + return; + } + presetList.innerHTML = presets.map((p, i) => ` +
+ ${p.name} + ${(p.tags||[]).join(', ')} + + +
`).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); - } - tagsInput.value = canonicalToDisplayString(existing); + const existing = normalizeTagsInput(target.value); + if (!existing.includes(canonical)) existing.push(canonical); + target.value = canonicalToDisplayString(existing); + } + + function applyPresetToInput(tagsStr, inputEl) { + tagsStr.split(',').forEach(t => addTagToInput(t.trim(), inputEl)); } function preloadLastTags() { @@ -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() { diff --git a/main-site/admin/index.html b/main-site/admin/index.html index 34495e9..297e45f 100644 --- a/main-site/admin/index.html +++ b/main-site/admin/index.html @@ -108,6 +108,34 @@
+
+
+ + +
+
+ +
-
-
- - +
+
+
+ + +
+
+ +
diff --git a/main-site/photo-gallery-app/backend/data/presets.json b/main-site/photo-gallery-app/backend/data/presets.json new file mode 100644 index 0000000..14310b4 --- /dev/null +++ b/main-site/photo-gallery-app/backend/data/presets.json @@ -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"] } +] diff --git a/main-site/photo-gallery-app/backend/lib/tagConfig.js b/main-site/photo-gallery-app/backend/lib/tagConfig.js index 0edd6c2..c6bc72b 100644 --- a/main-site/photo-gallery-app/backend/lib/tagConfig.js +++ b/main-site/photo-gallery-app/backend/lib/tagConfig.js @@ -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; diff --git a/main-site/photo-gallery-app/backend/routes/photos.js b/main-site/photo-gallery-app/backend/routes/photos.js index a152efd..6dc724e 100644 --- a/main-site/photo-gallery-app/backend/routes/photos.js +++ b/main-site/photo-gallery-app/backend/routes/photos.js @@ -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