Add an infinite/page toggle with a pager

This commit is contained in:
Fisto
2026-09-18 16:46:25 +02:00
parent 94ddfdeb31
commit 13ffad34d4
4 changed files with 176 additions and 5 deletions
+6
View File
@@ -40,6 +40,12 @@ mobile draft at 390px.
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB - [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`: cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200). `previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
- [x] C57 Page mode. The media grid has an Infinite/Page flippy toggle in the bar. Page mode
renders one `pageSize` slice via `offset = (page-1)*pageSize`, shows Prev / editable page
number / total / Next, persists the page in localStorage, and scrolls to the next page one at
a time with a 2 s cooldown after each load. Same filter + page is stable while the archive is
unchanged. Infinite mode keeps the stream buffer; prefetch, buffer fill, and the buffer are
disabled in page mode.
- [x] C56 Recovery completed. First run after the C55 fix recovered 1,117 of 1,240 Discord-CDN - [x] C56 Recovery completed. First run after the C55 fix recovered 1,117 of 1,240 Discord-CDN
previews (90%); the last 123 were confirmed genuinely gone (refresh + direct fetch and `/stream` previews (90%); the last 123 were confirmed genuinely gone (refresh + direct fetch and `/stream`
both fail) and are marked `unavailable after recovery` so later runs skip them. The recovery both fail) and are marked `unavailable after recovery` so later runs skip them. The recovery
+6 -1
View File
@@ -184,7 +184,12 @@ that deletion is irreversible. The delete button is disabled for a 5 second coun
can take its place while the hidden one stays resumable. The strip text turns red and reads can take its place while the hidden one stays resumable. The strip text turns red and reads
`API limited, retry in Ns` while Discord throttles media traffic, and a translucent `API limited, retry in Ns` while Discord throttles media traffic, and a translucent
`N scrapes completed` line appears when scrapes finish — it lives until the tab is reloaded. Each `N scrapes completed` line appears when scrapes finish — it lives until the tab is reloaded. Each
row shows `X/Yp (~Z%)` when the search total is known. The grid streams: appending a page inserts row shows `X/Yp (~Z%)` when the search total is known. A flippy toggle switches the grid
between **Infinite** (the default stream) and **Page** mode. In page mode only one page of
`pageSize` items is shown; sorting/filters are deterministic, so the same filter plus page
number returns the same content later. The pager shows `Prev`, an editable page number,
` / total`, and `Next`; scrolling to the bottom advances one page, but only after a 2 s cooldown
from the last load. The grid streams: appending a page inserts
only the new cards, so loaded images never rebuild or flash, and a stream **buffer** below the last only the new cards, so loaded images never rebuild or flash, and a stream **buffer** below the last
row keeps the scroll alive while the next page is in flight. The buffer is capped at 1.5 viewport row keeps the scroll alive while the next page is in flight. The buffer is capped at 1.5 viewport
heights, shrinks as the filtered results run out, and says `Loading more…` / `Scroll for more` / heights, shrinks as the filtered results run out, and says `Loading more…` / `Scroll for more` /
+46
View File
@@ -839,3 +839,49 @@
font: 400 10px/1.5 ui-monospace, Menlo, monospace; font: 400 10px/1.5 ui-monospace, Menlo, monospace;
color: var(--color-neutral-500); color: var(--color-neutral-500);
} }
/* Infinite/page toggle and pager. */
.vk-media-page-controls {
display: inline-flex;
align-items: center;
gap: 6px;
}
.vk-media-toggle {
display: inline-flex;
border: 1px solid var(--color-divider);
border-radius: 5px;
overflow: hidden;
}
.vk-toggle-half {
border: 0;
background: var(--color-surface);
color: var(--color-neutral-400);
font: 500 11px/1.4 ui-monospace, Menlo, monospace;
padding: 3px 8px;
cursor: pointer;
}
.vk-toggle-half.vk-toggle-on {
background: var(--color-accent-400, #4f8cff);
color: #fff;
}
.vk-media-page-nav {
display: inline-flex;
align-items: center;
gap: 6px;
}
.vk-media-page-input {
width: 58px;
}
.vk-media-page-end {
grid-column: 1 / -1;
padding: 10px 0;
text-align: center;
font: 400 11px/1.5 ui-monospace, Menlo, monospace;
color: var(--color-neutral-500);
}
+116 -2
View File
@@ -33,6 +33,9 @@
), ),
filters: defaultFilters(), filters: defaultFilters(),
zoom: Number(localStorage.getItem("vk-media-zoom") ?? 50), zoom: Number(localStorage.getItem("vk-media-zoom") ?? 50),
pageMode: localStorage.getItem("vk-media-page-mode") === "page" ? "page" : "infinite",
page: Math.max(1, Number(localStorage.getItem("vk-media-page")) || 1),
pageCooldownUntil: 0,
draft: null, draft: null,
prefetched: null, prefetched: null,
prefetchPromise: null, prefetchPromise: null,
@@ -362,6 +365,7 @@
state.stats = null; state.stats = null;
} }
renderStatus(); renderStatus();
if (state.pageMode === "page" && state.view === "media") renderToolbar();
} }
/** Append only the cards that are new, so existing images never get rebuilt. */ /** Append only the cards that are new, so existing images never get rebuilt. */
@@ -389,7 +393,8 @@
grid.replaceChildren(); grid.replaceChildren();
for (const item of state.items) grid.append(renderCard(item)); for (const item of state.items) grid.append(renderCard(item));
if (!state.items.length) grid.append(el("div", "vk-media-status", "No media matches the filters.")); if (!state.items.length) grid.append(el("div", "vk-media-status", "No media matches the filters."));
grid.append(buffer); if (state.pageMode === "infinite") grid.append(buffer);
else grid.append(el("div", "vk-media-page-end", `End of page ${state.page}`));
layoutBuffer(); layoutBuffer();
} }
@@ -402,7 +407,7 @@
if (reset) { if (reset) {
state.items = []; state.items = [];
state.itemKeys = new Set(); state.itemKeys = new Set();
state.offset = 0; state.offset = state.pageMode === "page" ? (state.page - 1) * state.pageSize : 0;
state.more = true; state.more = true;
state.prefetched = null; state.prefetched = null;
state.prefetchPromise = null; state.prefetchPromise = null;
@@ -431,12 +436,18 @@
layoutBuffer(); layoutBuffer();
void loadStats(); void loadStats();
if (state.pageMode === "infinite") {
void prefetchNext(); void prefetchNext();
void fillBuffer(); void fillBuffer();
} else {
state.pageCooldownUntil = Date.now() + 2000;
renderToolbar();
}
} }
/** Warm the next page's JSON and preview images before the user reaches them. */ /** Warm the next page's JSON and preview images before the user reaches them. */
async function prefetchNext() { async function prefetchNext() {
if (state.pageMode !== "infinite") return;
if (!state.loaded || !state.more || state.prefetched || state.prefetchPromise || state.loading) return; if (!state.loaded || !state.more || state.prefetched || state.prefetchPromise || state.loading) return;
state.prefetchPromise = window.VK.api state.prefetchPromise = window.VK.api
@@ -488,6 +499,12 @@
* 1.5 viewport heights and shrinks as the end of the filtered results comes closer. * 1.5 viewport heights and shrinks as the end of the filtered results comes closer.
*/ */
function layoutBuffer() { function layoutBuffer() {
if (state.pageMode !== "infinite") {
buffer.style.height = "0px";
bufferNote.textContent = "";
bufferRetry.hidden = true;
return;
}
if (!state.loaded) { if (!state.loaded) {
buffer.style.height = "0px"; buffer.style.height = "0px";
bufferNote.textContent = ""; bufferNote.textContent = "";
@@ -529,8 +546,46 @@
); );
} }
function pageCount() {
const total = filteredTotal();
if (total === null) return null;
return Math.max(1, Math.ceil(total / Math.max(1, state.pageSize)));
}
function hasNextPage() {
const count = pageCount();
return count === null ? state.more : state.page < count;
}
function savePage() {
localStorage.setItem("vk-media-page", String(state.page));
}
function setPageMode(mode) {
if (state.pageMode === mode) return;
state.pageMode = mode;
localStorage.setItem("vk-media-page-mode", mode);
state.prefetched = null;
state.page = 1;
savePage();
if (state.loaded) void loadMedia(true);
renderToolbar();
}
function gotoPage(page) {
const count = pageCount();
const wanted = Math.max(1, Math.round(Number(page) || 1));
state.page = count ? Math.min(count, wanted) : wanted;
savePage();
state.loaded = true;
state.pageCooldownUntil = Date.now() + 2000;
void loadMedia(true);
renderToolbar();
}
/** Keep loading while the buffer is on screen, so short filtered pages never dead-stop. */ /** Keep loading while the buffer is on screen, so short filtered pages never dead-stop. */
function fillBuffer() { function fillBuffer() {
if (state.pageMode !== "infinite") return;
if (!state.more || state.loading || !state.loaded) return; if (!state.more || state.loading || !state.loaded) return;
const top = buffer.getBoundingClientRect().top; const top = buffer.getBoundingClientRect().top;
const bottom = grid.getBoundingClientRect().bottom; const bottom = grid.getBoundingClientRect().bottom;
@@ -1297,6 +1352,50 @@
} }
/** Dropdown with the metadata-field checkboxes. */ /** Dropdown with the metadata-field checkboxes. */
/** Infinite/page flippy toggle plus the pager shown in page mode. */
function pageControls() {
const wrap = el("span", "vk-media-page-controls");
const toggle = el("span", "vk-media-toggle");
for (const [mode, label] of [
["infinite", "Infinite"],
["page", "Page"]
]) {
const half = el("button", `vk-toggle-half${state.pageMode === mode ? " vk-toggle-on" : ""}`, label);
half.type = "button";
half.addEventListener("click", () => setPageMode(mode));
toggle.append(half);
}
wrap.append(toggle);
if (state.pageMode === "page") {
const count = pageCount();
const nav = el("span", "vk-media-page-nav");
const prev = button("Prev", () => gotoPage(state.page - 1));
prev.disabled = state.page <= 1;
const input = document.createElement("input");
input.type = "number";
input.min = "1";
input.max = count ? String(count) : "";
input.value = String(state.page);
input.className = "vk-media-input vk-media-page-input";
input.addEventListener("change", () => gotoPage(input.value));
input.addEventListener("keydown", event => {
if (event.key === "Enter") gotoPage(input.value);
});
const next = button("Next", () => gotoPage(state.page + 1));
next.disabled = !hasNextPage();
nav.append(prev, input, el("span", "vk-media-status", ` / ${count ?? "?"}`), next);
wrap.append(nav);
}
return wrap;
}
function metaMenu() { function metaMenu() {
const wrap = el("span", "vk-media-meta"); const wrap = el("span", "vk-media-meta");
const panel = el("div", "vk-media-meta-menu"); const panel = el("div", "vk-media-meta-menu");
@@ -1376,6 +1475,8 @@
} }
function applyDraft() { function applyDraft() {
state.page = 1;
savePage();
state.kind = state.draft.kind; state.kind = state.draft.kind;
state.filters = { state.filters = {
source: state.draft.source, source: state.draft.source,
@@ -1504,6 +1605,7 @@
toolbar.append(peopleMenu()); toolbar.append(peopleMenu());
toolbar.append(button("Advanced search", toggleFilterDrawer)); toolbar.append(button("Advanced search", toggleFilterDrawer));
toolbar.append(metaMenu()); toolbar.append(metaMenu());
toolbar.append(pageControls());
toolbar.append(el("span", "vk-media-spacer")); toolbar.append(el("span", "vk-media-spacer"));
toolbar.append(button("Apply filters", applyDraft)); toolbar.append(button("Apply filters", applyDraft));
toolbar.append(button("Reset", resetMediaView)); toolbar.append(button("Reset", resetMediaView));
@@ -1892,6 +1994,9 @@
state.loaded = false; state.loaded = false;
state.loading = false; state.loading = false;
state.more = false; state.more = false;
state.page = 1;
state.pageCooldownUntil = 0;
savePage();
state.prefetched = null; state.prefetched = null;
state.items = []; state.items = [];
state.offset = 0; state.offset = 0;
@@ -1992,6 +2097,15 @@
grid.addEventListener("scroll", () => { grid.addEventListener("scroll", () => {
if (!state.loaded) return; if (!state.loaded) return;
const remaining = grid.scrollHeight - grid.scrollTop - grid.clientHeight; const remaining = grid.scrollHeight - grid.scrollTop - grid.clientHeight;
if (state.pageMode === "page") {
// One page at a time, and only after the cooldown following the last load.
if (remaining < 80 && Date.now() >= state.pageCooldownUntil && hasNextPage()) {
gotoPage(state.page + 1);
}
return;
}
if (remaining < 400) void loadNextPage(); if (remaining < 400) void loadNextPage();
}); });