siftlode/frontend/src/components/VirtualFeed.tsx
npeter83 cc1e670202 feat(channels): dedicated channel page + ephemeral explore UI
Frontend for the channel-explore feature:
- ChannelPage: banner/avatar/stats header, Subscribe/unsubscribe, an "exploring" badge
  while browsing an un-subscribed channel, Videos/About tabs. Reuses Feed scoped to the
  channel (scope=all + source=all so the per-user ephemeral videos show). Auto-ingests
  recent uploads on first visit (background, with a loading note) + "Load more from
  YouTube" to page deeper; skipped for demo / already-subscribed channels.
- App: openChannel/closeChannel as a Back-aware sub-view (history.state._chan, mirrors the
  YT-search _yt pattern); ChannelPage takes over the content column, nav rail stays.
- ChannelLink/cards/player: the channel name now opens our channel page (onChannelFilter →
  onOpenChannel); the in-card "only this channel" filter button is dropped (the page
  subsumes it). PlayerModal channel-name wiring follows in the next commit.
- api: channelDetail + exploreChannel; ChannelDetail/ExploreResult types.
- i18n EN/HU/DE: channel namespace, explore_cleanup scheduler labels, explore config group,
  channels_explore quota label.
2026-06-30 03:08:52 +02:00

176 lines
5.7 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;
onOpenChannel: (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,
onOpenChannel,
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}
onOpenChannel={onOpenChannel}
onResetState={onResetState}
onOpen={onOpen}
/>
))}
</div>
) : (
<div className="max-w-4xl mx-auto pb-1">
<VideoCard
video={row[0]}
view="list"
onState={onState}
onToggleSave={onToggleSave}
onOpenChannel={onOpenChannel}
onResetState={onResetState}
onOpen={onOpen}
/>
</div>
)}
</div>
);
})}
</div>
);
}