From f48b26076dea87de075a82ab8937db7e2c633ffd Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 6 Jan 2025 17:33:27 +0100 Subject: [PATCH] feat: loading skeleton for search (including jellyseerr) --- app/(auth)/(tabs)/(search)/index.tsx | 246 +++--------------- components/jellyseerr/JellyseerrIndexPage.tsx | 172 ++++++++++++ components/search/LoadingSkeleton.tsx | 66 +++++ components/search/SearchItemWrapper.tsx | 70 +++++ 4 files changed, 351 insertions(+), 203 deletions(-) create mode 100644 components/jellyseerr/JellyseerrIndexPage.tsx create mode 100644 components/search/LoadingSkeleton.tsx create mode 100644 components/search/SearchItemWrapper.tsx diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 1ae0059c..2a7d6a57 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -43,6 +43,9 @@ import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide"; import { sortBy } from "lodash"; import PersonPoster from "@/components/jellyseerr/PersonPoster"; import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery"; +import { SearchItemWrapper } from "@/components/search/SearchItemWrapper"; +import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage"; +import { LoadingSkeleton } from "@/components/search/LoadingSkeleton"; type SearchType = "Library" | "Discover"; @@ -155,57 +158,6 @@ export default function search() { enabled: searchType === "Library" && debouncedSearch.length > 0, }); - const { data: jellyseerrResults, isFetching: j1 } = useReactNavigationQuery({ - queryKey: ["search", "jellyseerr", "results", debouncedSearch], - queryFn: async () => { - const response = await jellyseerrApi?.search({ - query: new URLSearchParams(debouncedSearch).toString(), - page: 1, // todo: maybe rework page & page-size if first results are not enough... - language: "en", - }); - - return response?.results; - }, - enabled: - !!jellyseerrApi && - searchType === "Discover" && - debouncedSearch.length > 0, - }); - - const { data: jellyseerrDiscoverSettings, isFetching: j2 } = - useReactNavigationQuery({ - queryKey: ["search", "jellyseerr", "discoverSettings", debouncedSearch], - queryFn: async () => jellyseerrApi?.discoverSettings(), - enabled: - !!jellyseerrApi && - searchType === "Discover" && - debouncedSearch.length == 0, - }); - - const jellyseerrMovieResults: MovieResult[] | undefined = useMemo( - () => - jellyseerrResults?.filter( - (r) => r.mediaType === MediaType.MOVIE - ) as MovieResult[], - [jellyseerrResults] - ); - - const jellyseerrTvResults: TvResult[] | undefined = useMemo( - () => - jellyseerrResults?.filter( - (r) => r.mediaType === MediaType.TV - ) as TvResult[], - [jellyseerrResults] - ); - - const jellyseerrPersonResults: PersonResult[] | undefined = useMemo( - () => - jellyseerrResults?.filter( - (r) => r.mediaType === "person" - ) as PersonResult[], - [jellyseerrResults] - ); - const { data: series, isFetching: l2 } = useQuery({ queryKey: ["search", "series", debouncedSearch], queryFn: () => @@ -285,25 +237,13 @@ export default function search() { episodes?.length || series?.length || collections?.length || - actors?.length || - jellyseerrMovieResults?.length || - jellyseerrTvResults?.length + actors?.length ); - }, [ - artists, - episodes, - albums, - songs, - movies, - series, - collections, - actors, - jellyseerrResults, - ]); + }, [artists, episodes, albums, songs, movies, series, collections, actors]); const loading = useMemo(() => { - return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2; - }, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]); + return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8; + }, [l1, l2, l3, l4, l5, l6, l7, l8]); return ( <> @@ -350,15 +290,13 @@ export default function search() { )} - {!!q && ( - - - Results for {q} - - - )} - {searchType === "Library" && ( - <> + + + + + + {searchType === "Library" ? ( + m.Id!)} @@ -483,139 +421,41 @@ export default function search() { )} /> - - )} - {searchType === "Discover" && ( + + ) : ( <> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> + )} - {loading ? ( - - - - ) : noResults && debouncedSearch.length > 0 ? ( - - - No results found for - - - "{debouncedSearch}" - - - ) : debouncedSearch.length === 0 && searchType === "Library" ? ( - - {exampleSearches.map((e) => ( - setSearch(e)} - key={e} - className="mb-2" - > - {e} - - ))} - - ) : debouncedSearch.length === 0 && searchType === "Discover" ? ( - - {sortBy?.( - jellyseerrDiscoverSettings?.filter((s) => s.enabled), - "order" - ).map((slide) => ( - - ))} - - ) : null} + {searchType === "Library" && ( + <> + {!loading && noResults && debouncedSearch.length > 0 ? ( + + + No results found for + + + "{debouncedSearch}" + + + ) : debouncedSearch.length === 0 ? ( + + {exampleSearches.map((e) => ( + setSearch(e)} + key={e} + className="mb-2" + > + {e} + + ))} + + ) : null} + + )} ); } - -type Props = { - ids?: string[] | null; - items?: T[]; - renderItem: (item: any) => React.ReactNode; - header?: string; -}; - -const SearchItemWrapper = ({ - ids, - items, - renderItem, - header, -}: PropsWithChildren>) => { - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - - const { data, isLoading: l1 } = useQuery({ - queryKey: ["items", ids], - queryFn: async () => { - if (!user?.Id || !api || !ids || ids.length === 0) { - return []; - } - - const itemPromises = ids.map((id) => - getUserItemData({ - api, - userId: user.Id, - itemId: id, - }) - ); - - const results = await Promise.all(itemPromises); - - // Filter out null items - return results.filter( - (item) => item !== null - ) as unknown as BaseItemDto[]; - }, - enabled: !!ids && ids.length > 0 && !!api && !!user?.Id, - staleTime: Infinity, - }); - - if (!data && (!items || items.length === 0)) return null; - - return ( - <> - {header} - - {data && data?.length > 0 - ? data.map((item) => renderItem(item)) - : items && items?.length > 0 - ? items.map((i) => renderItem(i)) - : undefined} - - - ); -}; diff --git a/components/jellyseerr/JellyseerrIndexPage.tsx b/components/jellyseerr/JellyseerrIndexPage.tsx new file mode 100644 index 00000000..f85b9807 --- /dev/null +++ b/components/jellyseerr/JellyseerrIndexPage.tsx @@ -0,0 +1,172 @@ +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; +import { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import { + MovieResult, + PersonResult, + TvResult, +} from "@/utils/jellyseerr/server/models/Search"; +import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { useQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import { PropsWithChildren, useMemo } from "react"; +import { ScrollView, View, ViewProps } from "react-native"; +import { Text } from "../common/Text"; +import JellyseerrPoster from "../posters/JellyseerrPoster"; +import PersonPoster from "./PersonPoster"; +import { SearchItemWrapper } from "../search/SearchItemWrapper"; +import DiscoverSlide from "./DiscoverSlide"; +import { sortBy } from "lodash"; +import { Loader } from "../Loader"; +import Animated, { + withTiming, + useAnimatedStyle, + withSequence, + useSharedValue, + useAnimatedReaction, +} from "react-native-reanimated"; +import { LoadingSkeleton } from "../search/LoadingSkeleton"; + +interface Props extends ViewProps { + searchQuery: string; +} + +export const JellyserrIndexPage: React.FC = ({ searchQuery }) => { + const { jellyseerrApi } = useJellyseerr(); + const opacity = useSharedValue(1); + + const { + data: jellyseerrDiscoverSettings, + isFetching: f1, + isLoading: l1, + } = useReactNavigationQuery({ + queryKey: ["search", "jellyseerr", "discoverSettings", searchQuery], + queryFn: async () => jellyseerrApi?.discoverSettings(), + enabled: !!jellyseerrApi && searchQuery.length == 0, + }); + + const { + data: jellyseerrResults, + isFetching: f2, + isLoading: l2, + } = useReactNavigationQuery({ + queryKey: ["search", "jellyseerr", "results", searchQuery], + queryFn: async () => { + const response = await jellyseerrApi?.search({ + query: new URLSearchParams(searchQuery).toString(), + page: 1, + language: "en", + }); + return response?.results; + }, + enabled: !!jellyseerrApi && searchQuery.length > 0, + }); + + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: opacity.value, + }; + }); + + useAnimatedReaction( + () => f1 || f2 || l1 || l2, + (isLoading) => { + if (isLoading) { + opacity.value = withTiming(1, { duration: 200 }); + } else { + opacity.value = withTiming(0, { duration: 200 }); + } + } + ); + + const jellyseerrMovieResults = useMemo( + () => + jellyseerrResults?.filter( + (r) => r.mediaType === MediaType.MOVIE + ) as MovieResult[], + [jellyseerrResults] + ); + + const jellyseerrTvResults = useMemo( + () => + jellyseerrResults?.filter( + (r) => r.mediaType === MediaType.TV + ) as TvResult[], + [jellyseerrResults] + ); + + const jellyseerrPersonResults = useMemo( + () => + jellyseerrResults?.filter( + (r) => r.mediaType === "person" + ) as PersonResult[], + [jellyseerrResults] + ); + + if (!searchQuery.length) + return ( + + {sortBy?.( + jellyseerrDiscoverSettings?.filter((s) => s.enabled), + "order" + ).map((slide) => ( + + ))} + + ); + + return ( + + + + {!jellyseerrMovieResults?.length && + !jellyseerrTvResults?.length && + !jellyseerrPersonResults?.length && + !f1 && + !f2 && + !l1 && + !l2 && ( + + + No results found for + + + "{searchQuery}" + + + )} + + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + + ); +}; diff --git a/components/search/LoadingSkeleton.tsx b/components/search/LoadingSkeleton.tsx new file mode 100644 index 00000000..8ac38ada --- /dev/null +++ b/components/search/LoadingSkeleton.tsx @@ -0,0 +1,66 @@ +import { View } from "react-native"; +import { Text } from "../common/Text"; +import Animated, { + useAnimatedStyle, + useAnimatedReaction, + useSharedValue, + withTiming, +} from "react-native-reanimated"; + +interface Props { + isLoading: boolean; +} + +export const LoadingSkeleton: React.FC = ({ isLoading }) => { + const opacity = useSharedValue(1); + + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: opacity.value, + }; + }); + + useAnimatedReaction( + () => isLoading, + (loading) => { + if (loading) { + opacity.value = withTiming(1, { duration: 200 }); + } else { + opacity.value = withTiming(0, { duration: 200 }); + } + } + ); + + return ( + + {[1, 2, 3].map((s) => ( + + + + {[1, 2, 3].map((i) => ( + + + + + Nisi mollit voluptate amet. + + + + + Lorem ipsum + + + + ))} + + + ))} + + ); +}; diff --git a/components/search/SearchItemWrapper.tsx b/components/search/SearchItemWrapper.tsx new file mode 100644 index 00000000..45c3e341 --- /dev/null +++ b/components/search/SearchItemWrapper.tsx @@ -0,0 +1,70 @@ +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { useQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import { PropsWithChildren } from "react"; +import { ScrollView } from "react-native"; +import { Text } from "../common/Text"; + +type SearchItemWrapperProps = { + ids?: string[] | null; + items?: T[]; + renderItem: (item: any) => React.ReactNode; + header?: string; +}; + +export const SearchItemWrapper = ({ + ids, + items, + renderItem, + header, +}: PropsWithChildren>) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + const { data, isLoading: l1 } = useQuery({ + queryKey: ["items", ids], + queryFn: async () => { + if (!user?.Id || !api || !ids || ids.length === 0) { + return []; + } + + const itemPromises = ids.map((id) => + getUserItemData({ + api, + userId: user.Id, + itemId: id, + }) + ); + + const results = await Promise.all(itemPromises); + + // Filter out null items + return results.filter( + (item) => item !== null + ) as unknown as BaseItemDto[]; + }, + enabled: !!ids && ids.length > 0 && !!api && !!user?.Id, + staleTime: Infinity, + }); + + if (!data && (!items || items.length === 0)) return null; + + return ( + <> + {header} + + {data && data?.length > 0 + ? data.map((item) => renderItem(item)) + : items && items?.length > 0 + ? items.map((i) => renderItem(i)) + : undefined} + + + ); +};