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();
|
||||
}
|
||||
}
|
||||
|
||||
+278
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user