Add season header

This commit is contained in:
Zoe Roux 2023-09-09 18:08:29 +02:00
parent 3b84161ec5
commit 67deef897f
7 changed files with 165 additions and 79 deletions

View File

@ -37,26 +37,26 @@ const kyooUrl =
Platform.OS !== "web" Platform.OS !== "web"
? process.env.PUBLIC_BACK_URL ? process.env.PUBLIC_BACK_URL
: typeof window === "undefined" : typeof window === "undefined"
? process.env.KYOO_URL ?? "http://localhost:5000" ? process.env.KYOO_URL ?? "http://localhost:5000"
: "/api"; : "/api";
export let kyooApiUrl: string | null = kyooUrl || null; export let kyooApiUrl: string | null = kyooUrl || null;
export const setApiUrl = (apiUrl: string) => { export const setApiUrl = (apiUrl: string) => {
kyooApiUrl = apiUrl; kyooApiUrl = apiUrl;
} };
export const queryFn = async <Data,>( export const queryFn = async <Data,>(
context: context:
| QueryFunctionContext | QueryFunctionContext
| { | {
path: (string | false | undefined | null)[]; path: (string | false | undefined | null)[];
body?: object; body?: object;
method: "GET" | "POST" | "DELETE"; method: "GET" | "POST" | "DELETE";
authenticated?: boolean; authenticated?: boolean;
apiUrl?: string; apiUrl?: string;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
}, },
type?: z.ZodType<Data>, type?: z.ZodType<Data>,
token?: string | null, token?: string | null,
): Promise<Data> => { ): Promise<Data> => {
@ -72,8 +72,8 @@ export const queryFn = async <Data,>(
"path" in context "path" in context
? context.path.filter((x) => x) ? context.path.filter((x) => x)
: context.pageParam : context.pageParam
? [context.pageParam] ? [context.pageParam]
: (context.queryKey.filter((x, i) => x && i) as string[]), : (context.queryKey.filter((x, i) => x && i) as string[]),
) )
.join("/") .join("/")
.replace("/?", "?"); .replace("/?", "?");
@ -105,7 +105,13 @@ export const queryFn = async <Data,>(
} catch (e) { } catch (e) {
data = { errors: [error] } as KyooErrors; data = { errors: [error] } as KyooErrors;
} }
console.log(`Invalid response (${"method" in context && context.method ? context.method : "GET"} ${path}):`, data, resp.status); console.log(
`Invalid response (${
"method" in context && context.method ? context.method : "GET"
} ${path}):`,
data,
resp.status,
);
throw data as KyooErrors; throw data as KyooErrors;
} }
@ -124,7 +130,11 @@ export const queryFn = async <Data,>(
const parsed = await type.safeParseAsync(data); const parsed = await type.safeParseAsync(data);
if (!parsed.success) { if (!parsed.success) {
console.log("Parse error: ", parsed.error); console.log("Parse error: ", parsed.error);
throw { errors: ["Invalid response from kyoo. Possible version mismatch between the server and the application."] } as KyooErrors; throw {
errors: [
"Invalid response from kyoo. Possible version mismatch between the server and the application.",
],
} as KyooErrors;
} }
return parsed.data; return parsed.data;
}; };
@ -141,11 +151,11 @@ export const createQueryClient = () =>
}, },
}); });
export type QueryIdentifier<T = unknown> = { export type QueryIdentifier<T = unknown, Ret = T> = {
parser: z.ZodType<T, z.ZodTypeDef, any>; parser: z.ZodType<T, z.ZodTypeDef, any>;
path: (string | undefined)[]; path: (string | undefined)[];
params?: { [query: string]: boolean | number | string | string[] | undefined }; params?: { [query: string]: boolean | number | string | string[] | undefined };
infinite?: boolean; infinite?: boolean | { value: true; map?: (x: T[]) => Ret[] };
/** /**
* A custom get next function if the infinite query is not a page. * A custom get next function if the infinite query is not a page.
*/ */
@ -159,7 +169,7 @@ export type QueryPage<Props = {}> = ComponentType<Props> & {
| { Layout: QueryPage<{ page: ReactElement }>; props: object }; | { Layout: QueryPage<{ page: ReactElement }>; props: object };
}; };
const toQueryKey = <Data,>(query: QueryIdentifier<Data>) => { const toQueryKey = <Data, Ret>(query: QueryIdentifier<Data, Ret>) => {
const prefix = Platform.OS !== "web" ? [kyooApiUrl] : [""]; const prefix = Platform.OS !== "web" ? [kyooApiUrl] : [""];
if (query.params) { if (query.params) {
@ -184,8 +194,8 @@ export const useFetch = <Data,>(query: QueryIdentifier<Data>) => {
}); });
}; };
export const useInfiniteFetch = <Data,>( export const useInfiniteFetch = <Data, Ret>(
query: QueryIdentifier<Data>, query: QueryIdentifier<Data, Ret>,
options?: Partial<UseInfiniteQueryOptions<Data[], KyooErrors>>, options?: Partial<UseInfiniteQueryOptions<Data[], KyooErrors>>,
) => { ) => {
if (query.getNext) { if (query.getNext) {
@ -196,7 +206,7 @@ export const useInfiniteFetch = <Data,>(
getNextPageParam: query.getNext, getNextPageParam: query.getNext,
...options, ...options,
}); });
return { ...ret, items: ret.data?.pages.flatMap((x) => x) }; return { ...ret, items: ret.data?.pages.flatMap((x) => x) as unknown as Ret[] | undefined };
} }
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const ret = useInfiniteQuery<Page<Data>, KyooErrors>({ const ret = useInfiniteQuery<Page<Data>, KyooErrors>({
@ -204,7 +214,14 @@ export const useInfiniteFetch = <Data,>(
queryFn: (ctx) => queryFn(ctx, Paged(query.parser)), queryFn: (ctx) => queryFn(ctx, Paged(query.parser)),
getNextPageParam: (page: Page<Data>) => page?.next || undefined, getNextPageParam: (page: Page<Data>) => page?.next || undefined,
}); });
return { ...ret, items: ret.data?.pages.flatMap((x) => x.items) }; const items = ret.data?.pages.flatMap((x) => x.items);
return {
...ret,
items:
items && typeof query.infinite === "object" && query.infinite.map
? query.infinite.map(items)
: (items as unknown as Ret[] | undefined),
};
}; };
export const fetchQuery = async (queries: QueryIdentifier[], authToken?: string | null) => { export const fetchQuery = async (queries: QueryIdentifier[], authToken?: string | null) => {

View File

@ -18,14 +18,83 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Episode, EpisodeP, QueryIdentifier } from "@kyoo/models"; import {
import { Container } from "@kyoo/primitives"; Episode,
import { Stylable } from "yoshiki/native"; EpisodeP,
QueryIdentifier,
Season,
SeasonP,
useInfiniteFetch,
} from "@kyoo/models";
import { Skeleton, H6, HR, P, ts, Menu, IconButton, tooltip } from "@kyoo/primitives";
import { rem, useYoshiki } from "yoshiki/native";
import { View } from "react-native"; import { View } from "react-native";
import { InfiniteFetch } from "../fetch-infinite"; import { InfiniteFetch } from "../fetch-infinite";
import { episodeDisplayNumber, EpisodeLine } from "./episode"; import { episodeDisplayNumber, EpisodeLine } from "./episode";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ComponentType } from "react"; import { ComponentType } from "react";
import MenuIcon from "@material-symbols/svg-400/rounded/menu-fill.svg";
export const SeasonHeader = ({
isLoading,
seasonNumber,
name,
seasons,
slug,
}: {
isLoading: boolean;
seasonNumber?: number;
name?: string;
seasons?: Season[];
slug: string;
}) => {
const { css } = useYoshiki();
const { t } = useTranslation();
return (
<View id={`season-${seasonNumber}`}>
<View {...css({ flexDirection: "row", marginX: ts(1) })}>
<P
{...css({
width: rem(4),
flexShrink: 0,
marginX: ts(1),
textAlign: "center",
fontSize: rem(1.5),
})}
>
{isLoading ? <Skeleton variant="filltext" /> : seasonNumber}
</P>
<H6
aria-level={2}
{...css({ marginX: ts(1), fontSize: rem(1.5), flexGrow: 1, flexShrink: 1 })}
>
{isLoading ? <Skeleton /> : name}
</H6>
<Menu Trigger={IconButton} icon={MenuIcon} {...tooltip(t("show.jumpToSeason"))}>
{seasons?.map((x) => (
<Menu.Item
key={x.seasonNumber}
label={`${x.seasonNumber}: ${x.name}`}
href={`/show/${slug}?season=${x.seasonNumber}`}
/>
))}
</Menu>
</View>
<HR />
</View>
);
};
SeasonHeader.query = (slug: string): QueryIdentifier<Season> => ({
parser: SeasonP,
path: ["shows", slug, "seasons"],
params: {
// Fetch all seasons at one, there won't be hundred of thems anyways.
limit: 0,
},
infinite: true,
});
export const EpisodeList = <Props,>({ export const EpisodeList = <Props,>({
slug, slug,
@ -39,6 +108,9 @@ export const EpisodeList = <Props,>({
headerProps: Props; headerProps: Props;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { items: seasons, error } = useInfiniteFetch(SeasonHeader.query(slug));
if (error) console.error("Could not fetch seasons", error);
return ( return (
<InfiniteFetch <InfiniteFetch
@ -50,53 +122,48 @@ export const EpisodeList = <Props,>({
Header={Header} Header={Header}
headerProps={headerProps} headerProps={headerProps}
> >
{(item) => ( {(item) => {
<EpisodeLine const sea = item ? seasons?.find((x) => x.seasonNumber === item.seasonNumber) : null;
{...item} return (
displayNumber={item.isLoading ? undefined! : episodeDisplayNumber(item)!} <>
/> {item.firstOfSeason && (
)} <SeasonHeader
isLoading={!sea}
name={sea?.name}
seasonNumber={sea?.seasonNumber}
seasons={seasons}
slug={slug}
/>
)}
<EpisodeLine
{...item}
displayNumber={item.isLoading ? undefined! : episodeDisplayNumber(item)!}
/>
</>
);
}}
</InfiniteFetch> </InfiniteFetch>
); );
}; };
EpisodeList.query = (slug: string, season: string | number): QueryIdentifier<Episode> => ({ EpisodeList.query = (
slug: string,
season: string | number,
): QueryIdentifier<Episode, Episode & { firstOfSeason?: boolean }> => ({
parser: EpisodeP, parser: EpisodeP,
path: ["shows", slug, "episode"], path: ["shows", slug, "episode"],
params: { params: {
seasonNumber: season, seasonNumber: season ? `gte:${season}` : undefined,
},
infinite: {
value: true,
map: (episodes) => {
let currentSeason: number | null = null;
return episodes.map((x) => {
if (x.seasonNumber === currentSeason) return x;
currentSeason = x.seasonNumber;
return { ...x, firstOfSeason: true };
});
},
}, },
infinite: true,
}); });
export const SeasonTab = ({
slug,
season,
...props
}: { slug: string; season: number | string } & Stylable) => {
// TODO: handle absolute number only shows (without seasons)
return null;
return (
<View>
<Container>
{/* <Tabs value={season} onChange={(_, i) => setSeason(i)} aria-label="List of seasons"> */}
{/* {seasons */}
{/* ? seasons.map((x) => ( */}
{/* <Tab */}
{/* key={x.seasonNumber} */}
{/* label={x.name} */}
{/* value={x.seasonNumber} */}
{/* component={Link} */}
{/* to={{ query: { ...router.query, season: x.seasonNumber } }} */}
{/* shallow */}
{/* replace */}
{/* /> */}
{/* )) */}
{/* : [...Array(3)].map((_, i) => ( */}
{/* <Tab key={i} label={<Skeleton width="5rem" />} value={i + 1} disabled /> */}
{/* ))} */}
{/* </Tabs> */}
</Container>
</View>
);
};

View File

@ -22,7 +22,7 @@ import { QueryIdentifier, QueryPage, Show, ShowP } from "@kyoo/models";
import { Platform, View, ViewProps } from "react-native"; import { Platform, View, ViewProps } from "react-native";
import { percent, useYoshiki } from "yoshiki/native"; import { percent, useYoshiki } from "yoshiki/native";
import { DefaultLayout } from "../layout"; import { DefaultLayout } from "../layout";
import { EpisodeList } from "./season"; import { EpisodeList, SeasonHeader } from "./season";
import { Header } from "./header"; import { Header } from "./header";
import Svg, { Path, SvgProps } from "react-native-svg"; import Svg, { Path, SvgProps } from "react-native-svg";
import { Container } from "@kyoo/primitives"; import { Container } from "@kyoo/primitives";
@ -75,7 +75,6 @@ const ShowHeader = forwardRef<View, ViewProps & { slug: string }>(function ShowH
fill={theme.variant.background} fill={theme.variant.background}
{...css({ flexShrink: 0, flexGrow: 1, display: "flex" })} {...css({ flexShrink: 0, flexGrow: 1, display: "flex" })}
/> />
{/* <SeasonTab slug={slug} season={season} /> */}
<View {...css({ bg: theme.variant.background })}> <View {...css({ bg: theme.variant.background })}>
<Container>{children}</Container> <Container>{children}</Container>
</View> </View>
@ -104,6 +103,7 @@ ShowDetails.getFetchUrls = ({ slug, season }) => [
query(slug), query(slug),
// ShowStaff.query(slug), // ShowStaff.query(slug),
EpisodeList.query(slug, season), EpisodeList.query(slug, season),
SeasonHeader.query(slug),
]; ];
ShowDetails.getLayout = { Layout: DefaultLayout, props: { transparent: true } }; ShowDetails.getLayout = { Layout: DefaultLayout, props: { transparent: true } };

View File

@ -24,7 +24,7 @@ import { FlashList } from "@shopify/flash-list";
import { ComponentType, isValidElement, ReactElement, useRef } from "react"; import { ComponentType, isValidElement, ReactElement, useRef } from "react";
import { EmptyView, ErrorView, Layout, WithLoading } from "./fetch"; import { EmptyView, ErrorView, Layout, WithLoading } from "./fetch";
export const InfiniteFetch = <Data, Props>({ export const InfiniteFetch = <Data, Props, _>({
query, query,
placeholderCount = 15, placeholderCount = 15,
incremental = false, incremental = false,
@ -37,7 +37,7 @@ export const InfiniteFetch = <Data, Props>({
headerProps, headerProps,
...props ...props
}: { }: {
query: QueryIdentifier<Data>; query: QueryIdentifier<_, Data>;
placeholderCount?: number; placeholderCount?: number;
layout: Layout; layout: Layout;
horizontal?: boolean; horizontal?: boolean;
@ -49,7 +49,7 @@ export const InfiniteFetch = <Data, Props>({
incremental?: boolean; incremental?: boolean;
divider?: boolean | ComponentType; divider?: boolean | ComponentType;
Header?: ComponentType<Props & { children: JSX.Element }> | ReactElement; Header?: ComponentType<Props & { children: JSX.Element }> | ReactElement;
headerProps?: Props headerProps?: Props;
}): JSX.Element | null => { }): JSX.Element | null => {
if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch."); if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch.");
@ -69,15 +69,14 @@ export const InfiniteFetch = <Data, Props>({
return <EmptyView message={empty} />; return <EmptyView message={empty} />;
} }
if (incremental) if (incremental) items ??= oldItems.current;
items ??= oldItems.current;
const count = items ? numColumns - (items.length % numColumns) : placeholderCount; const count = items ? numColumns - (items.length % numColumns) : placeholderCount;
const placeholders = [...Array(count === 0 ? numColumns : count)].map( const placeholders = [...Array(count === 0 ? numColumns : count)].map(
(_, i) => ({ id: `gen${i}`, isLoading: true } as Data), (_, i) => ({ id: `gen${i}`, isLoading: true }) as Data,
); );
// @ts-ignore // @ts-ignore
if (headerProps && !isValidElement(Header)) Header = <Header {...headerProps} /> if (headerProps && !isValidElement(Header)) Header = <Header {...headerProps} />;
return ( return (
<FlashList <FlashList
renderItem={({ item, index }) => children({ isLoading: false, ...item } as any, index)} renderItem={({ item, index }) => children({ isLoading: false, ...item } as any, index)}

View File

@ -66,7 +66,8 @@ const InfiniteScroll = <Props,>({
{ {
display: "flex", display: "flex",
alignItems: "flex-start", alignItems: "flex-start",
overflow: "auto", overflowX: "hidden",
overflowY: "auto",
}, },
layout == "vertical" && { layout == "vertical" && {
flexDirection: "column", flexDirection: "column",
@ -101,7 +102,7 @@ const InfiniteScroll = <Props,>({
); );
}; };
export const InfiniteFetch = <Data,>({ export const InfiniteFetch = <Data, _>({
query, query,
incremental = false, incremental = false,
placeholderCount = 15, placeholderCount = 15,
@ -113,7 +114,7 @@ export const InfiniteFetch = <Data,>({
Header, Header,
...props ...props
}: { }: {
query: QueryIdentifier<Data>; query: QueryIdentifier<_, Data>;
incremental?: boolean; incremental?: boolean;
placeholderCount?: number; placeholderCount?: number;
layout: Layout; layout: Layout;

View File

@ -10,7 +10,8 @@
"noOverview": "No overview available", "noOverview": "No overview available",
"episode-none": "There is no episodes in this season", "episode-none": "There is no episodes in this season",
"episodeNoMetadata": "No metadata available", "episodeNoMetadata": "No metadata available",
"tags": "Tags" "tags": "Tags",
"jumpToSeason": "Jump to season"
}, },
"browse": { "browse": {
"sortby": "Sort by {{key}}", "sortby": "Sort by {{key}}",

View File

@ -10,7 +10,8 @@
"noOverview": "Aucune description disponible", "noOverview": "Aucune description disponible",
"episode-none": "Il n'y a pas d'épisodes dans cette saison", "episode-none": "Il n'y a pas d'épisodes dans cette saison",
"episodeNoMetadata": "Aucune metadonnée disponible", "episodeNoMetadata": "Aucune metadonnée disponible",
"tags": "Tags" "tags": "Tags",
"jumpToSeason": "Aller sur une saison"
}, },
"browse": { "browse": {
"sortby": "Trier par {{key}}", "sortby": "Trier par {{key}}",