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: [] }; 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 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: [], ...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(); } renderFlatGallery(photos); } 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; filterByTag(tag.toLowerCase()); filterBtns.forEach(otherBtn => otherBtn.classList.remove('is-active')); btn.classList.add('is-active'); }); }); } 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 readableTags = photoTags.map(tagLabel); const photoCard = document.createElement('div'); photoCard.className = 'gallery-item'; const tagBadges = readableTags.map(tag => `${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 || ''; 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'); }); }); }); } function filterPhotos() { const searchTerm = searchInput.value.toLowerCase(); // 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); }); 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 filterByTag(tag) { searchInput.value = ''; if (tag === 'all') { renderFlatGallery(photos); } else { const filteredPhotos = photos.filter(photo => { const photoTags = normalizeTags(photo.tags); return photoTags.some(t => t.toLowerCase() === tag); }); renderFlatGallery(filteredPhotos); } } 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 || ''; 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'); 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(); fetchPhotos(); });