278 lines
7.9 KiB
JavaScript
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();
|
|
})();
|