This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
imagerepo/app/static/js/showcase.js
T
2026-02-05 21:23:59 -05:00

278 lines
7.9 KiB
JavaScript

// /app/static/js/showcase.js
// JS-managed masonry with column distribution for infinite scroll
(function() {
'use strict';
// Use shared TagUtils module
const { formatTagHtml, createTagChipHtml } = window.TagUtils || {};
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';
// Use shared utility to create tag chips
overlay.innerHTML = visibleTags.map(t => createTagChipHtml(t)).join('');
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;
}
}
// Start
init();
})();