From 13ffad34d46aae6bcc0d24ccfe00f2854f654cd4 Mon Sep 17 00:00:00 2001 From: Fisto Date: Fri, 18 Sep 2026 16:46:25 +0200 Subject: [PATCH] Add an infinite/page toggle with a pager --- CURRENT.md | 6 +++ docs/media.md | 7 ++- poc/public/media.css | 46 ++++++++++++++++ poc/public/media.js | 122 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 176 insertions(+), 5 deletions(-) diff --git a/CURRENT.md b/CURRENT.md index 1513cc2..48d1196 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -40,6 +40,12 @@ mobile draft at 390px. - [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`: `previewed: 2`; thumbs served over `/api/media///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 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 diff --git a/docs/media.md b/docs/media.md index 433ca22..fead79a 100644 --- a/docs/media.md +++ b/docs/media.md @@ -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 `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 - 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 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` / diff --git a/poc/public/media.css b/poc/public/media.css index f710846..0a71e82 100644 --- a/poc/public/media.css +++ b/poc/public/media.css @@ -839,3 +839,49 @@ font: 400 10px/1.5 ui-monospace, Menlo, monospace; 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); +} diff --git a/poc/public/media.js b/poc/public/media.js index e6dd6be..0c6a9f7 100644 --- a/poc/public/media.js +++ b/poc/public/media.js @@ -33,6 +33,9 @@ ), filters: defaultFilters(), 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, prefetched: null, prefetchPromise: null, @@ -362,6 +365,7 @@ state.stats = null; } renderStatus(); + if (state.pageMode === "page" && state.view === "media") renderToolbar(); } /** Append only the cards that are new, so existing images never get rebuilt. */ @@ -389,7 +393,8 @@ grid.replaceChildren(); 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.")); - 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(); } @@ -402,7 +407,7 @@ if (reset) { state.items = []; state.itemKeys = new Set(); - state.offset = 0; + state.offset = state.pageMode === "page" ? (state.page - 1) * state.pageSize : 0; state.more = true; state.prefetched = null; state.prefetchPromise = null; @@ -431,12 +436,18 @@ layoutBuffer(); void loadStats(); - void prefetchNext(); - void fillBuffer(); + if (state.pageMode === "infinite") { + void prefetchNext(); + void fillBuffer(); + } else { + state.pageCooldownUntil = Date.now() + 2000; + renderToolbar(); + } } /** Warm the next page's JSON and preview images before the user reaches them. */ async function prefetchNext() { + if (state.pageMode !== "infinite") return; if (!state.loaded || !state.more || state.prefetched || state.prefetchPromise || state.loading) return; state.prefetchPromise = window.VK.api @@ -488,6 +499,12 @@ * 1.5 viewport heights and shrinks as the end of the filtered results comes closer. */ function layoutBuffer() { + if (state.pageMode !== "infinite") { + buffer.style.height = "0px"; + bufferNote.textContent = ""; + bufferRetry.hidden = true; + return; + } if (!state.loaded) { buffer.style.height = "0px"; 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. */ function fillBuffer() { + if (state.pageMode !== "infinite") return; if (!state.more || state.loading || !state.loaded) return; const top = buffer.getBoundingClientRect().top; const bottom = grid.getBoundingClientRect().bottom; @@ -1297,6 +1352,50 @@ } /** 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() { const wrap = el("span", "vk-media-meta"); const panel = el("div", "vk-media-meta-menu"); @@ -1376,6 +1475,8 @@ } function applyDraft() { + state.page = 1; + savePage(); state.kind = state.draft.kind; state.filters = { source: state.draft.source, @@ -1504,6 +1605,7 @@ toolbar.append(peopleMenu()); toolbar.append(button("Advanced search", toggleFilterDrawer)); toolbar.append(metaMenu()); + toolbar.append(pageControls()); toolbar.append(el("span", "vk-media-spacer")); toolbar.append(button("Apply filters", applyDraft)); toolbar.append(button("Reset", resetMediaView)); @@ -1892,6 +1994,9 @@ state.loaded = false; state.loading = false; state.more = false; + state.page = 1; + state.pageCooldownUntil = 0; + savePage(); state.prefetched = null; state.items = []; state.offset = 0; @@ -1992,6 +2097,15 @@ grid.addEventListener("scroll", () => { if (!state.loaded) return; 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(); });