moving styling and views to be more consistent and for gallery view to be less cumbersome.

This commit is contained in:
Bryan Van Deusen
2026-01-19 23:41:36 -05:00
parent 41037696bf
commit 96f72718bd
16 changed files with 2447 additions and 75 deletions
+475
View File
@@ -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();
}
})();
+23 -2
View File
@@ -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();
+21 -3
View File
@@ -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();
}
}
+278 -3
View File
@@ -378,14 +378,25 @@ header {
display: flex;
align-items: center;
justify-content: center;
max-width: 100%;
width: 100%;
max-width: calc(100% - 300px); /* Account for tag editor */
height: calc(100vh - 140px);
max-height: calc(100vh - 140px);
overflow: hidden;
cursor: default;
}
.modal-image-wrapper.zoomed { overflow: auto; cursor: grab; }
.modal-image-wrapper.zoomed {
overflow: auto;
cursor: grab;
/* Enable scrolling by removing flex centering when zoomed */
display: block;
}
.modal-image-wrapper.zoomed img {
width: auto; height: auto; max-width: none; max-height: none;
width: auto;
height: auto;
max-width: none;
max-height: none;
cursor: grab;
}
.modal-image-wrapper img {
max-width: 100%;
@@ -404,6 +415,15 @@ header {
width: 90vw; height: auto; max-height: none;
}
/* On smaller screens, full width for image wrapper */
@media (max-width: 900px) {
.modal-image-wrapper {
max-width: 100%;
height: auto;
max-height: 60vh;
}
}
/* Modal navigation buttons - cleaner pill style */
.modal-button {
position: absolute;
@@ -1191,3 +1211,258 @@ select.form-input optgroup {
overflow-wrap: anywhere; /* allows breaking long tokens */
word-break: break-word;
}
/*------------------------------------------------------------------------------
Gallery Infinite Scroll Layout
------------------------------------------------------------------------------*/
.gallery-infinite-container {
display: flex;
min-height: 100vh;
max-width: 1800px;
margin: 0 auto;
position: relative;
}
/* Timeline Sidebar - positioned relative to gallery container */
.timeline-sidebar {
position: sticky;
top: 60px;
height: calc(100vh - 60px);
width: 70px;
min-width: 70px;
background: rgba(0,0,0,0.3);
border-right: 1px solid rgba(255,255,255,0.08);
z-index: 50;
display: flex;
flex-direction: column;
padding: 0.5rem 0;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.2) transparent;
}
.timeline-sidebar::-webkit-scrollbar {
width: 4px;
}
.timeline-sidebar::-webkit-scrollbar-track {
background: transparent;
}
.timeline-sidebar::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.2);
border-radius: 2px;
}
.timeline-track {
display: flex;
flex-direction: column;
padding: 0 6px;
}
.timeline-loading {
font-size: 0.7rem;
color: var(--text-muted);
padding: 0.5rem;
text-align: center;
}
.timeline-year-group {
margin-bottom: 0.5rem;
}
.timeline-year {
font-size: 0.8rem;
font-weight: 600;
color: var(--text);
padding: 0.5rem 0.25rem 0.25rem;
text-align: center;
position: sticky;
top: 0;
background: linear-gradient(to bottom, rgba(10,10,10,0.95), rgba(10,10,10,0.8));
z-index: 1;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.timeline-months {
display: flex;
flex-direction: column;
gap: 2px;
padding-top: 4px;
}
.timeline-month {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 0.5rem;
font-size: 0.7rem;
color: var(--text-muted);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
}
.timeline-month:hover {
background: rgba(255,255,255,0.12);
color: var(--text);
}
.timeline-month.active {
background: var(--btn-primary);
color: white;
}
.timeline-month.loaded {
color: var(--text-dim);
}
.timeline-month-label {
font-weight: 500;
}
.timeline-month-count {
font-size: 0.6rem;
opacity: 0.7;
}
/* Main gallery area - flex child next to timeline */
.gallery-main {
flex: 1;
padding: 0 0.5rem;
min-width: 0;
}
.gallery-header {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem 0 1rem;
gap: 0.5rem;
}
.gallery-header h1 {
margin: 0;
font-size: 1.5rem;
}
.active-filter {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(255,255,255,0.08);
border-radius: 999px;
font-size: 0.9rem;
color: var(--text-dim);
}
.filter-tag {
color: var(--link);
font-weight: 500;
}
.clear-filter {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(255,255,255,0.1);
color: var(--text-muted);
text-decoration: none;
font-size: 0.9rem;
line-height: 1;
transition: all 0.15s ease;
}
.clear-filter:hover {
background: rgba(255,255,255,0.2);
color: var(--text);
}
/* Gallery content area */
.gallery-infinite {
min-height: 50vh;
}
.gallery-empty {
text-align: center;
padding: 4rem 1rem;
color: var(--text-muted);
}
/* Date sections */
.date-section {
margin-bottom: 1.5rem;
}
.date-divider {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 0.5rem;
margin: 0 0 0.5rem 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
position: sticky;
top: 50px;
background: linear-gradient(to bottom, var(--bg) 0%, var(--bg) 85%, transparent 100%);
z-index: 10;
}
.date-divider-text {
font-size: 1.1rem;
font-weight: 500;
color: var(--text);
}
.date-divider-count {
font-size: 0.8rem;
color: var(--text-muted);
background: rgba(255,255,255,0.08);
padding: 0.2rem 0.6rem;
border-radius: 999px;
}
/* Loading indicator */
.gallery-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
color: var(--text-muted);
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid rgba(255,255,255,0.2);
border-top-color: var(--btn-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.gallery-end {
text-align: center;
padding: 2rem;
color: var(--text-muted);
font-style: italic;
border-top: 1px solid rgba(255,255,255,0.05);
}
/* Play overlay with triangle icon */
.play-overlay::before {
content: '';
width: 0;
height: 0;
border-style: solid;
border-width: 20px 0 20px 35px;
border-color: transparent transparent transparent rgba(255,255,255,0.9);
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));
}
/* Mobile: hide timeline sidebar */
@media (max-width: 768px) {
.timeline-sidebar {
display: none;
}
.date-divider {
top: 45px;
}
}