476 lines
14 KiB
JavaScript
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();
|
|
}
|
|
})();
|