// /app/static/js/showcase.js // JS-managed masonry with column distribution for infinite scroll (function() { const container = document.getElementById('showcaseGrid'); if (!container) return; // Configuration const DESKTOP_COLUMNS = 4; const MOBILE_COLUMNS = 2; const MOBILE_BREAKPOINT = 768; const SCROLL_THRESHOLD = 1500; // Load more when 1500px from bottom (about 2-3 rows ahead) const LOAD_BATCH_SIZE = 4; // State let columns = []; let columnHeights = []; let isLoading = false; let currentColumnCount = 0; // Initialize function init() { setupColumns(); loadInitialImages(); setupEventListeners(); } function getColumnCount() { return window.innerWidth < MOBILE_BREAKPOINT ? MOBILE_COLUMNS : DESKTOP_COLUMNS; } function setupColumns() { const count = getColumnCount(); if (count === currentColumnCount && columns.length > 0) return; currentColumnCount = count; container.innerHTML = ''; columns = []; columnHeights = []; for (let i = 0; i < count; i++) { const col = document.createElement('div'); col.className = 'masonry-column'; col.dataset.column = i; container.appendChild(col); columns.push(col); columnHeights.push(0); } } function loadInitialImages() { const dataScript = document.getElementById('initialImages'); if (!dataScript) return; try { const images = JSON.parse(dataScript.textContent); distributeImages(images, true); // Use lazy loading for initial images (some may be below fold) } catch (e) { console.error('Failed to parse initial images:', e); } } function setupEventListeners() { // Keyboard shuffle document.addEventListener('keydown', (e) => { const modal = document.getElementById('imageModal'); if (modal && modal.classList.contains('active')) return; if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.key === 'r' || e.key === 'R' || e.key === ' ') { e.preventDefault(); shuffle(); } }); // Infinite scroll let scrollTimeout = null; window.addEventListener('scroll', () => { if (scrollTimeout) return; scrollTimeout = setTimeout(() => { scrollTimeout = null; checkScrollPosition(); }, 100); }); // Window resize - redistribute if column count changes let resizeTimeout = null; window.addEventListener('resize', () => { if (resizeTimeout) clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { const newCount = getColumnCount(); if (newCount !== currentColumnCount) { redistributeAllImages(); } }, 200); }); } function checkScrollPosition() { // For masonry layouts, we need to check if the SHORTEST column is near the viewport bottom, // not the overall page bottom. Otherwise, a tall image in one column prevents loading // even when other columns have empty space visible. // First, update accurate column heights updateColumnHeights(); // Find the shortest column's bottom position relative to viewport const shortestColumnIndex = getShortestColumnIndex(); const shortestColumn = columns[shortestColumnIndex]; if (!shortestColumn) return; const columnRect = shortestColumn.getBoundingClientRect(); const columnBottom = columnRect.bottom; // Distance from viewport top to column bottom const viewportHeight = window.innerHeight; // If the shortest column's bottom is within SCROLL_THRESHOLD of the viewport bottom, load more // columnBottom - viewportHeight = how far below the viewport the column extends // If this is negative or small, the column end is visible or nearly visible const distanceFromViewportBottom = columnBottom - viewportHeight; if (distanceFromViewportBottom < SCROLL_THRESHOLD) { loadMore(); } } function getShortestColumnIndex() { let minHeight = Infinity; let minIndex = 0; for (let i = 0; i < columnHeights.length; i++) { if (columnHeights[i] < minHeight) { minHeight = columnHeights[i]; minIndex = i; } } return minIndex; } function createImageElement(img, useLazyLoading = false) { const item = document.createElement('div'); item.className = 'masonry-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; const imgEl = document.createElement('img'); imgEl.src = img.thumb_url; imgEl.alt = img.filename; if (useLazyLoading) { imgEl.loading = 'lazy'; } item.appendChild(imgEl); // Filter out archive tags from display (they're still visible in modal) const visibleTags = (img.tags || []).filter(t => t.kind !== 'archive'); if (visibleTags.length > 0) { const overlay = document.createElement('div'); overlay.className = 'tag-overlay'; visibleTags.forEach(t => { const chip = document.createElement('a'); chip.className = 'tag-chip'; chip.href = `/gallery?tag=${encodeURIComponent(t.name)}`; chip.title = `Filter by ${t.name}`; chip.onclick = (e) => e.stopPropagation(); chip.innerHTML = formatTag(t); overlay.appendChild(chip); }); item.appendChild(overlay); } if (img.is_video) { const play = document.createElement('div'); play.className = 'play-overlay'; play.textContent = '▶'; item.appendChild(play); } return item; } function distributeImages(images, useLazyLoading = false) { images.forEach(img => { const colIndex = getShortestColumnIndex(); const item = createImageElement(img, useLazyLoading); columns[colIndex].appendChild(item); // Update height estimate (will be corrected after image loads) columnHeights[colIndex] += 300; // Correct height after image loads const imgEl = item.querySelector('img'); imgEl.onload = () => { updateColumnHeights(); }; }); document.dispatchEvent(new CustomEvent('showcaseUpdated')); } function updateColumnHeights() { columns.forEach((col, i) => { columnHeights[i] = col.offsetHeight; }); } function redistributeAllImages() { // Collect all current items const allItems = []; columns.forEach(col => { Array.from(col.children).forEach(item => { allItems.push(item); }); }); // Reset columns setupColumns(); // Re-add items maintaining order allItems.forEach(element => { const colIndex = getShortestColumnIndex(); columns[colIndex].appendChild(element); columnHeights[colIndex] += element.offsetHeight || 300; }); document.dispatchEvent(new CustomEvent('showcaseUpdated')); } async function shuffle() { if (isLoading) return; isLoading = true; container.classList.add('loading'); try { const response = await fetch(`/api/random-images?count=20`); if (!response.ok) throw new Error('Failed to fetch'); const data = await response.json(); // Clear everything columns.forEach(col => col.innerHTML = ''); columnHeights = columnHeights.map(() => 0); distributeImages(data.images); // Scroll to top window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (err) { console.error('Shuffle error:', err); } finally { container.classList.remove('loading'); isLoading = false; } } async function loadMore() { if (isLoading) return; isLoading = true; try { const response = await fetch(`/api/random-images?count=${LOAD_BATCH_SIZE}`); if (!response.ok) throw new Error('Failed to fetch'); const data = await response.json(); if (data.images.length === 0) { return; } distributeImages(data.images); } catch (err) { console.error('Load more error:', err); } finally { isLoading = false; } } function formatTag(tag) { const escape = (s) => { const div = document.createElement('div'); div.textContent = s; return div.innerHTML; }; const icons = { artist: '🎨', archive: '🗜️', character: '👤', series: '📺', rating: '⚠️', source: '🌐', post: '📌' }; const icon = icons[tag.kind]; if (icon) { const label = tag.name.includes(':') ? tag.name.split(':', 2)[1] : tag.name; return `${icon} ${escape(label)}`; } else { return `#${escape(tag.name)}`; } } // Start init(); })();