mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-20 04:40:27 +01:00
feat: loading skeleton for search (including jellyseerr)
This commit is contained in:
@@ -43,6 +43,9 @@ import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
|
|||||||
import { sortBy } from "lodash";
|
import { sortBy } from "lodash";
|
||||||
import PersonPoster from "@/components/jellyseerr/PersonPoster";
|
import PersonPoster from "@/components/jellyseerr/PersonPoster";
|
||||||
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
|
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";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -155,57 +158,6 @@ export default function search() {
|
|||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
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({
|
const { data: series, isFetching: l2 } = useQuery({
|
||||||
queryKey: ["search", "series", debouncedSearch],
|
queryKey: ["search", "series", debouncedSearch],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -285,25 +237,13 @@ export default function search() {
|
|||||||
episodes?.length ||
|
episodes?.length ||
|
||||||
series?.length ||
|
series?.length ||
|
||||||
collections?.length ||
|
collections?.length ||
|
||||||
actors?.length ||
|
actors?.length
|
||||||
jellyseerrMovieResults?.length ||
|
|
||||||
jellyseerrTvResults?.length
|
|
||||||
);
|
);
|
||||||
}, [
|
}, [artists, episodes, albums, songs, movies, series, collections, actors]);
|
||||||
artists,
|
|
||||||
episodes,
|
|
||||||
albums,
|
|
||||||
songs,
|
|
||||||
movies,
|
|
||||||
series,
|
|
||||||
collections,
|
|
||||||
actors,
|
|
||||||
jellyseerrResults,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return 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, j1, j2]);
|
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -350,15 +290,13 @@ export default function search() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{!!q && (
|
|
||||||
<View className="px-4 flex flex-col space-y-2">
|
<View className="mt-2">
|
||||||
<Text className="text-neutral-500 ">
|
<LoadingSkeleton isLoading={loading} />
|
||||||
Results for <Text className="text-purple-600">{q}</Text>
|
</View>
|
||||||
</Text>
|
|
||||||
</View>
|
{searchType === "Library" ? (
|
||||||
)}
|
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||||
{searchType === "Library" && (
|
|
||||||
<>
|
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
header="Movies"
|
header="Movies"
|
||||||
ids={movies?.map((m) => m.Id!)}
|
ids={movies?.map((m) => m.Id!)}
|
||||||
@@ -483,139 +421,41 @@ export default function search() {
|
|||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</>
|
</View>
|
||||||
)}
|
) : (
|
||||||
{searchType === "Discover" && (
|
|
||||||
<>
|
<>
|
||||||
<SearchItemWrapper
|
<JellyserrIndexPage searchQuery={debouncedSearch} />
|
||||||
header="Request Movies"
|
|
||||||
items={jellyseerrMovieResults}
|
|
||||||
renderItem={(item: MovieResult) => (
|
|
||||||
<JellyseerrPoster item={item} key={item.id} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
header="Request Series"
|
|
||||||
items={jellyseerrTvResults}
|
|
||||||
renderItem={(item: TvResult) => (
|
|
||||||
<JellyseerrPoster item={item} key={item.id} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
header="Actors"
|
|
||||||
items={jellyseerrPersonResults}
|
|
||||||
renderItem={(item: PersonResult) => (
|
|
||||||
<PersonPoster
|
|
||||||
className="mr-2"
|
|
||||||
key={item.id}
|
|
||||||
id={item.id.toString()}
|
|
||||||
name={item.name}
|
|
||||||
posterPath={item.profilePath}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{searchType === "Library" && (
|
||||||
<View className="mt-4 flex justify-center items-center">
|
<>
|
||||||
<Loader />
|
{!loading && noResults && debouncedSearch.length > 0 ? (
|
||||||
</View>
|
<View>
|
||||||
) : noResults && debouncedSearch.length > 0 ? (
|
<Text className="text-center text-lg font-bold mt-4">
|
||||||
<View>
|
No results found for
|
||||||
<Text className="text-center text-lg font-bold mt-4">
|
</Text>
|
||||||
No results found for
|
<Text className="text-xs text-purple-600 text-center">
|
||||||
</Text>
|
"{debouncedSearch}"
|
||||||
<Text className="text-xs text-purple-600 text-center">
|
</Text>
|
||||||
"{debouncedSearch}"
|
</View>
|
||||||
</Text>
|
) : debouncedSearch.length === 0 ? (
|
||||||
</View>
|
<View className="mt-4 flex flex-col items-center space-y-2">
|
||||||
) : debouncedSearch.length === 0 && searchType === "Library" ? (
|
{exampleSearches.map((e) => (
|
||||||
<View className="mt-4 flex flex-col items-center space-y-2">
|
<TouchableOpacity
|
||||||
{exampleSearches.map((e) => (
|
onPress={() => setSearch(e)}
|
||||||
<TouchableOpacity
|
key={e}
|
||||||
onPress={() => setSearch(e)}
|
className="mb-2"
|
||||||
key={e}
|
>
|
||||||
className="mb-2"
|
<Text className="text-purple-600">{e}</Text>
|
||||||
>
|
</TouchableOpacity>
|
||||||
<Text className="text-purple-600">{e}</Text>
|
))}
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
))}
|
) : null}
|
||||||
</View>
|
</>
|
||||||
) : debouncedSearch.length === 0 && searchType === "Discover" ? (
|
)}
|
||||||
<View className="flex flex-col">
|
|
||||||
{sortBy?.(
|
|
||||||
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
|
|
||||||
"order"
|
|
||||||
).map((slide) => (
|
|
||||||
<DiscoverSlide key={slide.id} slide={slide} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props<T> = {
|
|
||||||
ids?: string[] | null;
|
|
||||||
items?: T[];
|
|
||||||
renderItem: (item: any) => React.ReactNode;
|
|
||||||
header?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SearchItemWrapper = <T extends unknown>({
|
|
||||||
ids,
|
|
||||||
items,
|
|
||||||
renderItem,
|
|
||||||
header,
|
|
||||||
}: PropsWithChildren<Props<T>>) => {
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
className="px-4 mb-2"
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{data && data?.length > 0
|
|
||||||
? data.map((item) => renderItem(item))
|
|
||||||
: items && items?.length > 0
|
|
||||||
? items.map((i) => renderItem(i))
|
|
||||||
: undefined}
|
|
||||||
</ScrollView>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
172
components/jellyseerr/JellyseerrIndexPage.tsx
Normal file
172
components/jellyseerr/JellyseerrIndexPage.tsx
Normal file
@@ -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<Props> = ({ 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 (
|
||||||
|
<View className="flex flex-col">
|
||||||
|
{sortBy?.(
|
||||||
|
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
|
||||||
|
"order"
|
||||||
|
).map((slide) => (
|
||||||
|
<DiscoverSlide key={slide.id} slide={slide} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<LoadingSkeleton isLoading={f1 || f2 || l1 || l2} />
|
||||||
|
|
||||||
|
{!jellyseerrMovieResults?.length &&
|
||||||
|
!jellyseerrTvResults?.length &&
|
||||||
|
!jellyseerrPersonResults?.length &&
|
||||||
|
!f1 &&
|
||||||
|
!f2 &&
|
||||||
|
!l1 &&
|
||||||
|
!l2 && (
|
||||||
|
<View>
|
||||||
|
<Text className="text-center text-lg font-bold mt-4">
|
||||||
|
No results found for
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-purple-600 text-center">
|
||||||
|
"{searchQuery}"
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||||
|
<SearchItemWrapper
|
||||||
|
header="Request Movies"
|
||||||
|
items={jellyseerrMovieResults}
|
||||||
|
renderItem={(item: MovieResult) => (
|
||||||
|
<JellyseerrPoster item={item} key={item.id} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
header="Request Series"
|
||||||
|
items={jellyseerrTvResults}
|
||||||
|
renderItem={(item: TvResult) => (
|
||||||
|
<JellyseerrPoster item={item} key={item.id} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
header="Actors"
|
||||||
|
items={jellyseerrPersonResults}
|
||||||
|
renderItem={(item: PersonResult) => (
|
||||||
|
<PersonPoster
|
||||||
|
className="mr-2"
|
||||||
|
key={item.id}
|
||||||
|
id={item.id.toString()}
|
||||||
|
name={item.name}
|
||||||
|
posterPath={item.profilePath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
components/search/LoadingSkeleton.tsx
Normal file
66
components/search/LoadingSkeleton.tsx
Normal file
@@ -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<Props> = ({ 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 (
|
||||||
|
<Animated.View style={animatedStyle} className="mt-2 absolute w-full">
|
||||||
|
{[1, 2, 3].map((s) => (
|
||||||
|
<View className="px-4 mb-4" key={s}>
|
||||||
|
<View className="w-1/2 bg-neutral-900 h-6 mb-2 rounded-lg"></View>
|
||||||
|
<View className="flex flex-row gap-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<View className="w-28" key={i}>
|
||||||
|
<View className="bg-neutral-900 h-40 w-full rounded-md mb-1"></View>
|
||||||
|
<View className="rounded-md overflow-hidden mb-1 self-start">
|
||||||
|
<Text
|
||||||
|
className="text-neutral-900 bg-neutral-900 rounded-md"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
Nisi mollit voluptate amet.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="rounded-md overflow-hidden self-start mb-1">
|
||||||
|
<Text
|
||||||
|
className="text-neutral-900 bg-neutral-900 text-xs rounded-md"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
Lorem ipsum
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
70
components/search/SearchItemWrapper.tsx
Normal file
70
components/search/SearchItemWrapper.tsx
Normal file
@@ -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<T> = {
|
||||||
|
ids?: string[] | null;
|
||||||
|
items?: T[];
|
||||||
|
renderItem: (item: any) => React.ReactNode;
|
||||||
|
header?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SearchItemWrapper = <T extends unknown>({
|
||||||
|
ids,
|
||||||
|
items,
|
||||||
|
renderItem,
|
||||||
|
header,
|
||||||
|
}: PropsWithChildren<SearchItemWrapperProps<T>>) => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
className="px-4 mb-2"
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{data && data?.length > 0
|
||||||
|
? data.map((item) => renderItem(item))
|
||||||
|
: items && items?.length > 0
|
||||||
|
? items.map((i) => renderItem(i))
|
||||||
|
: undefined}
|
||||||
|
</ScrollView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user