diff --git a/gallery/gallery.js b/gallery/gallery.js
index e20452b..63c6987 100644
--- a/gallery/gallery.js
+++ b/gallery/gallery.js
@@ -42,7 +42,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
];
let photos = [];
- let tagMeta = { labels: {}, tags: [] };
+ let tagMeta = { labels: {}, tags: [], aliases: {} };
const tagLabel = (slug) => {
if (!slug) return '';
if (tagMeta.labels && tagMeta.labels[slug]) return tagMeta.labels[slug];
@@ -56,6 +56,26 @@ document.addEventListener('DOMContentLoaded', () => {
}
return [];
};
+ const slugifyTag = (value) => String(value || '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+ const resolveTagSlug = (value) => {
+ const raw = String(value || '').trim();
+ if (!raw) return '';
+ const lowerRaw = raw.toLowerCase();
+ const aliases = tagMeta.aliases || {};
+ if (aliases[lowerRaw]) {
+ return aliases[lowerRaw];
+ }
+ const labels = tagMeta.labels || {};
+ for (const [slug, label] of Object.entries(labels)) {
+ if (label && label.toLowerCase() === lowerRaw) {
+ return slug;
+ }
+ }
+ return slugifyTag(raw) || lowerRaw;
+ };
const apiBaseCandidates = (() => {
const protocol = window.location.protocol;
@@ -88,7 +108,7 @@ document.addEventListener('DOMContentLoaded', () => {
const response = await fetchWithTimeout(`${baseUrl}/photos/tags`, 3000);
if (!response.ok) return;
const data = await response.json();
- tagMeta = { labels: {}, tags: [], ...data };
+ tagMeta = { labels: {}, tags: [], aliases: {}, ...data };
} catch (err) {
// Metadata is optional; fall back to raw tag text if unavailable.
}
@@ -117,7 +137,12 @@ document.addEventListener('DOMContentLoaded', () => {
photos = fallbackPhotos;
rebuildFilterButtons();
}
- renderFlatGallery(photos);
+ const hashTag = getHashTag();
+ if (hashTag) {
+ applyTagFilter(hashTag, false);
+ } else {
+ applyTagFilter('all', false);
+ }
}
function updateResultCount(count) {
@@ -138,9 +163,7 @@ document.addEventListener('DOMContentLoaded', () => {
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
const tag = btn.dataset.tag;
- filterByTag(tag.toLowerCase());
- filterBtns.forEach(otherBtn => otherBtn.classList.remove('is-active'));
- btn.classList.add('is-active');
+ applyTagFilter(tag, true);
});
});
}
@@ -211,10 +234,11 @@ document.addEventListener('DOMContentLoaded', () => {
].filter(Boolean).join(', ')
: '';
const photoTags = normalizeTags(photo.tags);
- const readableTags = photoTags.map(tagLabel);
const photoCard = document.createElement('div');
photoCard.className = 'gallery-item';
- const tagBadges = readableTags.map(tag => `
`).join('');
+ const tagBadges = photoTags
+ .map(tag => `
`)
+ .join('');
photoCard.innerHTML = `

@@ -248,11 +272,7 @@ document.addEventListener('DOMContentLoaded', () => {
chip.addEventListener('click', (e) => {
e.stopPropagation();
const tagText = chip.dataset.tag || '';
- const slug = normalizeTags(tagText)[0] || tagText.toLowerCase();
- filterByTag(slug);
- const matchingBtn = Array.from(filterBtns).find(btn => btn.dataset.tag === slug);
- filterBtns.forEach(btn => btn.classList.remove('is-active'));
- if (matchingBtn) matchingBtn.classList.add('is-active');
+ applyTagFilter(tagText, true);
});
});
});
@@ -260,6 +280,7 @@ document.addEventListener('DOMContentLoaded', () => {
function filterPhotos() {
const searchTerm = searchInput.value.toLowerCase();
+ const normalizedSearch = resolveTagSlug(searchTerm);
// Deactivate tag buttons when searching
filterBtns.forEach(btn => btn.classList.remove('is-active'));
if (searchTerm) {
@@ -268,7 +289,9 @@ document.addEventListener('DOMContentLoaded', () => {
const captionMatch = photo.caption.toLowerCase().includes(searchTerm);
const tagMatch = photoTags.some(tag => {
const label = tagLabel(tag).toLowerCase();
- return tag.toLowerCase().includes(searchTerm) || label.includes(searchTerm);
+ return tag.toLowerCase().includes(searchTerm)
+ || label.includes(searchTerm)
+ || (normalizedSearch && tag.toLowerCase() === normalizedSearch);
});
return captionMatch || tagMatch;
});
@@ -281,17 +304,46 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
- function filterByTag(tag) {
- searchInput.value = '';
- if (tag === 'all') {
- renderFlatGallery(photos);
+ function setActiveFilterButton(tag) {
+ filterBtns.forEach(btn => btn.classList.toggle('is-active', btn.dataset.tag === tag));
+ }
+
+ function setHashTag(tag) {
+ const url = new URL(window.location.href);
+ if (!tag || tag === 'all') {
+ url.hash = '';
} else {
- const filteredPhotos = photos.filter(photo => {
- const photoTags = normalizeTags(photo.tags);
- return photoTags.some(t => t.toLowerCase() === tag);
- });
- renderFlatGallery(filteredPhotos);
+ url.hash = encodeURIComponent(tag);
}
+ history.replaceState(null, '', url);
+ }
+
+ function filterByTag(tag, updateHash = true) {
+ searchInput.value = '';
+ const slug = resolveTagSlug(tag);
+ if (!slug || slug === 'all') {
+ renderFlatGallery(photos);
+ setActiveFilterButton('all');
+ if (updateHash) setHashTag('');
+ return;
+ }
+ const filteredPhotos = photos.filter(photo => {
+ const photoTags = normalizeTags(photo.tags);
+ return photoTags.some(t => t.toLowerCase() === slug);
+ });
+ renderFlatGallery(filteredPhotos);
+ setActiveFilterButton(slug);
+ if (updateHash) setHashTag(slug);
+ }
+
+ function getHashTag() {
+ const hash = window.location.hash || '';
+ if (!hash) return '';
+ return resolveTagSlug(decodeURIComponent(hash.replace(/^#/, '')).trim());
+ }
+
+ function applyTagFilter(tag, updateHash = true) {
+ filterByTag(tag, updateHash);
}
function openModal(imageElement) {
@@ -321,11 +373,7 @@ document.addEventListener('DOMContentLoaded', () => {
chip.addEventListener('click', (e) => {
e.stopPropagation();
const tagText = chip.dataset.tag || '';
- const slug = normalizeTags(tagText)[0] || tagText.toLowerCase();
- filterByTag(slug);
- const matchingBtn = Array.from(filterBtns).find(btn => btn.dataset.tag === slug);
- filterBtns.forEach(btn => btn.classList.remove('is-active'));
- if (matchingBtn) matchingBtn.classList.add('is-active');
+ applyTagFilter(tagText, true);
closeModal();
});
});
@@ -373,5 +421,10 @@ document.addEventListener('DOMContentLoaded', () => {
}
renderSkeletonLoader();
+ window.addEventListener('hashchange', () => {
+ const tag = getHashTag() || 'all';
+ applyTagFilter(tag, false);
+ });
+
fetchPhotos();
});
diff --git a/photo-gallery-app/backend/lib/tagConfig.js b/photo-gallery-app/backend/lib/tagConfig.js
index bc4d1bd..0edd6c2 100644
--- a/photo-gallery-app/backend/lib/tagConfig.js
+++ b/photo-gallery-app/backend/lib/tagConfig.js
@@ -2,6 +2,8 @@ const MAIN_TAGS = [
{ slug: 'arch', label: 'Arch', aliases: ['arches', 'archway'] },
{ slug: 'garland', label: 'Garland', aliases: ['organic', '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'] },
@@ -11,27 +13,54 @@ const MAIN_TAGS = [
const OTHER_TAGS = [
{ slug: 'classic', label: 'Classic', aliases: [] },
{ slug: 'organic', label: 'Organic', aliases: [] },
- { slug: 'sculpture', label: 'Sculpture', aliases: ['sculpt'] },
+ { slug: 'hoop', label: 'Hoop', aliases: ['ring'] },
+ { slug: 'helium', label: 'Helium', aliases: [] },
+ { slug: 'air-filled', label: 'Air-filled', aliases: ['airfilled', 'air'] },
{ slug: 'reunion', label: 'Reunion', aliases: [] },
{ slug: 'corporate', label: 'Corporate', aliases: ['business', 'office'] },
- { slug: 'holiday', label: 'Holiday', aliases: ['christmas', 'halloween', 'easter'] },
+ { slug: 'holiday', label: 'Holiday', aliases: ['holidays'] },
+ { slug: 'christmas', label: 'Christmas', aliases: ['xmas', 'x-mas'] },
+ { slug: 'halloween', label: 'Halloween', aliases: [] },
+ { slug: 'easter', label: 'Easter', aliases: [] },
+ { slug: 'valentines', label: "Valentine's Day", aliases: ['valentine', 'valentine-day', 'valentines-day'] },
+ { slug: 'new-years', label: "New Year's", aliases: ['new-year', 'nye', 'new-years-eve', 'new-year-eve'] },
+ { slug: 'thanksgiving', label: 'Thanksgiving', aliases: ['turkey-day'] },
+ { slug: 'july-4th', label: 'July 4th', aliases: ['fourth-of-july', '4th-of-july', 'fourth', 'independence-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: '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: 'delivery', label: 'Delivery', aliases: ['deliver', 'delivered'] },
- { slug: 'pickup', label: 'Pickup', aliases: ['pick-up', 'collect'] },
{ slug: 'neon', label: 'Neon', aliases: ['led', 'light', 'lights'] },
];
const TAG_DEFINITIONS = [...MAIN_TAGS, ...OTHER_TAGS];
const TAG_PRESETS = [
- { name: 'Birthday', tags: ['birthday', 'arch', 'garland'] },
- { name: 'Baby Shower', tags: ['baby-shower', 'garland', 'gifts'] },
- { name: 'Graduation', tags: ['graduation', 'arch', 'classic'] },
- { name: 'Corporate', tags: ['corporate', 'columns', 'delivery'] },
- { name: 'Holiday', tags: ['holiday', 'garland', 'marquee'] }
+ { 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: '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'] }
];
const MAX_TAGS = 8;
+const HOLIDAY_SUBTAGS = new Set([
+ 'christmas',
+ 'halloween',
+ 'easter',
+ 'valentines',
+ 'new-years',
+ 'thanksgiving',
+ 'july-4th',
+ 'st-patricks',
+ 'mothers-day',
+ 'fathers-day'
+]);
const slugifyTag = (tag) => {
return String(tag || '')
@@ -68,6 +97,11 @@ const normalizeTags = (incomingTags = []) => {
}
});
+ if (normalized.some(tag => HOLIDAY_SUBTAGS.has(tag)) && !seen.has('holiday')) {
+ normalized.push('holiday');
+ seen.add('holiday');
+ }
+
return { normalized, rejected: [] };
};
diff --git a/photo-gallery-app/backend/routes/photos.js b/photo-gallery-app/backend/routes/photos.js
index 7ed0037..c4c4536 100644
--- a/photo-gallery-app/backend/routes/photos.js
+++ b/photo-gallery-app/backend/routes/photos.js
@@ -66,7 +66,15 @@ router.route('/').get((req, res) => {
router.route('/tags').get(async (_req, res) => {
try {
- const existing = await Photo.distinct('tags');
+ const tagCountsArray = await Photo.aggregate([
+ { $unwind: '$tags' },
+ { $group: { _id: '$tags', count: { $sum: 1 } } }
+ ]);
+ const tagCounts = tagCountsArray.reduce((acc, item) => {
+ acc[item._id] = item.count;
+ return acc;
+ }, {});
+ const existing = tagCountsArray.map(item => item._id);
res.json({
tags: TAG_DEFINITIONS,
main: MAIN_TAGS,
@@ -76,6 +84,7 @@ router.route('/tags').get(async (_req, res) => {
maxTags: MAX_TAGS,
labels: labelLookup,
existing: existing || [],
+ tagCounts,
});
} catch (err) {
console.error('Error fetching tag metadata:', err);
@@ -88,6 +97,7 @@ router.route('/tags').get(async (_req, res) => {
maxTags: MAX_TAGS,
labels: labelLookup,
existing: [],
+ tagCounts: {},
});
}
});
diff --git a/photo-gallery-app/backend/server.js b/photo-gallery-app/backend/server.js
index 12948ad..358815b 100644
--- a/photo-gallery-app/backend/server.js
+++ b/photo-gallery-app/backend/server.js
@@ -10,6 +10,8 @@ const whitelist = [
'https://beachpartyballoons.com',
'https://www.beachpartyballoons.com',
'https://photobackend.beachpartyballoons.com', // Dedicated backend hostname
+ 'http://localhost:3052',
+ 'http://127.0.0.1:3052',
'http://localhost:3050',
'http://127.0.0.1:3050',
'http://localhost:8080' // Common local dev port