mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 00:25:38 -04:00
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:
@@ -1,7 +1,7 @@
|
||||
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Stack } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { RefreshControl, ScrollView, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||
|
||||
@@ -11,8 +11,13 @@ import { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
|
||||
export default function ExploreScreen() {
|
||||
const trending = useQuery(
|
||||
orpc.explore.trending.queryOptions({ input: { type: "all" } }),
|
||||
const trending = 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 popularMovies = useQuery(
|
||||
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
|
||||
@@ -37,7 +42,28 @@ export default function ExploreScreen() {
|
||||
queryClient.invalidateQueries({ queryKey: orpc.discover.key() });
|
||||
}, []);
|
||||
|
||||
const heroItem = trending.data?.hero;
|
||||
const heroItem = trending.data?.pages[0]?.hero ?? null;
|
||||
|
||||
const trendingItems = useMemo(
|
||||
() => trending.data?.pages.flatMap((p) => p.items) ?? [],
|
||||
[trending.data?.pages],
|
||||
);
|
||||
const trendingStatuses = useMemo(
|
||||
() =>
|
||||
Object.assign(
|
||||
{},
|
||||
...(trending.data?.pages.map((p) => p.userStatuses) ?? []),
|
||||
) as Record<string, "watchlist" | "in_progress" | "completed">,
|
||||
[trending.data?.pages],
|
||||
);
|
||||
const trendingProgress = useMemo(
|
||||
() =>
|
||||
Object.assign(
|
||||
{},
|
||||
...(trending.data?.pages.map((p) => p.episodeProgress) ?? []),
|
||||
) as Record<string, { watched: number; total: number }>,
|
||||
[trending.data?.pages],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -68,9 +94,9 @@ export default function ExploreScreen() {
|
||||
title="Trending Today"
|
||||
icon={IconFlame}
|
||||
mediaType="movie"
|
||||
defaultItems={trending.data?.items ?? []}
|
||||
defaultUserStatuses={trending.data?.userStatuses ?? {}}
|
||||
defaultEpisodeProgress={trending.data?.episodeProgress ?? {}}
|
||||
defaultItems={trendingItems}
|
||||
defaultUserStatuses={trendingStatuses}
|
||||
defaultEpisodeProgress={trendingProgress}
|
||||
isLoading={trending.isPending}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import {
|
||||
IconBooks,
|
||||
IconPlayerPlay,
|
||||
@@ -6,7 +7,7 @@ import {
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { FlatList, RefreshControl, ScrollView, View } from "react-native";
|
||||
import { RefreshControl, ScrollView, View } from "react-native";
|
||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
|
||||
import { ContinueWatchingCard } from "@/components/dashboard/continue-watching-card";
|
||||
@@ -18,6 +19,10 @@ import { authClient } from "@/lib/auth-client";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
|
||||
const horizontalListStyle = { overflow: "visible" as const };
|
||||
const statsListContentStyle = { gap: 12, paddingHorizontal: 16 };
|
||||
const continueWatchingContentStyle = { gap: 12, paddingHorizontal: 16 };
|
||||
|
||||
export default function DashboardScreen() {
|
||||
const { push } = useRouter();
|
||||
authClient.useSession();
|
||||
@@ -26,7 +31,7 @@ export default function DashboardScreen() {
|
||||
const continueWatching = useQuery(
|
||||
orpc.dashboard.continueWatching.queryOptions(),
|
||||
);
|
||||
const library = useQuery(orpc.dashboard.library.queryOptions());
|
||||
const library = useQuery(orpc.dashboard.library.queryOptions({ input: {} }));
|
||||
const recommendations = useQuery(
|
||||
orpc.dashboard.recommendations.queryOptions(),
|
||||
);
|
||||
@@ -52,6 +57,21 @@ export default function DashboardScreen() {
|
||||
[stats.data],
|
||||
);
|
||||
|
||||
const renderStatItem = useCallback(
|
||||
({ item }: { item: (typeof statsData)[number] }) => (
|
||||
<StatsCard label={item.label} value={item.value} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
const renderContinueWatchingItem = useCallback(
|
||||
({
|
||||
item,
|
||||
}: {
|
||||
item: NonNullable<typeof continueWatching.data>["items"][number];
|
||||
}) => <ContinueWatchingCard item={item} />,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="bg-background"
|
||||
@@ -72,16 +92,14 @@ export default function DashboardScreen() {
|
||||
<View className="gap-8">
|
||||
{/* Stats */}
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||
<FlatList
|
||||
<FlashList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={statsData}
|
||||
keyExtractor={(item) => item.label}
|
||||
renderItem={({ item }) => (
|
||||
<StatsCard label={item.label} value={item.value} />
|
||||
)}
|
||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={renderStatItem}
|
||||
contentContainerStyle={statsListContentStyle}
|
||||
style={horizontalListStyle}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
@@ -91,14 +109,14 @@ export default function DashboardScreen() {
|
||||
<View className="px-4">
|
||||
<SectionHeader title="Continue Watching" icon={IconPlayerPlay} />
|
||||
</View>
|
||||
<FlatList
|
||||
<FlashList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={continueWatching.data?.items ?? []}
|
||||
keyExtractor={(item) => item.title.id}
|
||||
renderItem={({ item }) => <ContinueWatchingCard item={item} />}
|
||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={renderContinueWatchingItem}
|
||||
contentContainerStyle={continueWatchingContentStyle}
|
||||
style={horizontalListStyle}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
skipToken,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
} from "@tanstack/react-query";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import Animated, { FadeIn } from "react-native-reanimated";
|
||||
import { RecentlyViewedList } from "@/components/search/recently-viewed-list";
|
||||
import {
|
||||
@@ -22,9 +26,16 @@ export default function SearchScreen() {
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedQuery = useDebounce(query.trim(), 300);
|
||||
|
||||
const searchResults = useQuery({
|
||||
...orpc.search.queryOptions({ input: { query: debouncedQuery } }),
|
||||
enabled: debouncedQuery.length > 0,
|
||||
const searchResults = useInfiniteQuery({
|
||||
...orpc.search.infiniteOptions({
|
||||
input:
|
||||
debouncedQuery.length > 0
|
||||
? (pageParam: number) => ({ query: debouncedQuery, page: pageParam })
|
||||
: skipToken,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
// Track which item is currently being resolved/added
|
||||
@@ -98,15 +109,17 @@ export default function SearchScreen() {
|
||||
// Memoize mapped results to maintain stable references
|
||||
const allResults = useMemo<SearchResultItem[]>(
|
||||
() =>
|
||||
searchResults.data?.results?.map((r) => ({
|
||||
tmdbId: r.tmdbId,
|
||||
title: r.title,
|
||||
type: r.type,
|
||||
posterPath: r.posterPath,
|
||||
profilePath: r.profilePath,
|
||||
releaseDate: r.releaseDate,
|
||||
})) ?? [],
|
||||
[searchResults.data?.results],
|
||||
searchResults.data?.pages.flatMap((page) =>
|
||||
page.results.map((r) => ({
|
||||
tmdbId: r.tmdbId,
|
||||
title: r.title,
|
||||
type: r.type,
|
||||
posterPath: r.posterPath,
|
||||
profilePath: r.profilePath,
|
||||
releaseDate: r.releaseDate,
|
||||
})),
|
||||
) ?? [],
|
||||
[searchResults.data?.pages],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
@@ -161,6 +174,22 @@ export default function SearchScreen() {
|
||||
renderItem={renderItem}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
onEndReached={() => {
|
||||
if (
|
||||
searchResults.hasNextPage &&
|
||||
!searchResults.isFetchingNextPage
|
||||
) {
|
||||
searchResults.fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={
|
||||
searchResults.isFetchingNextPage ? (
|
||||
<View className="items-center py-4">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -6,11 +6,16 @@ import {
|
||||
IconMovie,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Pressable, useWindowDimensions, View } from "react-native";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
@@ -59,12 +64,35 @@ export default function PersonDetailScreen() {
|
||||
|
||||
const { handlePress, handleQuickAdd, addingKey } = usePosterActions();
|
||||
|
||||
const { data, isPending, isError } = useQuery(
|
||||
orpc.people.detail.queryOptions({ input: { id } }),
|
||||
const {
|
||||
data,
|
||||
isPending,
|
||||
isError,
|
||||
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 person = data?.person;
|
||||
const filmography = data?.filmography ?? [];
|
||||
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],
|
||||
);
|
||||
|
||||
const personName = person?.name;
|
||||
const personProfilePath = person?.profilePath ?? null;
|
||||
@@ -94,7 +122,7 @@ export default function PersonDetailScreen() {
|
||||
posterThumbHash={credit.posterThumbHash}
|
||||
releaseDate={credit.releaseDate ?? credit.firstAirDate}
|
||||
voteAverage={credit.voteAverage}
|
||||
userStatus={data?.userStatuses?.[credit.titleId] ?? null}
|
||||
userStatus={userStatuses[credit.titleId] ?? null}
|
||||
width={undefined}
|
||||
onPress={handlePress}
|
||||
onQuickAdd={handleQuickAdd}
|
||||
@@ -102,7 +130,7 @@ export default function PersonDetailScreen() {
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[columnWidth, data?.userStatuses, handlePress, handleQuickAdd, addingKey],
|
||||
[columnWidth, userStatuses, handlePress, handleQuickAdd, addingKey],
|
||||
);
|
||||
|
||||
if (isPending) {
|
||||
@@ -285,6 +313,19 @@ export default function PersonDetailScreen() {
|
||||
paddingHorizontal: FILMOGRAPHY_PADDING,
|
||||
}}
|
||||
ListHeaderComponent={listHeader}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={
|
||||
isFetchingNextPage ? (
|
||||
<View className="items-center py-4">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import {
|
||||
IconBrandAppstore,
|
||||
IconBrandGooglePlay,
|
||||
@@ -14,8 +15,8 @@ import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { FlatList, Platform, Pressable, ScrollView, View } from "react-native";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Platform, Pressable, ScrollView, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
@@ -40,6 +41,9 @@ import { queryClient } from "@/lib/query-client";
|
||||
import { addRecentlyViewed } from "@/lib/recently-viewed";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
const castListContentStyle = { gap: 12, paddingHorizontal: 16 };
|
||||
const castListStyle = { overflow: "visible" as const };
|
||||
|
||||
export default function TitleDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -175,6 +179,10 @@ export default function TitleDetailScreen() {
|
||||
})),
|
||||
[recommendations.data],
|
||||
);
|
||||
const renderCastItem = useCallback(
|
||||
({ item }: { item: (typeof cast)[number] }) => <CastCard person={item} />,
|
||||
[],
|
||||
);
|
||||
|
||||
const hydratedTitleId = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -597,14 +605,14 @@ export default function TitleDetailScreen() {
|
||||
iconColor={titleAccent}
|
||||
/>
|
||||
</View>
|
||||
<FlatList
|
||||
<FlashList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={cast}
|
||||
keyExtractor={(item, index) => `${item.id}-${index}`}
|
||||
renderItem={({ item }) => <CastCard person={item} />}
|
||||
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={renderCastItem}
|
||||
contentContainerStyle={castListContentStyle}
|
||||
style={castListStyle}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FlatList } from "react-native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
|
||||
import { usePosterActions } from "@/hooks/use-poster-actions";
|
||||
@@ -28,10 +29,34 @@ export function HorizontalPosterRow({
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const { handlePress, handleQuickAdd, addingKey } = usePosterActions();
|
||||
const keyExtractor = useCallback(
|
||||
(item: PosterRowItem) => item.id ?? `${item.tmdbId}-${item.type}`,
|
||||
[],
|
||||
);
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: PosterRowItem }) => (
|
||||
<PosterCard
|
||||
id={item.id}
|
||||
tmdbId={item.tmdbId}
|
||||
title={item.title}
|
||||
type={item.type as "movie" | "tv"}
|
||||
posterPath={item.posterPath}
|
||||
posterThumbHash={item.posterThumbHash}
|
||||
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||
voteAverage={item.voteAverage}
|
||||
userStatus={item.userStatus}
|
||||
episodeProgress={item.episodeProgress}
|
||||
onPress={handlePress}
|
||||
onQuickAdd={handleQuickAdd}
|
||||
isAdding={addingKey === `${item.tmdbId}-${item.type}`}
|
||||
/>
|
||||
),
|
||||
[addingKey, handlePress, handleQuickAdd],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<FlatList
|
||||
<FlashList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={[1, 2, 3, 4]}
|
||||
@@ -44,28 +69,12 @@ export function HorizontalPosterRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
<FlashList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id ?? `${item.tmdbId}-${item.type}`}
|
||||
renderItem={({ item }) => (
|
||||
<PosterCard
|
||||
id={item.id}
|
||||
tmdbId={item.tmdbId}
|
||||
title={item.title}
|
||||
type={item.type as "movie" | "tv"}
|
||||
posterPath={item.posterPath}
|
||||
posterThumbHash={item.posterThumbHash}
|
||||
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||
voteAverage={item.voteAverage}
|
||||
userStatus={item.userStatus}
|
||||
episodeProgress={item.episodeProgress}
|
||||
onPress={handlePress}
|
||||
onQuickAdd={handleQuickAdd}
|
||||
isAdding={addingKey === `${item.tmdbId}-${item.type}`}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={listContentStyle}
|
||||
style={listStyle}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Icon } from "@tabler/icons-react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import {
|
||||
@@ -42,23 +42,48 @@ export function FilterableTitleRow({
|
||||
}) {
|
||||
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
|
||||
|
||||
const discover = useQuery({
|
||||
...orpc.discover.queryOptions({
|
||||
input: { type: mediaType, genreId: selectedGenre ?? 0 },
|
||||
const discover = useInfiniteQuery({
|
||||
...orpc.discover.infiniteOptions({
|
||||
input:
|
||||
selectedGenre != null
|
||||
? (pageParam: number) => ({
|
||||
type: mediaType,
|
||||
genreId: selectedGenre,
|
||||
page: pageParam,
|
||||
})
|
||||
: skipToken,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
}),
|
||||
enabled: selectedGenre !== null,
|
||||
});
|
||||
|
||||
const rawItems =
|
||||
selectedGenre === null ? defaultItems : (discover.data?.items ?? []);
|
||||
const discoverItems = useMemo(
|
||||
() => discover.data?.pages.flatMap((p) => p.items) ?? [],
|
||||
[discover.data?.pages],
|
||||
);
|
||||
const discoverStatuses = useMemo(
|
||||
() =>
|
||||
Object.assign(
|
||||
{},
|
||||
...(discover.data?.pages.map((p) => p.userStatuses) ?? []),
|
||||
) as Record<string, TitleStatus>,
|
||||
[discover.data?.pages],
|
||||
);
|
||||
const discoverProgress = useMemo(
|
||||
() =>
|
||||
Object.assign(
|
||||
{},
|
||||
...(discover.data?.pages.map((p) => p.episodeProgress) ?? []),
|
||||
) as Record<string, { watched: number; total: number }>,
|
||||
[discover.data?.pages],
|
||||
);
|
||||
|
||||
const rawItems = selectedGenre === null ? defaultItems : discoverItems;
|
||||
const userStatuses =
|
||||
selectedGenre === null
|
||||
? defaultUserStatuses
|
||||
: (discover.data?.userStatuses ?? {});
|
||||
selectedGenre === null ? defaultUserStatuses : discoverStatuses;
|
||||
const episodeProgress =
|
||||
selectedGenre === null
|
||||
? defaultEpisodeProgress
|
||||
: (discover.data?.episodeProgress ?? {});
|
||||
selectedGenre === null ? defaultEpisodeProgress : discoverProgress;
|
||||
const showLoading =
|
||||
isLoading || (selectedGenre !== null && discover.isPending);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user