diff --git a/index.html b/index.html
index 113a9a1..a351652 100644
--- a/index.html
+++ b/index.html
@@ -177,6 +177,12 @@ let currentLibrary = null;
let currentFolder = null; // null = all
let currentImageId = null;
+// --- Virtual scroller state ---
+const OVERSCAN = 2; // extra rows to render above/below viewport
+let vCols = 1;
+let vRowHeight = 0;
+let vScrollRAF = null;
+
// Init: load library list
async function init() {
const res = await fetch('/api/libraries');
@@ -274,6 +280,7 @@ function filterImages() {
function renderGrid() {
const grid = document.getElementById('grid');
const empty = document.getElementById('empty');
+ const container = document.getElementById('grid-container');
const stats = document.getElementById('stats');
stats.textContent = `${filteredImages.length} items`;
@@ -286,35 +293,95 @@ function renderGrid() {
empty.style.display = 'none';
grid.style.display = 'grid';
- grid.innerHTML = '';
+ container.scrollTop = 0;
- filteredImages.forEach(img => {
- const card = document.createElement('div');
- card.className = 'card';
- card.onclick = () => openLightbox(img);
+ // Measure column count and row height from CSS grid
+ // We do one measurement pass with a dummy card first
+ function measureGrid() {
+ const thumbSize = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--thumb-size')) || 180;
+ const gridWidth = grid.clientWidth || container.clientWidth - 32;
+ vCols = Math.max(1, Math.floor((gridWidth + 12) / (thumbSize + 12)));
+ // Card height: thumb aspect 16/10 + info ~60px
+ vRowHeight = Math.round((thumbSize * 10 / 16)) + 62 + 12; // thumb-h + info + gap
+ }
+ measureGrid();
- const thumbSrc = img._thumbnail
- ? `/api/thumbnail/${currentLibrary}/${img.id}`
- : null;
+ // Detach scroll listener before wiping grid
+ container.onscroll = null;
- const extIcon = extToIcon(img.ext);
+ function renderVisible() {
+ measureGrid();
+ const totalRows = Math.ceil(filteredImages.length / vCols);
+ const totalHeight = totalRows * vRowHeight;
- card.innerHTML = `
-
- ${thumbSrc
- ? `

`
- : ''}
-
${extIcon}
-
${img.ext || '?'}
-
-
-
${escHtml(img.name)}
-
${img.ext?.toUpperCase() || ''} ${img.duration ? '· ' + fmtDuration(img.duration) : ''}
- ${img.tags?.length ? `
${img.tags.map(t => `${escHtml(t)}`).join('')}
` : ''}
-
- `;
- grid.appendChild(card);
- });
+ const scrollTop = container.scrollTop;
+ const viewHeight = container.clientHeight;
+
+ const firstVisRow = Math.max(0, Math.floor(scrollTop / vRowHeight) - OVERSCAN);
+ const lastVisRow = Math.min(totalRows - 1, Math.ceil((scrollTop + viewHeight) / vRowHeight) + OVERSCAN);
+
+ const firstIdx = firstVisRow * vCols;
+ const lastIdx = Math.min(filteredImages.length - 1, (lastVisRow + 1) * vCols - 1);
+
+ // Spacer above
+ const topH = firstVisRow * vRowHeight;
+ // Spacer below
+ const bottomH = Math.max(0, (totalRows - lastVisRow - 1) * vRowHeight);
+
+ grid.innerHTML = '';
+
+ // Top spacer
+ if (topH > 0) {
+ const spacer = document.createElement('div');
+ spacer.style.cssText = `grid-column: 1 / -1; height: ${topH}px; pointer-events: none;`;
+ grid.appendChild(spacer);
+ }
+
+ // Render only visible cards
+ for (let i = firstIdx; i <= lastIdx; i++) {
+ const img = filteredImages[i];
+ const card = document.createElement('div');
+ card.className = 'card';
+ card.onclick = () => openLightbox(img);
+
+ const thumbSrc = img._thumbnail ? `/api/thumbnail/${currentLibrary}/${img.id}` : null;
+ const extIcon = extToIcon(img.ext);
+
+ card.innerHTML = `
+
+ ${thumbSrc
+ ? `

`
+ : ''}
+
${extIcon}
+
${img.ext || '?'}
+
+
+
${escHtml(img.name)}
+
${img.ext?.toUpperCase() || ''} ${img.duration ? '· ' + fmtDuration(img.duration) : ''}
+ ${img.tags?.length ? `
${img.tags.map(t => `${escHtml(t)}`).join('')}
` : ''}
+
+ `;
+ grid.appendChild(card);
+ }
+
+ // Bottom spacer
+ if (bottomH > 0) {
+ const spacer = document.createElement('div');
+ spacer.style.cssText = `grid-column: 1 / -1; height: ${bottomH}px; pointer-events: none;`;
+ grid.appendChild(spacer);
+ }
+ }
+
+ renderVisible();
+
+ // Re-render on scroll (throttled via rAF)
+ container.onscroll = () => {
+ if (vScrollRAF) return;
+ vScrollRAF = requestAnimationFrame(() => {
+ vScrollRAF = null;
+ renderVisible();
+ });
+ };
}
function openLightbox(img) {
@@ -359,6 +426,7 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbo
function updateThumbSize(val) {
document.documentElement.style.setProperty('--thumb-size', val + 'px');
+ if (filteredImages.length > 0) renderGrid();
}
function extToIcon(ext) {