moving styling and views to be more consistent and for gallery view to be less cumbersome.
This commit is contained in:
@@ -0,0 +1,475 @@
|
||||
// /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();
|
||||
}
|
||||
})();
|
||||
@@ -28,11 +28,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
let dragStartY = 0;
|
||||
let dragMoved = false;
|
||||
|
||||
// Legacy pagination inputs (for non-infinite-scroll pages)
|
||||
const hasNextPageInput = document.getElementById('hasNextPage');
|
||||
const nextPageUrlInput = document.getElementById('nextPageUrl');
|
||||
const hasPrevPageInput = document.getElementById('hasPrevPage');
|
||||
const prevPageUrlInput = document.getElementById('prevPageUrl');
|
||||
|
||||
// Check if we're in infinite scroll mode
|
||||
// Infinite scroll mode is when galleryState exists (set by gallery.html template)
|
||||
const isInfiniteScrollMode = typeof window.galleryState !== 'undefined';
|
||||
|
||||
// ---------------------------
|
||||
// Tag editor helpers
|
||||
// ---------------------------
|
||||
@@ -158,9 +163,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
async function fetchAutocomplete(query) {
|
||||
try {
|
||||
// Exclude system-managed tag kinds from autocomplete (archive, post, source)
|
||||
const excludeKinds = 'archive,post,source';
|
||||
const url = query
|
||||
? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8&exclude_kind=archive`
|
||||
: `/api/tags/search?limit=8&exclude_kind=archive`;
|
||||
? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8&exclude_kind=${excludeKinds}`
|
||||
: `/api/tags/search?limit=8&exclude_kind=${excludeKinds}`;
|
||||
const r = await fetch(url);
|
||||
const j = await r.json();
|
||||
renderAutocomplete(j.tags || []);
|
||||
@@ -316,7 +323,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
function showNext() {
|
||||
if (currentIndex < images.length - 1) {
|
||||
updateImage(currentIndex + 1);
|
||||
} else if (isInfiniteScrollMode) {
|
||||
// In infinite scroll mode, just stay at the last image
|
||||
// User needs to scroll the gallery to load more
|
||||
return;
|
||||
} else if (hasNextPageInput?.value === "true") {
|
||||
// Legacy pagination mode - fetch next page
|
||||
fetch(nextPageUrlInput.value)
|
||||
.then(res => res.text())
|
||||
.then(html => {
|
||||
@@ -349,7 +361,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
function showPrev() {
|
||||
if (currentIndex > 0) {
|
||||
updateImage(currentIndex - 1);
|
||||
} else if (isInfiniteScrollMode) {
|
||||
// In infinite scroll mode, just stay at the first image
|
||||
return;
|
||||
} else if (hasPrevPageInput?.value === "true") {
|
||||
// Legacy pagination mode - fetch previous page
|
||||
fetch(prevPageUrlInput.value)
|
||||
.then(res => res.text())
|
||||
.then(html => {
|
||||
@@ -480,6 +496,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
images = Array.from(document.querySelectorAll('.img-clickable'));
|
||||
});
|
||||
|
||||
// Listen for gallery infinite scroll updates to refresh the images array
|
||||
document.addEventListener('galleryUpdated', () => {
|
||||
images = Array.from(document.querySelectorAll('.img-clickable'));
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
if (modal.classList.contains('active')) {
|
||||
closeModal();
|
||||
|
||||
@@ -97,10 +97,28 @@
|
||||
}
|
||||
|
||||
function checkScrollPosition() {
|
||||
const scrollBottom = window.innerHeight + window.scrollY;
|
||||
const docHeight = document.documentElement.scrollHeight;
|
||||
// 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.
|
||||
|
||||
if (docHeight - scrollBottom < SCROLL_THRESHOLD) {
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user