feat(core): add pagination to explore, search, person, and library endpoints

Convert trending, search, people.detail, and dashboard.library to page-based responses (`page` / `totalPages`) so clients can load results incrementally.

Update `@sofa/core` discovery and person services to accept a `page` / `limit` input and slice results accordingly. Add a new DB migration to persist the data needed to back paginated filmography queries.

On the web, introduce a `useInfiniteScroll` hook and wire up `useInfiniteQuery` in the explore, person detail, and filterable title row components with an intersection-observer sentinel. On native, switch the same screens from `useQuery` to `useInfiniteQuery` with `onEndReached` / `ListFooterComponent` loading indicators. Also replace remaining `FlatList` usages with `FlashList` in the home and title detail screens.
This commit is contained in:
2026-03-15 17:37:39 -04:00
parent d0eca7cf32
commit 6c193a9271
34 changed files with 4135 additions and 313 deletions
@@ -1,15 +1,30 @@
import { IconBooks } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { orpc } from "@/lib/orpc/client";
import { FeedSection } from "./feed-section";
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
export function LibrarySection() {
const { data, isPending } = useQuery(orpc.dashboard.library.queryOptions());
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(
orpc.dashboard.library.infiniteOptions({
input: (pageParam: number) => ({ page: pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
const sentinelRef = useInfiniteScroll({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
});
if (isPending) return <TitleGridSectionSkeleton />;
const items = data?.items ?? [];
const items = data?.pages.flatMap((p) => p.items) ?? [];
if (items.length === 0) return null;
return (
@@ -18,6 +33,8 @@ export function LibrarySection() {
icon={<IconBooks className="size-5 text-primary" />}
>
<TitleGrid items={items} />
<div ref={sentinelRef} />
{isFetchingNextPage && <TitleGridSectionSkeleton />}
</FeedSection>
);
}
@@ -1,5 +1,6 @@
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
import { FilterableTitleRow } from "./filterable-title-row";
@@ -33,14 +34,32 @@ function ExploreSkeletons() {
);
}
function mergeMaps<T>(
...maps: (Record<string, T> | undefined)[]
): Record<string, T> {
return Object.assign({}, ...maps.filter(Boolean));
}
export function ExploreClient() {
const { data: trending, isPending: trendingPending } = useQuery(
orpc.explore.trending.queryOptions({ input: { type: "all" } }),
const {
data: trendingData,
isPending: trendingPending,
fetchNextPage: fetchNextTrending,
hasNextPage: hasNextTrending,
isFetchingNextPage: isFetchingNextTrending,
} = useInfiniteQuery(
orpc.explore.trending.infiniteOptions({
input: (pageParam: number) => ({ type: "all" as const, page: pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
const { data: popularMovies, isPending: moviesPending } = useQuery(
const { data: popularMoviesData, isPending: moviesPending } = useQuery(
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
);
const { data: popularTv, isPending: tvPending } = useQuery(
const { data: popularTvData, isPending: tvPending } = useQuery(
orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
);
const { data: movieGenreData } = useQuery(
@@ -52,46 +71,60 @@ export function ExploreClient() {
const isPending = trendingPending || moviesPending || tvPending;
const trendingItems = useMemo(
() => trendingData?.pages.flatMap((p) => p.items) ?? [],
[trendingData?.pages],
);
const hero = trendingData?.pages[0]?.hero ?? null;
if (isPending) return <ExploreSkeletons />;
// Merge user statuses and episode progress from all responses
const userStatuses = {
...trending?.userStatuses,
...popularMovies?.userStatuses,
...popularTv?.userStatuses,
};
const episodeProgress = {
...trending?.episodeProgress,
...popularMovies?.episodeProgress,
...popularTv?.episodeProgress,
};
const userStatuses = mergeMaps(
...(trendingData?.pages.map((p) => p.userStatuses) ?? []),
popularMoviesData?.userStatuses,
popularTvData?.userStatuses,
);
const episodeProgress = mergeMaps(
...(trendingData?.pages.map((p) => p.episodeProgress) ?? []),
popularMoviesData?.episodeProgress,
popularTvData?.episodeProgress,
);
return (
<div className="space-y-10">
{trending?.hero && (
{hero && (
<HeroBanner
tmdbId={trending.hero.tmdbId}
type={trending.hero.type}
title={trending.hero.title}
overview={trending.hero.overview}
backdropPath={trending.hero.backdropPath}
voteAverage={trending.hero.voteAverage}
tmdbId={hero.tmdbId}
type={hero.type}
title={hero.title}
overview={hero.overview}
backdropPath={hero.backdropPath}
voteAverage={hero.voteAverage}
/>
)}
<TitleRow
heading="Trending Today"
icon={<IconFlame aria-hidden={true} className="size-5 text-primary" />}
items={(trending?.items ?? []).slice(0, 20)}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
/>
<div>
<TitleRow
heading="Trending Today"
icon={
<IconFlame aria-hidden={true} className="size-5 text-primary" />
}
items={trendingItems}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
onEndReached={fetchNextTrending}
hasNextPage={hasNextTrending}
isFetchingNextPage={isFetchingNextTrending}
/>
</div>
<FilterableTitleRow
heading="Popular Movies"
icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />}
mediaType="movie"
defaultItems={(popularMovies?.items ?? []).slice(0, 20)}
defaultItems={(popularMoviesData?.items ?? []).slice(0, 20)}
genres={movieGenreData?.genres ?? []}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
@@ -103,7 +136,7 @@ export function ExploreClient() {
<IconDeviceTv aria-hidden={true} className="size-5 text-primary" />
}
mediaType="tv"
defaultItems={(popularTv?.items ?? []).slice(0, 20)}
defaultItems={(popularTvData?.items ?? []).slice(0, 20)}
genres={tvGenreData?.genres ?? []}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
@@ -1,8 +1,9 @@
import { skipToken, useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
import { useMemo, useRef, useState } from "react";
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { hasReachedHorizontalEnd } from "@/hooks/use-infinite-scroll";
import { orpc } from "@/lib/orpc/client";
interface Genre {
@@ -43,25 +44,56 @@ export function FilterableTitleRow({
episodeProgress: initialProgress = {},
}: FilterableTitleRowProps) {
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
const { data: discoverData, isLoading: isPending } = useQuery(
orpc.discover.queryOptions({
const scrollRef = useRef<HTMLDivElement>(null);
const {
data: discoverData,
isPending,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery(
orpc.discover.infiniteOptions({
input:
selectedGenre != null
? { type: mediaType, genreId: selectedGenre }
? (pageParam: number) => ({
type: mediaType,
genreId: selectedGenre,
page: pageParam,
})
: skipToken,
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
const items =
selectedGenre === null ? defaultItems : (discoverData?.items ?? []);
const discoverItems = useMemo(
() => discoverData?.pages.flatMap((p) => p.items) ?? [],
[discoverData?.pages],
);
const discoverStatuses = useMemo(
() =>
Object.assign(
{},
...(discoverData?.pages.map((p) => p.userStatuses) ?? []),
) as Record<string, TitleStatus>,
[discoverData?.pages],
);
const discoverProgress = useMemo(
() =>
Object.assign(
{},
...(discoverData?.pages.map((p) => p.episodeProgress) ?? []),
) as Record<string, { watched: number; total: number }>,
[discoverData?.pages],
);
const isLoading = selectedGenre !== null && isPending;
const items = selectedGenre === null ? defaultItems : discoverItems;
const userStatuses =
selectedGenre === null
? initialStatuses
: (discoverData?.userStatuses ?? {});
selectedGenre === null ? initialStatuses : discoverStatuses;
const episodeProgress =
selectedGenre === null
? initialProgress
: (discoverData?.episodeProgress ?? {});
selectedGenre === null ? initialProgress : discoverProgress;
function toggleGenre(genreId: number) {
setSelectedGenre(genreId === selectedGenre ? null : genreId);
@@ -98,7 +130,7 @@ export function FilterableTitleRow({
</ScrollArea>
{/* Loading skeleton */}
{isPending && (
{isLoading && (
<div className="-mx-4 flex gap-4 overflow-hidden px-4 sm:-mx-0 sm:px-0">
{Array.from({ length: 8 }).map((_, i) => (
<div
@@ -113,22 +145,38 @@ export function FilterableTitleRow({
)}
{/* Empty state */}
{!isPending && selectedGenre !== null && items.length === 0 && (
{!isLoading && selectedGenre !== null && items.length === 0 && (
<p className="py-8 text-center text-muted-foreground text-sm">
No titles found for this genre.
</p>
)}
{/* Title cards */}
{!isPending && items.length > 0 && (
{!isLoading && items.length > 0 && (
<ScrollArea
key={selectedGenre ?? "default"}
scrollFade
hideScrollbar
className="-mx-6 sm:-mx-2"
scrollRef={scrollRef}
onScrollEnd={() => {
const viewport = scrollRef.current;
if (
selectedGenre === null ||
!viewport ||
!hasNextPage ||
isFetchingNextPage ||
!hasReachedHorizontalEnd(viewport)
) {
return;
}
fetchNextPage();
}}
>
<div className="flex gap-4 px-6 py-2 sm:px-2">
{items.slice(0, 20).map((item: TitleRowItem, i: number) => (
{items.map((item: TitleRowItem, i: number) => (
<div
key={`${item.type}-${item.tmdbId}`}
className="w-[140px] shrink-0 sm:w-[160px]"
@@ -153,6 +201,11 @@ export function FilterableTitleRow({
</div>
</div>
))}
{isFetchingNextPage && (
<div className="flex shrink-0 items-center px-4">
<div className="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)}
</div>
</ScrollArea>
)}
+36 -1
View File
@@ -1,5 +1,7 @@
import { useRef } from "react";
import { TitleCard } from "@/components/title-card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { hasReachedHorizontalEnd } from "@/hooks/use-infinite-scroll";
interface TitleRowItem {
tmdbId: number;
@@ -18,6 +20,9 @@ interface TitleRowProps {
items: TitleRowItem[];
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
episodeProgress?: Record<string, { watched: number; total: number }>;
onEndReached?: () => void;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
}
export function TitleRow({
@@ -26,7 +31,12 @@ export function TitleRow({
items,
userStatuses,
episodeProgress,
onEndReached,
hasNextPage = false,
isFetchingNextPage = false,
}: TitleRowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
if (items.length === 0) return null;
return (
@@ -37,7 +47,27 @@ export function TitleRow({
{heading}
</h2>
</div>
<ScrollArea scrollFade hideScrollbar className="-mx-6 sm:-mx-2">
<ScrollArea
scrollFade
hideScrollbar
className="-mx-6 sm:-mx-2"
scrollRef={scrollRef}
onScrollEnd={() => {
const viewport = scrollRef.current;
if (
!viewport ||
!onEndReached ||
!hasNextPage ||
isFetchingNextPage ||
!hasReachedHorizontalEnd(viewport)
) {
return;
}
onEndReached();
}}
>
<div className="flex gap-4 px-6 py-2 sm:px-2">
{items.map((item, i) => (
<div
@@ -64,6 +94,11 @@ export function TitleRow({
</div>
</div>
))}
{isFetchingNextPage && (
<div className="flex shrink-0 items-center px-4">
<div className="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)}
</div>
</ScrollArea>
</section>
@@ -1,6 +1,7 @@
import type { PersonCredit, ResolvedPerson } from "@sofa/api/schemas";
import { useQuery } from "@tanstack/react-query";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { orpc } from "@/lib/orpc/client";
import { FilmographyGrid } from "./filmography-grid";
import { PersonHero } from "./person-hero";
@@ -28,27 +29,50 @@ export function PersonDetailSkeleton() {
);
}
export interface PersonDetailResponse {
person: ResolvedPerson;
filmography: PersonCredit[];
userStatuses: Record<string, "watchlist" | "in_progress" | "completed">;
}
export function PersonDetailClient({ id }: { id: string }) {
const { data, isPending } = useQuery(
orpc.people.detail.queryOptions({ input: { id } }),
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(
orpc.people.detail.infiniteOptions({
input: (pageParam: number) => ({ id, page: pageParam, limit: 20 }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
const sentinelRef = useInfiniteScroll({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
});
const person = data?.pages[0]?.person;
const filmography = useMemo(
() => data?.pages.flatMap((p) => p.filmography) ?? [],
[data?.pages],
);
const userStatuses = useMemo(
() =>
Object.assign(
{},
...(data?.pages.map((p) => p.userStatuses) ?? []),
) as Record<string, "watchlist" | "in_progress" | "completed">,
[data?.pages],
);
if (isPending) return <PersonDetailSkeleton />;
if (!data) return null;
if (!person) return null;
return (
<div className="space-y-10">
<PersonHero person={data.person} />
<FilmographyGrid
credits={data.filmography}
userStatuses={data.userStatuses}
/>
<PersonHero person={person} />
<FilmographyGrid credits={filmography} userStatuses={userStatuses} />
<div ref={sentinelRef} />
{isFetchingNextPage && (
<div className="flex justify-center py-4">
<div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useRef } from "react";
export function hasReachedHorizontalEnd(element: HTMLElement, threshold = 24) {
if (element.scrollWidth <= element.clientWidth) {
return false;
}
return (
element.scrollLeft + element.clientWidth >= element.scrollWidth - threshold
);
}
export function useInfiniteScroll({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
}: {
fetchNextPage: () => void;
hasNextPage: boolean;
isFetchingNextPage: boolean;
}) {
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = sentinelRef.current;
if (!el || !hasNextPage) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: "200px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return sentinelRef;
}
+11 -2
View File
@@ -7,8 +7,17 @@ import { orpc } from "@/lib/orpc/client";
export const Route = createFileRoute("/_app/people/$id")({
loader: async ({ params, context }) => {
await context.queryClient.ensureQueryData(
orpc.people.detail.queryOptions({ input: { id: params.id } }),
await context.queryClient.ensureInfiniteQueryData(
orpc.people.detail.infiniteOptions({
input: (pageParam: number) => ({
id: params.id,
page: pageParam,
limit: 20,
}),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
},
head: ({ loaderData: _loaderData, params: _params }) => {