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"
? process.env.PUBLIC_BACK_URL
: typeof window === "undefined"
? process.env.KYOO_URL ?? "http://localhost:5000"
: "/api";
? process.env.KYOO_URL ?? "http://localhost:5000"
: "/api";
export let kyooApiUrl: string | null = kyooUrl || null;
export const setApiUrl = (apiUrl: string) => {
kyooApiUrl = apiUrl;
}
};
export const queryFn = async <Data,>(
context:
| QueryFunctionContext
| {
path: (string | false | undefined | null)[];
body?: object;
method: "GET" | "POST" | "DELETE";
authenticated?: boolean;
apiUrl?: string;
abortSignal?: AbortSignal;
},
path: (string | false | undefined | null)[];
body?: object;
method: "GET" | "POST" | "DELETE";
authenticated?: boolean;
apiUrl?: string;
abortSignal?: AbortSignal;
},
type?: z.ZodType<Data>,
token?: string | null,
): Promise<Data> => {
@ -72,8 +72,8 @@ export const queryFn = async <Data,>(
"path" in context
? context.path.filter((x) => x)
: context.pageParam
? [context.pageParam]
: (context.queryKey.filter((x, i) => x && i) as string[]),
? [context.pageParam]
: (context.queryKey.filter((x, i) => x && i) as string[]),
)
.join("/")
.replace("/?", "?");
@ -105,7 +105,13 @@ export const queryFn = async <Data,>(
} catch (e) {
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;
}
@ -124,7 +130,11 @@ export const queryFn = async <Data,>(
const parsed = await type.safeParseAsync(data);
if (!parsed.success) {
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;
};
@ -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>;
path: (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.
*/
@ -159,7 +169,7 @@ export type QueryPage<Props = {}> = ComponentType<Props> & {
| { 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] : [""];
if (query.params) {
@ -184,8 +194,8 @@ export const useFetch = <Data,>(query: QueryIdentifier<Data>) => {
});
};
export const useInfiniteFetch = <Data,>(
query: QueryIdentifier<Data>,
export const useInfiniteFetch = <Data, Ret>(
query: QueryIdentifier<Data, Ret>,
options?: Partial<UseInfiniteQueryOptions<Data[], KyooErrors>>,
) => {
if (query.getNext) {
@ -196,7 +206,7 @@ export const useInfiniteFetch = <Data,>(
getNextPageParam: query.getNext,
...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
const ret = useInfiniteQuery<Page<Data>, KyooErrors>({
@ -204,7 +214,14 @@ export const useInfiniteFetch = <Data,>(
queryFn: (ctx) => queryFn(ctx, Paged(query.parser)),
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) => {

View File

@ -18,14 +18,83 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { Episode, EpisodeP, QueryIdentifier } from "@kyoo/models";
import { Container } from "@kyoo/primitives";
import { Stylable } from "yoshiki/native";
import {
Episode,
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 { InfiniteFetch } from "../fetch-infinite";
import { episodeDisplayNumber, EpisodeLine } from "./episode";
import { useTranslation } from "react-i18next";
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,>({
slug,
@ -39,6 +108,9 @@ export const EpisodeList = <Props,>({
headerProps: Props;
}) => {
const { t } = useTranslation();
const { items: seasons, error } = useInfiniteFetch(SeasonHeader.query(slug));
if (error) console.error("Could not fetch seasons", error);
return (
<InfiniteFetch
@ -50,53 +122,48 @@ export const EpisodeList = <Props,>({
Header={Header}
headerProps={headerProps}
>
{(item) => (
<EpisodeLine
{...item}
displayNumber={item.isLoading ? undefined! : episodeDisplayNumber(item)!}
/>
)}
{(item) => {
const sea = item ? seasons?.find((x) => x.seasonNumber === item.seasonNumber) : null;
return (
<>
{item.firstOfSeason && (
<SeasonHeader
isLoading={!sea}
name={sea?.name}
seasonNumber={sea?.seasonNumber}
seasons={seasons}
slug={slug}
/>
)}
<EpisodeLine
{...item}
displayNumber={item.isLoading ? undefined! : episodeDisplayNumber(item)!}
/>
</>
);
}}
</InfiniteFetch>
);
};
EpisodeList.query = (slug: string, season: string | number): QueryIdentifier<Episode> => ({
EpisodeList.query = (
slug: string,
season: string | number,
): QueryIdentifier<Episode, Episode & { firstOfSeason?: boolean }> => ({
parser: EpisodeP,
path: ["shows", slug, "episode"],
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 { percent, useYoshiki } from "yoshiki/native";
import { DefaultLayout } from "../layout";
import { EpisodeList } from "./season";
import { EpisodeList, SeasonHeader } from "./season";
import { Header } from "./header";
import Svg, { Path, SvgProps } from "react-native-svg";
import { Container } from "@kyoo/primitives";
@ -75,7 +75,6 @@ const ShowHeader = forwardRef<View, ViewProps & { slug: string }>(function ShowH
fill={theme.variant.background}
{...css({ flexShrink: 0, flexGrow: 1, display: "flex" })}
/>
{/* <SeasonTab slug={slug} season={season} /> */}
<View {...css({ bg: theme.variant.background })}>
<Container>{children}</Container>
</View>
@ -104,6 +103,7 @@ ShowDetails.getFetchUrls = ({ slug, season }) => [
query(slug),
// ShowStaff.query(slug),
EpisodeList.query(slug, season),
SeasonHeader.query(slug),
];
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 { EmptyView, ErrorView, Layout, WithLoading } from "./fetch";
export const InfiniteFetch = <Data, Props>({
export const InfiniteFetch = <Data, Props, _>({
query,
placeholderCount = 15,
incremental = false,
@ -37,7 +37,7 @@ export const InfiniteFetch = <Data, Props>({
headerProps,
...props
}: {
query: QueryIdentifier<Data>;
query: QueryIdentifier<_, Data>;
placeholderCount?: number;
layout: Layout;
horizontal?: boolean;
@ -49,7 +49,7 @@ export const InfiniteFetch = <Data, Props>({
incremental?: boolean;
divider?: boolean | ComponentType;
Header?: ComponentType<Props & { children: JSX.Element }> | ReactElement;
headerProps?: Props
headerProps?: Props;
}): JSX.Element | null => {
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} />;
}
if (incremental)
items ??= oldItems.current;
if (incremental) items ??= oldItems.current;
const count = items ? numColumns - (items.length % numColumns) : placeholderCount;
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
if (headerProps && !isValidElement(Header)) Header = <Header {...headerProps} />
if (headerProps && !isValidElement(Header)) Header = <Header {...headerProps} />;
return (
<FlashList
renderItem={({ item, index }) => children({ isLoading: false, ...item } as any, index)}

View File

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

View File

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

View File

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