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/gallery-infinite.js
T

476 lines
14 KiB
JavaScript

// /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 = `
<span class="date-divider-text">${label}</span>
<span class="date-divider-count">${images.length} images</span>
`;
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 `<a class="tag-chip" href="/gallery?tag=${encodeURIComponent(t.name)}" title="Filter by ${t.name}" onclick="event.stopPropagation()">${prefix}${displayName}</a>`;
}).join('');
tagsHtml = `<div class="tag-overlay">${tagChips}</div>`;
}
// Play overlay for videos
const playOverlay = img.is_video ? '<div class="play-overlay"></div>' : '';
item.innerHTML = `
<div class="gallery-thumb" style="background-image: url('${img.thumb_url}');"></div>
${tagsHtml}
${playOverlay}
<span class="image-date">${img.date}</span>
`;
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 = '<div class="timeline-loading">Failed to load</div>';
}
}
function renderTimeline() {
if (!dom.timelineTrack || state.timelineData.length === 0) {
dom.timelineTrack.innerHTML = '<div class="timeline-loading">No dates</div>';
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 += `<div class="timeline-year-group" data-year="${year}">`;
html += `<div class="timeline-year">${year}</div>`;
html += '<div class="timeline-months">';
months.forEach(month => {
const isLoaded = state.loadedSections.has(month.key);
const loadedClass = isLoaded ? ' loaded' : '';
html += `
<a class="timeline-month${loadedClass}"
data-year-month="${month.key}"
href="#section-${month.key}"
title="${month.label}: ${month.count} images">
<span class="timeline-month-label">${month.short_label}</span>
<span class="timeline-month-count">${month.count}</span>
</a>
`;
});
html += '</div></div>';
});
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();
}
})();