feat(feed): virtualize the feed list and consume the keyset cursor

Render only the visible rows via @tanstack/react-virtual (useVirtualizer
against the app's <main> scroll container, per-row dynamic measurement),
so the DOM no longer accumulates every loaded card. A ResizeObserver keeps
the responsive grid column count in sync and chunks cards into virtualized
rows; the list view virtualizes single-card rows. Infinite scroll now
triggers from the virtual range and pages via next_cursor instead of
offset; feed/count drops its unused paging args.
This commit is contained in:
npeter83 2026-06-25 19:54:40 +02:00
parent 424b19f3b6
commit 0058ba7ccf
5 changed files with 228 additions and 55 deletions

View file

@ -79,7 +79,8 @@ export interface VideoDetail {
export interface FeedResponse {
items: Video[];
has_more: boolean;
offset: number;
// Opaque keyset cursor for the next page, or null when this is the last page.
next_cursor: string | null;
limit: number;
}
@ -308,11 +309,11 @@ function filterParams(f: FeedFilters): URLSearchParams {
return p;
}
function feedQuery(f: FeedFilters, offset: number, limit: number): string {
function feedQuery(f: FeedFilters, cursor: string | null, limit: number): string {
const p = filterParams(f);
p.set("sort", f.sort);
if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed));
p.set("offset", String(offset));
if (cursor) p.set("cursor", cursor);
p.set("limit", String(limit));
return p.toString();
}
@ -511,10 +512,10 @@ export const api = {
version: (): Promise<VersionInfo> => req("/api/version"),
tags: (): Promise<Tag[]> => req("/api/tags"),
status: (): Promise<SyncStatus> => req("/api/sync/status"),
feed: (f: FeedFilters, offset: number, limit: number): Promise<FeedResponse> =>
req(`/api/feed?${feedQuery(f, offset, limit)}`),
feed: (f: FeedFilters, cursor: string | null, limit: number): Promise<FeedResponse> =>
req(`/api/feed?${feedQuery(f, cursor, limit)}`),
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
req(`/api/feed/count?${filterParams(f).toString()}`),
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
req(`/api/facets?${filterParams(f).toString()}`),
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these