import { useEffect, useRef, useState } from "react"; import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { api, type FeedFilters, type Video } from "../lib/api"; import { toast } from "../lib/toast"; import VideoCard from "./VideoCard"; const PAGE = 60; function matchesView(status: string, show: string): boolean { switch (show) { case "hidden": return status === "hidden"; case "watched": return status === "watched"; case "saved": return status === "saved"; case "unwatched": return status !== "watched" && status !== "hidden"; default: return status !== "hidden"; // all } } export default function Feed({ filters, setFilters, view, }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; view: "grid" | "list"; }) { const [overrides, setOverrides] = useState>({}); const qc = useQueryClient(); const query = useInfiniteQuery({ queryKey: ["feed", filters], queryFn: ({ pageParam }) => api.feed(filters, pageParam as number, PAGE), initialPageParam: 0, getNextPageParam: (last) => (last.has_more ? last.offset + last.limit : undefined), }); useEffect(() => setOverrides({}), [filters]); const countQuery = useQuery({ queryKey: ["feed-count", filters], queryFn: () => api.feedCount(filters), staleTime: 30_000, }); const sentinel = useRef(null); const { hasNextPage, isFetchingNextPage, fetchNextPage } = query; useEffect(() => { const el = sentinel.current; if (!el) return; const io = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, { rootMargin: "800px" } ); io.observe(el); return () => io.disconnect(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); function onState(id: string, status: string) { setOverrides((o) => ({ ...o, [id]: status })); // Refetch once the server has the change so other views (e.g. Hidden) are in sync. api .setState(id, status) .then(() => qc.invalidateQueries({ queryKey: ["feed"] })) .catch(() => {}); if (status === "hidden") { toast("Video hidden", { label: "Undo", onClick: () => onState(id, "new") }); } } function onChannelFilter(channelId: string, channelName: string) { setFilters({ ...filters, channelId, channelName }); } const items: Video[] = (query.data?.pages ?? []) .flatMap((p) => p.items) .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) .filter((v) => matchesView(v.status, filters.show)); if (query.isLoading) return
Loading feed…
; if (query.isError) return
Couldn't load the feed.
; if (items.length === 0) return
No videos match these filters.
; return (
{countQuery.data ? `${countQuery.data.count.toLocaleString()} video${countQuery.data.count === 1 ? "" : "s"}` : " "}
{view === "grid" ? (
{items.map((v) => ( ))}
) : (
{items.map((v) => ( ))}
)}
{isFetchingNextPage && (
Loading more…
)}
); }