diff --git a/front/packages/models/src/query.tsx b/front/packages/models/src/query.tsx index 0773907b..78e7f3c7 100644 --- a/front/packages/models/src/query.tsx +++ b/front/packages/models/src/query.tsx @@ -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 ( 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, token?: string | null, ): Promise => { @@ -72,8 +72,8 @@ export const queryFn = async ( "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 ( } 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 ( 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 = { +export type QueryIdentifier = { parser: z.ZodType; 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 = ComponentType & { | { Layout: QueryPage<{ page: ReactElement }>; props: object }; }; -const toQueryKey = (query: QueryIdentifier) => { +const toQueryKey = (query: QueryIdentifier) => { const prefix = Platform.OS !== "web" ? [kyooApiUrl] : [""]; if (query.params) { @@ -184,8 +194,8 @@ export const useFetch = (query: QueryIdentifier) => { }); }; -export const useInfiniteFetch = ( - query: QueryIdentifier, +export const useInfiniteFetch = ( + query: QueryIdentifier, options?: Partial>, ) => { if (query.getNext) { @@ -196,7 +206,7 @@ export const useInfiniteFetch = ( 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, KyooErrors>({ @@ -204,7 +214,14 @@ export const useInfiniteFetch = ( queryFn: (ctx) => queryFn(ctx, Paged(query.parser)), getNextPageParam: (page: Page) => 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) => { diff --git a/front/packages/ui/src/details/season.tsx b/front/packages/ui/src/details/season.tsx index fd3da62d..616ff5a5 100644 --- a/front/packages/ui/src/details/season.tsx +++ b/front/packages/ui/src/details/season.tsx @@ -18,14 +18,83 @@ * along with Kyoo. If not, see . */ -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 ( + + +

+ {isLoading ? : seasonNumber} +

+
+ {isLoading ? : name} +
+ + {seasons?.map((x) => ( + + ))} + +
+
+
+ ); +}; + +SeasonHeader.query = (slug: string): QueryIdentifier => ({ + 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 = ({ slug, @@ -39,6 +108,9 @@ export const EpisodeList = ({ headerProps: Props; }) => { const { t } = useTranslation(); + const { items: seasons, error } = useInfiniteFetch(SeasonHeader.query(slug)); + + if (error) console.error("Could not fetch seasons", error); return ( ({ Header={Header} headerProps={headerProps} > - {(item) => ( - - )} + {(item) => { + const sea = item ? seasons?.find((x) => x.seasonNumber === item.seasonNumber) : null; + return ( + <> + {item.firstOfSeason && ( + + )} + + + ); + }} ); }; -EpisodeList.query = (slug: string, season: string | number): QueryIdentifier => ({ +EpisodeList.query = ( + slug: string, + season: string | number, +): QueryIdentifier => ({ 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 ( - - - {/* setSeason(i)} aria-label="List of seasons"> */} - {/* {seasons */} - {/* ? seasons.map((x) => ( */} - {/* */} - {/* )) */} - {/* : [...Array(3)].map((_, i) => ( */} - {/* } value={i + 1} disabled /> */} - {/* ))} */} - {/* */} - - - ); -}; diff --git a/front/packages/ui/src/details/show.tsx b/front/packages/ui/src/details/show.tsx index bc8508d1..d7fc7c6f 100644 --- a/front/packages/ui/src/details/show.tsx +++ b/front/packages/ui/src/details/show.tsx @@ -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(function ShowH fill={theme.variant.background} {...css({ flexShrink: 0, flexGrow: 1, display: "flex" })} /> - {/* */} {children} @@ -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 } }; diff --git a/front/packages/ui/src/fetch-infinite.tsx b/front/packages/ui/src/fetch-infinite.tsx index f559dbb6..be4a94e8 100644 --- a/front/packages/ui/src/fetch-infinite.tsx +++ b/front/packages/ui/src/fetch-infinite.tsx @@ -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 = ({ +export const InfiniteFetch = ({ query, placeholderCount = 15, incremental = false, @@ -37,7 +37,7 @@ export const InfiniteFetch = ({ headerProps, ...props }: { - query: QueryIdentifier; + query: QueryIdentifier<_, Data>; placeholderCount?: number; layout: Layout; horizontal?: boolean; @@ -49,7 +49,7 @@ export const InfiniteFetch = ({ incremental?: boolean; divider?: boolean | ComponentType; Header?: ComponentType | 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 = ({ return ; } - 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 =
+ if (headerProps && !isValidElement(Header)) Header =
; return ( children({ isLoading: false, ...item } as any, index)} diff --git a/front/packages/ui/src/fetch-infinite.web.tsx b/front/packages/ui/src/fetch-infinite.web.tsx index 26f555d2..5f352885 100644 --- a/front/packages/ui/src/fetch-infinite.web.tsx +++ b/front/packages/ui/src/fetch-infinite.web.tsx @@ -66,7 +66,8 @@ const InfiniteScroll = ({ { display: "flex", alignItems: "flex-start", - overflow: "auto", + overflowX: "hidden", + overflowY: "auto", }, layout == "vertical" && { flexDirection: "column", @@ -101,7 +102,7 @@ const InfiniteScroll = ({ ); }; -export const InfiniteFetch = ({ +export const InfiniteFetch = ({ query, incremental = false, placeholderCount = 15, @@ -113,7 +114,7 @@ export const InfiniteFetch = ({ Header, ...props }: { - query: QueryIdentifier; + query: QueryIdentifier<_, Data>; incremental?: boolean; placeholderCount?: number; layout: Layout; diff --git a/front/translations/en.json b/front/translations/en.json index f420c695..170e3511 100644 --- a/front/translations/en.json +++ b/front/translations/en.json @@ -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}}", diff --git a/front/translations/fr.json b/front/translations/fr.json index dd87d5df..c480a589 100644 --- a/front/translations/fr.json +++ b/front/translations/fr.json @@ -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}}",