mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Add season header
This commit is contained in:
parent
3b84161ec5
commit
67deef897f
@ -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) => {
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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 } };
|
||||
|
@ -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)}
|
||||
|
@ -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;
|
||||
|
@ -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}}",
|
||||
|
@ -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}}",
|
||||
|
Loading…
x
Reference in New Issue
Block a user