document.addEventListener('DOMContentLoaded', () => { const gallery = document.getElementById('photo-gallery'); const searchInput = document.getElementById('searchInput'); const filterRows = document.querySelector('.filter-rows'); let filterBtns = Array.from(document.querySelectorAll('.filter-btn')); const modal = document.getElementById('image-modal'); const modalImg = document.getElementById('modal-image-src'); const modalCaption = document.getElementById('modal-caption'); const modalCaptionTags = document.getElementById('modal-caption-tags'); const modalCloseBtn = modal.querySelector('.modal-close-btn') || modal.querySelector('.delete'); const modalBackground = modal.querySelector('.modal-background'); const resultCountEl = document.getElementById('result-count'); const noResults = document.getElementById('no-results'); const isTouchDevice = 'ontouchstart' in window || (navigator.maxTouchPoints || 0) > 0; const topButton = document.getElementById('top'); const fallbackPhotos = [ { path: '../assets/pics/gallery/classic/20230617_131551.webp', caption: "20' Classic Arch", tags: ['arch', 'classic', 'outdoor', 'wedding'] }, { path: '../assets/pics/gallery/classic/_Photos_20241207_083534.webp', caption: 'Classic Columns', tags: ['columns', 'classic', 'indoor', 'corporate', 'black-tie'] }, { path: '../assets/pics/gallery/centerpiece/20230108_112718.jpg}.webp', caption: 'Cocktail Arrangements', tags: ['centerpiece', 'cocktail', 'tablescape', 'baby-shower'] }, { path: '../assets/pics/gallery/organic/20241121_200047~2.jpg', caption: 'Organic Garland', tags: ['organic', 'garland', 'outdoor', 'birthday'] }, { path: '../assets/pics/gallery/organic/20250202_133930~2.jpg', caption: 'Organic Garland (Pastel)', tags: ['organic', 'garland', 'pastel'] } ]; let photos = []; let tagMeta = { labels: {}, tags: [], aliases: {} }; const tagLabel = (slug) => { if (!slug) return ''; if (tagMeta.labels && tagMeta.labels[slug]) return tagMeta.labels[slug]; return slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); }; const normalizeTags = (tags) => { if (Array.isArray(tags)) return tags; if (typeof tags === 'string') { return tags.split(',').map(tag => tag.trim()).filter(Boolean); } 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; const host = window.location.hostname; const hints = [ window.GALLERY_API_URL || '', 'https://photobackend.beachpartyballoons.com', `${protocol}//${host}:5000`, `${protocol}//${host}:5001`, ]; // Remove duplicates/empties return [...new Set(hints.filter(Boolean))]; })(); let activeApiBase = ''; const fetchWithTimeout = async (url, timeoutMs = 4000) => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { signal: controller.signal }); } finally { clearTimeout(timer); } }; async function fetchTagMetadata(baseUrl) { if (!baseUrl) return; try { const response = await fetchWithTimeout(`${baseUrl}/photos/tags`, 3000); if (!response.ok) return; const data = await response.json(); tagMeta = { labels: {}, tags: [], aliases: {}, ...data }; } catch (err) { // Metadata is optional; fall back to raw tag text if unavailable. } } async function fetchPhotos() { try { let data = null; for (const base of apiBaseCandidates) { try { const response = await fetchWithTimeout(`${base}/photos`, 3500); if (!response.ok) continue; data = await response.json(); activeApiBase = base; await fetchTagMetadata(activeApiBase); break; } catch (err) { // Try the next candidate quickly; don't block the UI. continue; } } photos = Array.isArray(data) && data.length ? data : fallbackPhotos; rebuildFilterButtons(); } catch (error) { console.error('Error fetching photos:', error); photos = fallbackPhotos; rebuildFilterButtons(); } const hashTag = getHashTag(); if (hashTag) { applyTagFilter(hashTag, false); } else { applyTagFilter('all', false); } } function updateResultCount(count) { if (!resultCountEl) return; const total = photos.length; const totalText = total ? `${total}` : '0'; const countText = count !== undefined ? `${count}` : totalText; if (count === 0) { resultCountEl.innerHTML = `${countText} photos shown • ${totalText} total`; return; } resultCountEl.innerHTML = count === total ? `${countText} photos on display` : `${countText} shown • ${totalText} total`; } function attachFilterListeners() { filterBtns.forEach(btn => { btn.addEventListener('click', () => { const tag = btn.dataset.tag; applyTagFilter(tag, true); }); }); } function rebuildFilterButtons() { if (!filterRows) return; const tagCounts = {}; photos.forEach(photo => { normalizeTags(photo.tags).forEach(tag => { tagCounts[tag] = (tagCounts[tag] || 0) + 1; }); }); const sorted = Object.entries(tagCounts) .filter(([, count]) => count > 1) .sort((a, b) => b[1] - a[1] || tagLabel(a[0]).localeCompare(tagLabel(b[0]))); const buttons = [`
`]; sorted.forEach(([slug, count]) => { const label = `${tagLabel(slug)}${count ? ` (${count})` : ''}`; buttons.push(`
`); }); const rows = []; const maxPerRow = 7; const maxRows = 2; const maxButtons = maxPerRow * maxRows; const limitedButtons = buttons.slice(0, maxButtons); for (let i = 0; i < limitedButtons.length; i += maxPerRow) { rows.push(`
${buttons.slice(i, i + 7).join('')}
`); } filterRows.innerHTML = rows.join(''); filterBtns = Array.from(filterRows.querySelectorAll('.filter-btn')); attachFilterListeners(); } function renderFlatGallery(photoArray) { gallery.innerHTML = ''; // Clear skeleton or old photos updateResultCount(photoArray.length); if (photoArray.length === 0) { if (noResults) { gallery.appendChild(noResults); noResults.style.display = 'block'; } return; } if (noResults) { noResults.style.display = 'none'; } photoArray.forEach((photo, idx) => { const resolveUrl = (p) => { if (typeof p !== 'string') return ''; if (p.startsWith('http') || p.startsWith('assets') || p.startsWith('/assets') || p.startsWith('../assets')) return p; const base = activeApiBase || 'https://photobackend.beachpartyballoons.com' || `${window.location.protocol}//${window.location.hostname}:5000`; const path = p.startsWith('/') ? p.slice(1) : p; return `${base.replace(/\/$/, '')}/${path}`; }; const src = resolveUrl(photo.path); const srcset = photo.variants ? [ photo.variants.thumb ? `${resolveUrl(photo.variants.thumb)} 640w` : null, photo.variants.medium ? `${resolveUrl(photo.variants.medium)} 1200w` : null, src ? `${src} 2000w` : null ].filter(Boolean).join(', ') : ''; const photoTags = normalizeTags(photo.tags); const photoCard = document.createElement('div'); photoCard.className = 'gallery-item'; const tagBadges = photoTags .map(tag => `${tagLabel(tag)}`) .join(''); photoCard.innerHTML = ` `; gallery.appendChild(photoCard); setTimeout(() => requestAnimationFrame(() => photoCard.classList.add('is-visible')), idx * 70); const imgEl = photoCard.querySelector('img'); imgEl.addEventListener('click', (e) => { const card = e.currentTarget.closest('.gallery-item'); if (isTouchDevice && card) { if (!card.classList.contains('touch-active')) { card.classList.add('touch-active'); setTimeout(() => card.classList.remove('touch-active'), 2200); return; } } openModal(e.target); if (card) card.classList.remove('touch-active'); }); const tagChips = photoCard.querySelectorAll('.tag-chip'); tagChips.forEach(chip => { chip.addEventListener('click', (e) => { e.stopPropagation(); const tagText = chip.dataset.tag || ''; applyTagFilter(tagText, true); }); }); }); } 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) { const filteredPhotos = photos.filter(photo => { const photoTags = normalizeTags(photo.tags); 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) || (normalizedSearch && tag.toLowerCase() === normalizedSearch); }); return captionMatch || tagMatch; }); renderFlatGallery(filteredPhotos); } else { renderFlatGallery(photos); // Reactivate 'All' button if search is cleared const allBtn = document.querySelector('.filter-btn[data-tag="all"]'); if (allBtn) allBtn.classList.add('is-active'); } } 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 { 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) { const rect = imageElement.getBoundingClientRect(); const centerX = window.innerWidth / 2; const centerY = window.innerHeight / 2; const imgCenterX = rect.left + rect.width / 2; const imgCenterY = rect.top + rect.height / 2; const translateX = imgCenterX - centerX; const translateY = imgCenterY - centerY; const scaleStartRaw = Math.max( rect.width / (window.innerWidth * 0.8), rect.height / (window.innerHeight * 0.8), 0.55 ); const scaleStart = Math.min(Math.max(scaleStartRaw, 0.72), 0.96); document.documentElement.style.setProperty('--modal-img-translate-x', `${translateX}px`); document.documentElement.style.setProperty('--modal-img-translate-y', `${translateY}px`); document.documentElement.style.setProperty('--modal-img-scale', scaleStart.toFixed(3)); modalImg.src = imageElement.dataset.fullSrc || imageElement.src; modalCaption.textContent = imageElement.dataset.caption; if (modalCaptionTags) { const tags = (imageElement.dataset.tags || '').split(',').filter(Boolean); modalCaptionTags.innerHTML = tags.map(t => `${tagLabel(t)}`).join(''); const chips = modalCaptionTags.querySelectorAll('.tag-chip'); chips.forEach(chip => { chip.addEventListener('click', (e) => { e.stopPropagation(); const tagText = chip.dataset.tag || ''; applyTagFilter(tagText, true); closeModal(); }); }); } modal.classList.remove('show-bg'); modal.classList.add('chrome-hidden'); modal.classList.add('is-active'); document.documentElement.classList.add('is-clipped'); document.body.classList.add('modal-open'); if (topButton) topButton.style.display = 'none'; // Fade in chrome and background immediately after paint requestAnimationFrame(() => { modal.classList.add('show-bg'); modal.classList.remove('chrome-hidden'); }); } function closeModal() { modal.classList.remove('is-active'); modal.classList.remove('show-bg'); modal.classList.remove('chrome-hidden'); document.documentElement.classList.remove('is-clipped'); document.body.classList.remove('modal-open'); document.documentElement.style.removeProperty('--modal-img-translate-x'); document.documentElement.style.removeProperty('--modal-img-translate-y'); document.documentElement.style.removeProperty('--modal-img-scale'); if (topButton) { const shouldShow = document.body.scrollTop > 130 || document.documentElement.scrollTop > 130; topButton.style.display = shouldShow ? 'block' : 'none'; } } searchInput.addEventListener('keyup', filterPhotos); if (modalCloseBtn) modalCloseBtn.addEventListener('click', closeModal); modalBackground.addEventListener('click', closeModal); function renderSkeletonLoader() { gallery.innerHTML = ''; // Clear gallery for (let i = 0; i < 8; i++) { const skeletonItem = document.createElement('div'); skeletonItem.className = 'skeleton-item'; gallery.appendChild(skeletonItem); } } renderSkeletonLoader(); window.addEventListener('hashchange', () => { const tag = getHashTag() || 'all'; applyTagFilter(tag, false); }); fetchPhotos(); });