177 lines
5.8 KiB
TypeScript
177 lines
5.8 KiB
TypeScript
|
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||
|
|
import type { Video } from "../lib/api";
|
||
|
|
import VideoCard from "./VideoCard";
|
||
|
|
|
||
|
|
// Grid column sizing mirrors the CSS the feed used before virtualization
|
||
|
|
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter).
|
||
|
|
const GRID_MIN_COL = 260;
|
||
|
|
const GRID_GAP = 16;
|
||
|
|
const LIST_GAP = 4;
|
||
|
|
// Estimated row heights before measurement; real heights are measured per-row so
|
||
|
|
// these only affect the very first paint and the scrollbar's initial guess.
|
||
|
|
const GRID_ROW_EST = 340;
|
||
|
|
const LIST_ROW_EST = 96;
|
||
|
|
// Start fetching the next page this many rows before the end scrolls into view
|
||
|
|
// (keeps the old IntersectionObserver's generous prefetch feel).
|
||
|
|
const PREFETCH_ROWS = 4;
|
||
|
|
|
||
|
|
function chunk<T>(arr: T[], size: number): T[][] {
|
||
|
|
if (size <= 1) return arr.map((x) => [x]);
|
||
|
|
const out: T[][] = [];
|
||
|
|
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
// The feed scrolls inside the app's <main overflow-y-auto>, not the window, so the
|
||
|
|
// virtualizer needs that element. Walk up to the nearest scrollable ancestor.
|
||
|
|
function getScrollParent(node: HTMLElement | null): HTMLElement {
|
||
|
|
let el = node?.parentElement ?? null;
|
||
|
|
while (el) {
|
||
|
|
const oy = getComputedStyle(el).overflowY;
|
||
|
|
if (oy === "auto" || oy === "scroll") return el;
|
||
|
|
el = el.parentElement;
|
||
|
|
}
|
||
|
|
return document.documentElement;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface VirtualFeedProps {
|
||
|
|
items: Video[];
|
||
|
|
view: "grid" | "list";
|
||
|
|
onState: (id: string, status: string) => void;
|
||
|
|
onToggleSave: (id: string, saved: boolean) => void;
|
||
|
|
onChannelFilter: (channelId: string, channelName: string) => void;
|
||
|
|
onResetState: (id: string) => void;
|
||
|
|
onOpen: (v: Video, startAt?: number | null) => void;
|
||
|
|
hasNextPage: boolean;
|
||
|
|
isFetchingNextPage: boolean;
|
||
|
|
fetchNextPage: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function VirtualFeed({
|
||
|
|
items,
|
||
|
|
view,
|
||
|
|
onState,
|
||
|
|
onToggleSave,
|
||
|
|
onChannelFilter,
|
||
|
|
onResetState,
|
||
|
|
onOpen,
|
||
|
|
hasNextPage,
|
||
|
|
isFetchingNextPage,
|
||
|
|
fetchNextPage,
|
||
|
|
}: VirtualFeedProps) {
|
||
|
|
const listRef = useRef<HTMLDivElement>(null);
|
||
|
|
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
|
||
|
|
const [colCount, setColCount] = useState(view === "list" ? 1 : 4);
|
||
|
|
// Distance from the scroll element's content top to where this list starts (the
|
||
|
|
// feed toolbar sits above it inside the same scroll area).
|
||
|
|
const [scrollMargin, setScrollMargin] = useState(0);
|
||
|
|
|
||
|
|
useLayoutEffect(() => {
|
||
|
|
if (listRef.current) setScrollEl(getScrollParent(listRef.current));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// Re-measure the column count and start offset on container/scroll-area resize.
|
||
|
|
useLayoutEffect(() => {
|
||
|
|
const node = listRef.current;
|
||
|
|
if (!node) return;
|
||
|
|
const measure = () => {
|
||
|
|
if (scrollEl) {
|
||
|
|
const m =
|
||
|
|
node.getBoundingClientRect().top -
|
||
|
|
scrollEl.getBoundingClientRect().top +
|
||
|
|
scrollEl.scrollTop;
|
||
|
|
setScrollMargin(m);
|
||
|
|
}
|
||
|
|
setColCount(
|
||
|
|
view === "list"
|
||
|
|
? 1
|
||
|
|
: Math.max(1, Math.floor((node.clientWidth + GRID_GAP) / (GRID_MIN_COL + GRID_GAP)))
|
||
|
|
);
|
||
|
|
};
|
||
|
|
measure();
|
||
|
|
const ro = new ResizeObserver(measure);
|
||
|
|
ro.observe(node);
|
||
|
|
if (scrollEl) ro.observe(scrollEl);
|
||
|
|
return () => ro.disconnect();
|
||
|
|
}, [scrollEl, view]);
|
||
|
|
|
||
|
|
const rows = useMemo(() => chunk(items, colCount), [items, colCount]);
|
||
|
|
|
||
|
|
const virtualizer = useVirtualizer({
|
||
|
|
count: rows.length,
|
||
|
|
getScrollElement: () => scrollEl,
|
||
|
|
estimateSize: () => (view === "grid" ? GRID_ROW_EST : LIST_ROW_EST),
|
||
|
|
overscan: 4,
|
||
|
|
gap: view === "grid" ? GRID_GAP : LIST_GAP,
|
||
|
|
scrollMargin,
|
||
|
|
});
|
||
|
|
|
||
|
|
const virtualRows = virtualizer.getVirtualItems();
|
||
|
|
|
||
|
|
// Infinite-scroll trigger: once the last rendered row is within PREFETCH_ROWS of
|
||
|
|
// the end, pull the next keyset page.
|
||
|
|
useEffect(() => {
|
||
|
|
const last = virtualRows[virtualRows.length - 1];
|
||
|
|
if (!last) return;
|
||
|
|
if (last.index >= rows.length - 1 - PREFETCH_ROWS && hasNextPage && !isFetchingNextPage) {
|
||
|
|
fetchNextPage();
|
||
|
|
}
|
||
|
|
}, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div ref={listRef} style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||
|
|
{virtualRows.map((vr) => {
|
||
|
|
const row = rows[vr.index];
|
||
|
|
if (!row) return null;
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={vr.key}
|
||
|
|
data-index={vr.index}
|
||
|
|
ref={virtualizer.measureElement}
|
||
|
|
style={{
|
||
|
|
position: "absolute",
|
||
|
|
top: 0,
|
||
|
|
left: 0,
|
||
|
|
width: "100%",
|
||
|
|
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{view === "grid" ? (
|
||
|
|
<div
|
||
|
|
className="grid gap-4"
|
||
|
|
style={{ gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }}
|
||
|
|
>
|
||
|
|
{row.map((v) => (
|
||
|
|
<VideoCard
|
||
|
|
key={v.id}
|
||
|
|
video={v}
|
||
|
|
view="grid"
|
||
|
|
onState={onState}
|
||
|
|
onToggleSave={onToggleSave}
|
||
|
|
onChannelFilter={onChannelFilter}
|
||
|
|
onResetState={onResetState}
|
||
|
|
onOpen={onOpen}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="max-w-4xl mx-auto pb-1">
|
||
|
|
<VideoCard
|
||
|
|
video={row[0]}
|
||
|
|
view="list"
|
||
|
|
onState={onState}
|
||
|
|
onToggleSave={onToggleSave}
|
||
|
|
onChannelFilter={onChannelFilter}
|
||
|
|
onResetState={onResetState}
|
||
|
|
onOpen={onOpen}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|