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
+10 -5
View File
@@ -1,6 +1,6 @@
import {
getContinueWatchingFeed,
getNewAvailableFeed,
getLibraryFeed,
getRecommendationsFeed,
getUserStats,
getWatchCount,
@@ -44,9 +44,14 @@ export const continueWatching = os.dashboard.continueWatching
export const library = os.dashboard.library
.use(authed)
.handler(({ context }) => {
const feed = getNewAvailableFeed(context.user.id);
const items = feed.slice(0, 10).map((t) => ({
.handler(({ input, context }) => {
const {
items: feed,
page,
totalPages,
totalResults,
} = getLibraryFeed(context.user.id, input.page, input.limit);
const items = feed.map((t) => ({
id: t.titleId,
tmdbId: t.tmdbId,
type: t.type,
@@ -58,7 +63,7 @@ export const library = os.dashboard.library
voteAverage: t.voteAverage,
userStatus: t.userStatus,
}));
return { items };
return { items, page, totalPages, totalResults };
});
export const recommendations = os.dashboard.recommendations
+17 -6
View File
@@ -22,11 +22,15 @@ export const discover = os.discover
});
}
const results = await discoverTmdb(input.type, {
sort_by: "popularity.desc",
"vote_count.gte": "50",
with_genres: String(input.genreId),
});
const results = await discoverTmdb(
input.type,
{
sort_by: "popularity.desc",
"vote_count.gte": "50",
with_genres: String(input.genreId),
},
input.page,
);
type DiscoverResult = NonNullable<typeof results.results>[number] & {
title?: string;
@@ -61,5 +65,12 @@ export const discover = os.discover
]
: [{}, {}];
return { items, userStatuses, episodeProgress };
return {
items,
userStatuses,
episodeProgress,
page: results.page ?? input.page,
totalPages: results.total_pages ?? 1,
totalResults: results.total_results ?? 0,
};
});
+19 -4
View File
@@ -26,7 +26,7 @@ export const trending = os.explore.trending
.handler(async ({ input, context }) => {
requireTmdb();
const data = await getTrending(input.type, "day");
const data = await getTrending(input.type, "day", input.page);
const results = (data.results ?? []) as Record<string, unknown>[];
const baseItems = results
@@ -83,7 +83,15 @@ export const trending = os.explore.trending
]
: [{}, {}];
return { items, hero, userStatuses, episodeProgress };
return {
items,
hero,
userStatuses,
episodeProgress,
page: (data as { page?: number }).page ?? input.page,
totalPages: (data as { total_pages?: number }).total_pages ?? 1,
totalResults: (data as { total_results?: number }).total_results ?? 0,
};
});
export const popular = os.explore.popular
@@ -91,7 +99,7 @@ export const popular = os.explore.popular
.handler(async ({ input, context }) => {
requireTmdb();
const data = await getPopular(input.type);
const data = await getPopular(input.type, input.page);
const baseItems = ((data.results ?? []) as Record<string, unknown>[])
.filter((r) => r.poster_path)
.map((r) => ({
@@ -118,7 +126,14 @@ export const popular = os.explore.popular
]
: [{}, {}];
return { items, userStatuses, episodeProgress };
return {
items,
userStatuses,
episodeProgress,
page: data.page ?? input.page,
totalPages: data.total_pages ?? 1,
totalResults: data.total_results ?? 0,
};
});
export const genres = os.explore.genres
+14 -3
View File
@@ -15,13 +15,24 @@ export const detail = os.people.detail
if (!person)
throw new ORPCError("NOT_FOUND", { message: "Person not found" });
const filmography = await fetchFullFilmography(person.id);
const allCredits = await fetchFullFilmography(person.id);
const start = (input.page - 1) * input.limit;
const pageCredits = allCredits.slice(start, start + input.limit);
const userStatuses = getUserStatusesByTitleIds(
context.user.id,
filmography.map((c) => c.titleId),
pageCredits.map((c) => c.titleId),
);
return { person, filmography, userStatuses };
return {
person,
filmography: pageCredits,
userStatuses,
page: input.page,
totalPages: Math.max(1, Math.ceil(allCredits.length / input.limit)),
totalResults: allCredits.length,
};
});
export const resolve = os.people.resolve
+14 -6
View File
@@ -19,12 +19,12 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
const query = input.query.trim();
if (!query) {
return { results: [] };
return { results: [], page: 1, totalPages: 0, totalResults: 0 };
}
const type = input.type ?? null;
if (type === "person") {
const personResults = await searchPerson(query);
const personResults = await searchPerson(query, input.page);
return {
results: (personResults.results ?? []).map((r) => ({
tmdbId: r.id,
@@ -43,15 +43,18 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
.map((k) => k.title ?? (k as { name?: string }).name)
.filter((s): s is string => !!s) as string[]) ?? null,
})),
page: personResults.page ?? input.page,
totalPages: personResults.total_pages ?? 1,
totalResults: personResults.total_results ?? 0,
};
}
const raw =
type === "movie"
? await searchMovies(query)
? await searchMovies(query, input.page)
: type === "tv"
? await searchTv(query)
: await searchMulti(query);
? await searchTv(query, input.page)
: await searchMulti(query, input.page);
type SearchResult = {
id: number;
@@ -105,5 +108,10 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
})
.filter((r): r is NonNullable<typeof r> => r !== null);
return { results: mapped };
return {
results: mapped,
page: raw.page ?? input.page,
totalPages: raw.total_pages ?? 1,
totalResults: raw.total_results ?? 0,
};
});