431 lines
18 KiB
JavaScript
431 lines
18 KiB
JavaScript
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 = `<span>${countText}</span> photos shown • ${totalText} total`;
|
|
return;
|
|
}
|
|
resultCountEl.innerHTML = count === total
|
|
? `<span>${countText}</span> photos on display`
|
|
: `<span>${countText}</span> 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 = [`<div class="control"><button class="button filter-btn is-active" data-tag="all">All</button></div>`];
|
|
sorted.forEach(([slug, count]) => {
|
|
const label = `${tagLabel(slug)}${count ? ` (${count})` : ''}`;
|
|
buttons.push(`<div class="control"><button class="button filter-btn" data-tag="${slug}">${label}</button></div>`);
|
|
});
|
|
|
|
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(`<div class="filter-row">${buttons.slice(i, i + 7).join('')}</div>`);
|
|
}
|
|
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 => `<span class="tag-chip" data-tag="${tag}"><i class="fa-solid fa-wand-magic-sparkles"></i>${tagLabel(tag)}</span>`)
|
|
.join('');
|
|
photoCard.innerHTML = `
|
|
<div class="gallery-photo">
|
|
<img loading="lazy" ${srcset ? `srcset="${srcset}" sizes="(min-width: 1024px) 33vw, (min-width: 768px) 45vw, 90vw"` : ''} src="${src}" alt="${photo.caption}" data-caption="${photo.caption}" data-tags="${photoTags.join(',')}" data-full-src="${src}" decoding="async">
|
|
</div>
|
|
<div class="gallery-overlay">
|
|
<div class="overlay-bottom">
|
|
<p class="overlay-title">${photo.caption}</p>
|
|
<div class="overlay-tags" aria-label="Tags for ${photo.caption}">${tagBadges}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 => `<span class="tag-chip" data-tag="${t}"><i class="fa-solid fa-wand-magic-sparkles"></i>${tagLabel(t)}</span>`).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();
|
|
});
|