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
|
- [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
@@ -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` /
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user