Add an infinite/page toggle with a pager
This commit is contained in:
@@ -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/<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
|
||||
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
|
||||
|
||||
+6
-1
@@ -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` /
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+118
-4
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user