// /app/static/js/gallery-infinite.js // Infinite scroll gallery with year-month date dividers and timeline navigation (function() { 'use strict'; // Configuration const CONFIG = { SCROLL_THRESHOLD: 1200, // px from bottom to trigger load BATCH_SIZE: 30, // images per API request DEBOUNCE_SCROLL: 100, // ms debounce for scroll handler DEBOUNCE_RESIZE: 200 // ms debounce for resize handler }; // State const state = { isLoading: false, hasMore: true, cursor: null, activeTag: null, loadedSections: new Set(), // year_month keys of loaded sections timelineData: [], currentVisibleSection: null, isJumping: false // true during jump navigation }; // DOM references const dom = { container: null, loading: null, end: null, timeline: null, timelineTrack: null }; // Initialize function init() { dom.container = document.getElementById('galleryInfinite'); dom.loading = document.getElementById('galleryLoading'); dom.end = document.getElementById('galleryEnd'); dom.timeline = document.getElementById('timelineSidebar'); dom.timelineTrack = document.getElementById('timelineTrack'); if (!dom.container) return; // Parse initial state from window.galleryState (set by template) if (window.galleryState) { state.cursor = window.galleryState.cursor; state.hasMore = window.galleryState.hasMore; state.activeTag = window.galleryState.activeTag; } // Track initially loaded sections parseInitialSections(); // Load timeline data loadTimeline(); // Setup event listeners setupScrollListener(); setupIntersectionObserver(); // Check if we need to load more on init (short content) setTimeout(checkScrollPosition, 100); } // Parse initially rendered sections function parseInitialSections() { const sections = dom.container.querySelectorAll('.date-section'); sections.forEach(section => { const yearMonth = section.dataset.yearMonth; if (yearMonth) { state.loadedSections.add(yearMonth); } }); } // Fetch more images async function loadMore() { if (state.isLoading || !state.hasMore) return; state.isLoading = true; dom.loading.style.display = 'flex'; try { const params = new URLSearchParams({ limit: CONFIG.BATCH_SIZE }); if (state.cursor) { params.set('cursor', state.cursor); } if (state.activeTag) { params.set('tag', state.activeTag); } const response = await fetch(`/api/gallery/scroll?${params}`); const data = await response.json(); if (data.images && data.images.length > 0) { appendImages(data.images); state.cursor = data.next_cursor; state.hasMore = data.has_more; // Notify modal system dispatchGalleryUpdate(); } if (!data.has_more) { state.hasMore = false; dom.end.style.display = 'block'; } } catch (err) { console.error('Failed to load more images:', err); } finally { state.isLoading = false; dom.loading.style.display = 'none'; } } // Append images to the gallery function appendImages(images) { // Group images by year_month const groups = new Map(); images.forEach(img => { const key = img.year_month; if (!groups.has(key)) { groups.set(key, { key: key, label: img.year_month_label, images: [] }); } groups.get(key).images.push(img); }); // Append to existing sections or create new ones groups.forEach((group, key) => { let section = dom.container.querySelector(`.date-section[data-year-month="${key}"]`); if (section) { // Append to existing section const grid = section.querySelector('.gallery-grid'); group.images.forEach(img => { grid.appendChild(createImageElement(img)); }); // Update count updateSectionCount(section); } else { // Create new section section = createDateSection(group.key, group.label, group.images); dom.container.appendChild(section); } state.loadedSections.add(key); }); // Update timeline highlights updateTimelineLoaded(); } // Create a date section element function createDateSection(yearMonth, label, images) { const section = document.createElement('section'); section.className = 'date-section'; section.dataset.yearMonth = yearMonth; const divider = document.createElement('h2'); divider.className = 'date-divider'; divider.id = `section-${yearMonth}`; divider.innerHTML = ` ${label} ${images.length} images `; const grid = document.createElement('div'); grid.className = 'gallery-grid'; images.forEach(img => { grid.appendChild(createImageElement(img)); }); section.appendChild(divider); section.appendChild(grid); return section; } // Create a single image element function createImageElement(img) { const item = document.createElement('div'); item.className = 'gallery-item img-clickable'; item.dataset.id = img.id; item.dataset.full = img.full_url; item.dataset.type = img.is_video ? 'video' : 'image'; item.dataset.filename = img.filename; // Build tag overlay HTML let tagsHtml = ''; if (img.tags && img.tags.length > 0) { const tagChips = img.tags.map(t => { const displayName = t.name.includes(':') ? t.name.split(':')[1] : t.name; const prefix = t.kind === 'user' ? '#' : ''; return `${prefix}${displayName}`; }).join(''); tagsHtml = `
${tagChips}
`; } // Play overlay for videos const playOverlay = img.is_video ? '
' : ''; item.innerHTML = ` ${tagsHtml} ${playOverlay} ${img.date} `; return item; } // Update section image count function updateSectionCount(section) { const grid = section.querySelector('.gallery-grid'); const count = grid.querySelectorAll('.gallery-item').length; const countEl = section.querySelector('.date-divider-count'); if (countEl) { countEl.textContent = `${count} images`; } } // Scroll handling function setupScrollListener() { let scrollTimeout = null; window.addEventListener('scroll', () => { if (scrollTimeout) return; scrollTimeout = setTimeout(() => { scrollTimeout = null; checkScrollPosition(); }, CONFIG.DEBOUNCE_SCROLL); }, { passive: true }); } function checkScrollPosition() { if (state.isLoading || !state.hasMore || state.isJumping) return; const scrollBottom = window.innerHeight + window.scrollY; const docHeight = document.documentElement.scrollHeight; if (docHeight - scrollBottom < CONFIG.SCROLL_THRESHOLD) { loadMore(); } } // Intersection observer for tracking visible section function setupIntersectionObserver() { const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const yearMonth = entry.target.dataset.yearMonth; if (yearMonth && yearMonth !== state.currentVisibleSection) { state.currentVisibleSection = yearMonth; updateTimelineActive(yearMonth); } } }); }, { rootMargin: '-100px 0px -60% 0px', threshold: 0 }); // Observe all date sections const observeSections = () => { dom.container.querySelectorAll('.date-section').forEach(section => { observer.observe(section); }); }; observeSections(); // Re-observe when new sections are added const mutationObserver = new MutationObserver(() => { observeSections(); }); mutationObserver.observe(dom.container, { childList: true }); } // Timeline functions async function loadTimeline() { try { const params = state.activeTag ? `?tag=${encodeURIComponent(state.activeTag)}` : ''; const response = await fetch(`/api/gallery/timeline${params}`); const data = await response.json(); if (data.groups) { state.timelineData = data.groups; renderTimeline(); } } catch (err) { console.error('Failed to load timeline:', err); dom.timelineTrack.innerHTML = '
Failed to load
'; } } function renderTimeline() { if (!dom.timelineTrack || state.timelineData.length === 0) { dom.timelineTrack.innerHTML = '
No dates
'; return; } // Group by year const yearGroups = new Map(); state.timelineData.forEach(item => { if (!yearGroups.has(item.year)) { yearGroups.set(item.year, []); } yearGroups.get(item.year).push(item); }); let html = ''; yearGroups.forEach((months, year) => { html += `
`; html += `
${year}
`; html += '
'; months.forEach(month => { const isLoaded = state.loadedSections.has(month.key); const loadedClass = isLoaded ? ' loaded' : ''; html += ` ${month.short_label} ${month.count} `; }); html += '
'; }); dom.timelineTrack.innerHTML = html; // Add click handlers for timeline navigation dom.timelineTrack.querySelectorAll('.timeline-month').forEach(el => { el.addEventListener('click', handleTimelineClick); }); // Update active state if (state.currentVisibleSection) { updateTimelineActive(state.currentVisibleSection); } } function handleTimelineClick(e) { e.preventDefault(); const yearMonth = e.currentTarget.dataset.yearMonth; if (!yearMonth) return; // Check if section is already loaded const existingSection = document.getElementById(`section-${yearMonth}`); if (existingSection) { // Scroll to existing section existingSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); } else { // Jump to this date - fetch and load jumpToDate(yearMonth); } } async function jumpToDate(yearMonth) { if (state.isJumping) return; state.isJumping = true; dom.loading.style.display = 'flex'; try { const params = new URLSearchParams({ year_month: yearMonth, limit: CONFIG.BATCH_SIZE }); if (state.activeTag) { params.set('tag', state.activeTag); } const response = await fetch(`/api/gallery/jump?${params}`); const data = await response.json(); if (data.images && data.images.length > 0) { // Clear existing content and replace with jumped-to content dom.container.innerHTML = ''; state.loadedSections.clear(); // Add images appendImages(data.images); // Update cursor for continued scrolling state.cursor = data.next_cursor; state.hasMore = data.has_more; // Reset end marker if (data.has_more) { dom.end.style.display = 'none'; } else { dom.end.style.display = 'block'; } // Scroll to top of new content window.scrollTo({ top: 0, behavior: 'instant' }); // Update timeline updateTimelineLoaded(); // Notify modal system dispatchGalleryUpdate(); } } catch (err) { console.error('Failed to jump to date:', err); } finally { state.isJumping = false; dom.loading.style.display = 'none'; } } function updateTimelineActive(yearMonth) { // Remove active from all dom.timelineTrack.querySelectorAll('.timeline-month.active').forEach(el => { el.classList.remove('active'); }); // Add active to current const activeEl = dom.timelineTrack.querySelector(`.timeline-month[data-year-month="${yearMonth}"]`); if (activeEl) { activeEl.classList.add('active'); // Scroll timeline to keep active visible const track = dom.timelineTrack; const elRect = activeEl.getBoundingClientRect(); const trackRect = track.getBoundingClientRect(); if (elRect.top < trackRect.top || elRect.bottom > trackRect.bottom) { activeEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } } function updateTimelineLoaded() { dom.timelineTrack.querySelectorAll('.timeline-month').forEach(el => { const yearMonth = el.dataset.yearMonth; if (state.loadedSections.has(yearMonth)) { el.classList.add('loaded'); } }); } // Dispatch event for modal system integration function dispatchGalleryUpdate() { document.dispatchEvent(new CustomEvent('galleryUpdated')); } // Initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();