mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user