mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Add an episode list for shows
This commit is contained in:
parent
de06c7f81f
commit
1ee955fbfe
@ -18,24 +18,10 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { styled, experimental_sx as sx } from "@mui/system";
|
||||
import { ShowDetails } from "@kyoo/ui";
|
||||
import { withRoute } from "../../utils";
|
||||
|
||||
export const Container = styled("div")(
|
||||
sx({
|
||||
display: "flex",
|
||||
px: "15px",
|
||||
mx: "auto",
|
||||
width: {
|
||||
sm: "540px",
|
||||
md: "880px",
|
||||
lg: "1170px",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const containerPadding = {
|
||||
xs: "15px",
|
||||
sm: "calc((100vw - 540px) / 2)",
|
||||
md: "calc((100vw - 880px) / 2)",
|
||||
lg: "calc((100vw - 1170px) / 2)",
|
||||
};
|
||||
export default withRoute(ShowDetails, {
|
||||
options: { headerTransparent: true, headerStyle: { backgroundColor: "transparent" } },
|
||||
statusBar: { barStyle: "light-content" },
|
||||
});
|
@ -1,93 +0,0 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Box, Divider, Skeleton, SxProps, Typography } from "@mui/material";
|
||||
import useTranslation from "next-translate/useTranslation";
|
||||
import { Episode } from "~/models";
|
||||
import { Link } from "~/utils/link";
|
||||
import { Image } from "./poster";
|
||||
|
||||
export const episodeDisplayNumber = (
|
||||
episode: {
|
||||
seasonNumber?: number | null;
|
||||
episodeNumber?: number | null;
|
||||
absoluteNumber?: number | null;
|
||||
},
|
||||
def?: string,
|
||||
) => {
|
||||
if (typeof episode.seasonNumber === "number" && typeof episode.episodeNumber === "number")
|
||||
return `S${episode.seasonNumber}:E${episode.episodeNumber}`;
|
||||
if (episode.absoluteNumber) return episode.absoluteNumber.toString();
|
||||
return def;
|
||||
};
|
||||
|
||||
export const EpisodeBox = ({ episode, sx }: { episode?: Episode; sx: SxProps }) => {
|
||||
return (
|
||||
<Box sx={sx}>
|
||||
<Image img={episode?.thumbnail} alt="" width="100%" aspectRatio="16/9" />
|
||||
<Typography>{episode?.name ?? <Skeleton />}</Typography>
|
||||
<Typography variant="body2">{episode?.overview ?? <Skeleton />}</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const EpisodeLine = ({ episode, sx }: { episode?: Episode; sx?: SxProps }) => {
|
||||
const { t } = useTranslation("browse");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
href={episode ? `/watch/${episode.slug}` : ""}
|
||||
color="inherit"
|
||||
underline="none"
|
||||
sx={{
|
||||
m: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
"& > *": { m: 1 },
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" align="center" sx={{ width: "4rem", flexShrink: 0 }}>
|
||||
{episode ? episodeDisplayNumber(episode, "???") : <Skeleton />}
|
||||
</Typography>
|
||||
<Image
|
||||
img={episode?.thumbnail}
|
||||
alt=""
|
||||
width="18%"
|
||||
aspectRatio="16/9"
|
||||
sx={{ flexShrink: 0 }}
|
||||
/>
|
||||
{episode ? (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6">{episode.name ?? t("show.episodeNoMetadata")}</Typography>
|
||||
{episode.overview && <Typography variant="body2">{episode.overview}</Typography>}
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6">{<Skeleton />}</Typography>
|
||||
<Typography variant="body2">{<Skeleton />}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Link>
|
||||
<Divider />
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,72 +0,0 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Alert, Box, Snackbar, SnackbarCloseReason, Typography, SxProps } from "@mui/material";
|
||||
import { SyntheticEvent, useState } from "react";
|
||||
import { KyooErrors } from "~/models";
|
||||
|
||||
export const ErrorComponent = ({ errors, sx }: { errors: string[]; sx?: SxProps }) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
backgroundColor: "error.light",
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h1" component="h1" sx={{ fontWeight: 500 }}>
|
||||
Error
|
||||
</Typography>
|
||||
{errors?.map((x, i) => (
|
||||
<Typography variant="h2" component="h2" key={i}>
|
||||
{x}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const ErrorPage = ({ errors }: { errors: string[] }) => {
|
||||
return (
|
||||
<Box sx={{ height: "100vh" }}>
|
||||
<ErrorComponent errors={errors} sx={{ backgroundColor: "unset" }} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const ErrorSnackbar = ({ error }: { error: KyooErrors }) => {
|
||||
const [isOpen, setOpen] = useState(true);
|
||||
const close = (_: Event | SyntheticEvent, reason?: SnackbarCloseReason) => {
|
||||
if (reason !== "clickaway") setOpen(false);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<Snackbar open={isOpen} onClose={close} autoHideDuration={6000}>
|
||||
<Alert severity="error" onClose={close} sx={{ width: "100%" }}>
|
||||
{error.errors[0]}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
};
|
@ -18,127 +18,7 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Box, Skeleton, SxProps, Tab, Tabs, Typography } from "@mui/material";
|
||||
import useTranslation from "next-translate/useTranslation";
|
||||
import Head from "next/head";
|
||||
import { Episode, EpisodeP, Season, Show, ShowP } from "~/models";
|
||||
import { QueryIdentifier, QueryPage, useFetch, useInfiniteFetch } from "~/utils/query";
|
||||
import { ShowDetails } from "@kyoo/ui";
|
||||
import { withRoute } from "~/utils/router";
|
||||
import { Container } from "~/components/container";
|
||||
import { makeTitle } from "~/utils/utils";
|
||||
import { Link } from "~/utils/link";
|
||||
import { ErrorComponent, ErrorPage } from "~/components/errors";
|
||||
import { useState } from "react";
|
||||
import { EpisodeLine } from "~/components/episode";
|
||||
import InfiniteScroll from "react-infinite-scroll-component";
|
||||
import { useRouter } from "next/router";
|
||||
import { ShowHeader, ShowStaff } from "../movie/[slug]";
|
||||
import { Navbar } from "~/components/navbar";
|
||||
|
||||
const EpisodeGrid = ({ slug, season }: { slug: string; season: number }) => {
|
||||
const { items, isError, error, hasNextPage, fetchNextPage } = useInfiniteFetch(
|
||||
EpisodeGrid.query(slug, season),
|
||||
);
|
||||
const { t } = useTranslation("browse");
|
||||
|
||||
if (isError) return <ErrorComponent {...error} />;
|
||||
|
||||
if (items && items?.length === 0) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center" }}>
|
||||
<Typography sx={{ py: 3 }}>{t("show.episode-none")}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfiniteScroll
|
||||
dataLength={items?.length ?? 0}
|
||||
next={fetchNextPage}
|
||||
hasMore={hasNextPage!}
|
||||
loader={[...Array(12)].map((_, i) => (
|
||||
<EpisodeLine key={i} />
|
||||
))}
|
||||
>
|
||||
{(items ?? [...Array(12)]).map((x, i) => (
|
||||
<EpisodeLine key={x ? x.id : i} episode={x} />
|
||||
))}
|
||||
</InfiniteScroll>
|
||||
);
|
||||
};
|
||||
|
||||
EpisodeGrid.query = (slug: string, season: string | number): QueryIdentifier<Episode> => ({
|
||||
parser: EpisodeP,
|
||||
path: ["shows", slug, "episode"],
|
||||
params: {
|
||||
seasonNumber: season,
|
||||
},
|
||||
infinite: true,
|
||||
});
|
||||
|
||||
const SeasonTab = ({ slug, seasons, sx }: { slug: string; seasons?: Season[]; sx?: SxProps }) => {
|
||||
const router = useRouter();
|
||||
const seasonQuery = typeof router.query.season === "string" ? parseInt(router.query.season) : NaN;
|
||||
const [season, setSeason] = useState(isNaN(seasonQuery) ? 1 : seasonQuery);
|
||||
|
||||
// TODO: handle absolute number only shows (without seasons)
|
||||
return (
|
||||
<Container sx={sx}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: "divider", width: "100%" }}>
|
||||
<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>
|
||||
<EpisodeGrid slug={slug} season={season} />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const query = (slug: string): QueryIdentifier<Show> => ({
|
||||
parser: ShowP,
|
||||
path: ["shows", slug],
|
||||
params: {
|
||||
fields: ["genres", "studio", "seasons"],
|
||||
},
|
||||
});
|
||||
|
||||
const ShowDetails: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||
const { data, error } = useFetch(query(slug));
|
||||
|
||||
if (error) return <ErrorPage {...error} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{makeTitle(data?.name)}</title>
|
||||
<meta name="description" content={data?.overview!} />
|
||||
</Head>
|
||||
<ShowHeader data={data} />
|
||||
<ShowStaff slug={slug} />
|
||||
<SeasonTab slug={slug} seasons={data?.seasons} sx={{ pt: 3 }} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ShowDetails.getFetchUrls = ({ slug, season = 1 }) => [
|
||||
query(slug),
|
||||
ShowStaff.query(slug),
|
||||
EpisodeGrid.query(slug, season),
|
||||
Navbar.query(),
|
||||
];
|
||||
|
||||
export default withRoute(ShowDetails);
|
||||
|
@ -53,7 +53,7 @@ export const Skeleton = ({
|
||||
children?: JSX.Element | JSX.Element[] | boolean | null;
|
||||
show?: boolean;
|
||||
lines?: number;
|
||||
variant?: "text" | "header" | "round" | "custom";
|
||||
variant?: "text" | "header" | "round" | "custom" | "fill";
|
||||
}) => {
|
||||
const { css, theme } = useYoshiki();
|
||||
const [width, setWidth] = useState<number | undefined>(undefined);
|
||||
@ -87,6 +87,10 @@ export const Skeleton = ({
|
||||
variant === "round" && {
|
||||
borderRadius: 9999999,
|
||||
},
|
||||
variant === "fill" && {
|
||||
width: percent(100),
|
||||
height: percent(100),
|
||||
},
|
||||
],
|
||||
props,
|
||||
)}
|
||||
|
@ -41,7 +41,7 @@ export const catppuccin: ThemeBuilder = {
|
||||
subtext: "#6c6f85",
|
||||
},
|
||||
variant: {
|
||||
background: "#dc8a78",
|
||||
background: "#e6e9ef",
|
||||
accent: "#d20f39",
|
||||
divider: "#dd7878",
|
||||
heading: "#4c4f69",
|
||||
|
@ -93,7 +93,7 @@ export const BrowsePage: QueryPage<{ slug?: string }> = ({ slug }) => {
|
||||
placeholderCount={15}
|
||||
layout={LayoutComponent.layout}
|
||||
>
|
||||
{(item, key) => <LayoutComponent key={key} {...itemMap(item)} />}
|
||||
{(item) => <LayoutComponent {...itemMap(item)} />}
|
||||
</InfiniteFetch>
|
||||
</>
|
||||
);
|
||||
|
115
front/packages/ui/src/details/episode.tsx
Normal file
115
front/packages/ui/src/details/episode.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { H6, Image, Link, P, Skeleton, ts } from "@kyoo/primitives";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Layout, WithLoading } from "../fetch";
|
||||
import { percent, rem, Stylable, useYoshiki, vw } from "yoshiki/native";
|
||||
|
||||
export const episodeDisplayNumber = (
|
||||
episode: {
|
||||
seasonNumber?: number | null;
|
||||
episodeNumber?: number | null;
|
||||
absoluteNumber?: number | null;
|
||||
},
|
||||
def?: string,
|
||||
) => {
|
||||
if (typeof episode.seasonNumber === "number" && typeof episode.episodeNumber === "number")
|
||||
return `S${episode.seasonNumber}:E${episode.episodeNumber}`;
|
||||
if (episode.absoluteNumber) return episode.absoluteNumber.toString();
|
||||
return def;
|
||||
};
|
||||
|
||||
export const EpisodeBox = ({
|
||||
name,
|
||||
overview,
|
||||
thumbnail,
|
||||
isLoading,
|
||||
...props
|
||||
}: WithLoading<{
|
||||
name: string;
|
||||
overview: string;
|
||||
thumbnail?: string | null;
|
||||
}> &
|
||||
Stylable) => {
|
||||
return (
|
||||
<View {...props}>
|
||||
<Image src={thumbnail} alt="" layout={{ width: percent(100), aspectRatio: 16 / 9 }} />
|
||||
<Skeleton>{isLoading || <P>{name}</P>}</Skeleton>
|
||||
<Skeleton>{isLoading || <P>{overview}</P>}</Skeleton>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const EpisodeLine = ({
|
||||
slug,
|
||||
displayNumber,
|
||||
name,
|
||||
thumbnail,
|
||||
overview,
|
||||
isLoading,
|
||||
...props
|
||||
}: WithLoading<{
|
||||
slug: string;
|
||||
displayNumber: string;
|
||||
name: string;
|
||||
overview: string;
|
||||
thumbnail?: string | null;
|
||||
}> &
|
||||
Stylable) => {
|
||||
const { css } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={slug ? `/watch/${slug}` : ""}
|
||||
{...css(
|
||||
{
|
||||
m: ts(1),
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
},
|
||||
props,
|
||||
)}
|
||||
>
|
||||
<P {...css({ width: rem(4), flexShrink: 0, m: ts(1) })}>
|
||||
{isLoading ? <Skeleton variant="fill" /> : displayNumber}
|
||||
</P>
|
||||
<Image
|
||||
src={thumbnail}
|
||||
alt=""
|
||||
layout={{
|
||||
width: percent(18),
|
||||
aspectRatio: 16 / 9,
|
||||
}}
|
||||
{...css({ flexShrink: 0, m: ts(1) })}
|
||||
/>
|
||||
<View {...css({ flexGrow: 1, flexShrink: 1, m: ts(1) })}>
|
||||
<Skeleton>{isLoading || <H6 as="p">{name ?? t("show.episodeNoMetadata")}</H6>}</Skeleton>
|
||||
<Skeleton>{isLoading || <P numberOfLines={3}>{overview}</P>}</Skeleton>
|
||||
</View>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
EpisodeLine.layout = {
|
||||
numColumns: 1,
|
||||
size: 100, //vw(18) / (16 / 9) + ts(2),
|
||||
} satisfies Layout;
|
@ -224,6 +224,7 @@ const Description = ({
|
||||
<P
|
||||
{...css({
|
||||
display: { xs: "flex", sm: "none" },
|
||||
flexWrap: "wrap",
|
||||
color: (theme: Theme) => theme.user.paragraph,
|
||||
})}
|
||||
>
|
||||
|
@ -19,3 +19,4 @@
|
||||
*/
|
||||
|
||||
export { MovieDetails } from "./movie";
|
||||
export { ShowDetails } from "./show";
|
||||
|
103
front/packages/ui/src/details/season.tsx
Normal file
103
front/packages/ui/src/details/season.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Episode, EpisodeP, QueryIdentifier, Season } from "@kyoo/models";
|
||||
import { Container, SwitchVariant } from "@kyoo/primitives";
|
||||
import Svg, { SvgProps, Path } from "react-native-svg";
|
||||
import { Stylable } from "yoshiki/native";
|
||||
import { View } from "react-native";
|
||||
import { InfiniteFetch } from "../fetch-infinite";
|
||||
import { episodeDisplayNumber, EpisodeLine } from "./episode";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const EpisodeGrid = ({ slug, season }: { slug: string; season: string | number }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<InfiniteFetch
|
||||
query={EpisodeGrid.query(slug, season)}
|
||||
placeholderCount={15}
|
||||
layout={EpisodeLine.layout}
|
||||
empty={t("show.episode-none")}
|
||||
divider
|
||||
>
|
||||
{(item) => (
|
||||
<EpisodeLine
|
||||
{...item}
|
||||
displayNumber={item.isLoading ? undefined : episodeDisplayNumber(item)}
|
||||
/>
|
||||
)}
|
||||
</InfiniteFetch>
|
||||
);
|
||||
};
|
||||
|
||||
EpisodeGrid.query = (slug: string, season: string | number): QueryIdentifier<Episode> => ({
|
||||
parser: EpisodeP,
|
||||
path: ["shows", slug, "episode"],
|
||||
params: {
|
||||
seasonNumber: season,
|
||||
},
|
||||
infinite: true,
|
||||
});
|
||||
|
||||
const SvgWave = (props: SvgProps) => (
|
||||
<Svg viewBox="0 372.979 612 52.771" {...props}>
|
||||
<Path d="M0 375.175c68-5.1 136-.85 204 7.948 68 9.052 136 22.652 204 24.777s136-8.075 170-12.878l34-4.973v35.7H0" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const SeasonTab = ({
|
||||
slug,
|
||||
season,
|
||||
...props
|
||||
}: { slug: string; season: number | string } & Stylable) => {
|
||||
// TODO: handle absolute number only shows (without seasons)
|
||||
return (
|
||||
<SwitchVariant>
|
||||
{({ css, theme }) => (
|
||||
<View>
|
||||
<SvgWave fill={theme.background} />
|
||||
<View {...css({ bg: (theme) => theme.background }, props)}>
|
||||
<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> */}
|
||||
<EpisodeGrid slug={slug} season={season} />
|
||||
</Container>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</SwitchVariant>
|
||||
);
|
||||
};
|
54
front/packages/ui/src/details/show.tsx
Normal file
54
front/packages/ui/src/details/show.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { QueryIdentifier, QueryPage, Show, ShowP } from "@kyoo/models";
|
||||
import { Platform, ScrollView } from "react-native";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
import { TransparentLayout } from "../layout";
|
||||
import { SeasonTab } from "./season";
|
||||
import { Header } from "./header";
|
||||
|
||||
const query = (slug: string): QueryIdentifier<Show> => ({
|
||||
parser: ShowP,
|
||||
path: ["shows", slug],
|
||||
params: {
|
||||
fields: ["genres", "studio"],
|
||||
},
|
||||
});
|
||||
|
||||
export const ShowDetails: QueryPage<{ slug: string; season: string }> = ({ slug, season }) => {
|
||||
const { css } = useYoshiki();
|
||||
|
||||
return (
|
||||
<ScrollView {...css(Platform.OS === "web" && { overflow: "overlay" as any })}>
|
||||
<Header slug={slug} query={query(slug)} />
|
||||
{/* <Staff slug={slug} /> */}
|
||||
<SeasonTab slug={slug} season={season} />
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
ShowDetails.getFetchUrls = ({ slug, season = 1 }) => [
|
||||
query(slug),
|
||||
// ShowStaff.query(slug),
|
||||
// EpisodeGrid.query(slug, season),
|
||||
];
|
||||
|
||||
ShowDetails.getLayout = TransparentLayout;
|
@ -19,10 +19,10 @@
|
||||
*/
|
||||
|
||||
import { Page, QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
|
||||
import { useBreakpointMap } from "@kyoo/primitives";
|
||||
import { useBreakpointMap, HR } from "@kyoo/primitives";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { ReactElement } from "react";
|
||||
import { ErrorView, Layout, WithLoading } from "./fetch";
|
||||
import { EmptyView, ErrorView, Layout, WithLoading } from "./fetch";
|
||||
|
||||
export const InfiniteFetch = <Data,>({
|
||||
query,
|
||||
@ -30,6 +30,8 @@ export const InfiniteFetch = <Data,>({
|
||||
horizontal = false,
|
||||
children,
|
||||
layout,
|
||||
empty,
|
||||
divider = false,
|
||||
...props
|
||||
}: {
|
||||
query: QueryIdentifier<Data>;
|
||||
@ -38,9 +40,10 @@ export const InfiniteFetch = <Data,>({
|
||||
horizontal?: boolean;
|
||||
children: (
|
||||
item: Data extends Page<infer Item> ? WithLoading<Item> : WithLoading<Data>,
|
||||
key: string | undefined,
|
||||
i: number,
|
||||
) => ReactElement | null;
|
||||
empty?: string | JSX.Element;
|
||||
divider?: boolean | JSX.Element;
|
||||
}): JSX.Element | null => {
|
||||
if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch.");
|
||||
|
||||
@ -49,12 +52,19 @@ export const InfiniteFetch = <Data,>({
|
||||
useInfiniteFetch(query);
|
||||
|
||||
if (error) return <ErrorView error={error} />;
|
||||
if (empty && items && items.length === 0) {
|
||||
if (typeof empty !== "string") return empty;
|
||||
return <EmptyView message={empty} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
renderItem={({ item, index }) =>
|
||||
children({ isLoading: false, ...item } as any, undefined, index)
|
||||
}
|
||||
renderItem={({ item, index }) => (
|
||||
<>
|
||||
{(divider === true && index !== 0) ? <HR orientation={horizontal ? "vertical" : "horizontal"} /> : divider}
|
||||
{children({ isLoading: false, ...item } as any, index)}
|
||||
</>
|
||||
)}
|
||||
data={
|
||||
hasNextPage
|
||||
? [
|
||||
|
@ -19,16 +19,17 @@
|
||||
*/
|
||||
|
||||
import { Page, QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
|
||||
import { ReactElement, useRef } from "react";
|
||||
import { HR } from "@kyoo/primitives";
|
||||
import { Fragment, ReactElement, useRef } from "react";
|
||||
import { Stylable, useYoshiki } from "yoshiki";
|
||||
import { ErrorView, Layout, WithLoading } from "./fetch";
|
||||
import { EmptyView, ErrorView, Layout, WithLoading } from "./fetch";
|
||||
|
||||
const InfiniteScroll = ({
|
||||
children,
|
||||
loader,
|
||||
layout = "vertical",
|
||||
loadMore,
|
||||
hasMore,
|
||||
hasMore = true,
|
||||
isFetching,
|
||||
...props
|
||||
}: {
|
||||
@ -91,6 +92,8 @@ export const InfiniteFetch = <Data,>({
|
||||
children,
|
||||
layout,
|
||||
horizontal = false,
|
||||
empty,
|
||||
divider = false,
|
||||
...props
|
||||
}: {
|
||||
query: QueryIdentifier<Data>;
|
||||
@ -99,9 +102,10 @@ export const InfiniteFetch = <Data,>({
|
||||
horizontal?: boolean;
|
||||
children: (
|
||||
item: Data extends Page<infer Item> ? WithLoading<Item> : WithLoading<Data>,
|
||||
key: string | undefined,
|
||||
i: number,
|
||||
) => ReactElement | null;
|
||||
empty?: string | JSX.Element;
|
||||
divider?: boolean | JSX.Element;
|
||||
}): JSX.Element | null => {
|
||||
if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch.");
|
||||
|
||||
@ -109,6 +113,10 @@ export const InfiniteFetch = <Data,>({
|
||||
const grid = layout.numColumns !== 1;
|
||||
|
||||
if (error) return <ErrorView error={error} />;
|
||||
if (empty && items && items.length === 0) {
|
||||
if (typeof empty !== "string") return empty;
|
||||
return <EmptyView message={empty} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InfiniteScroll
|
||||
@ -116,12 +124,20 @@ export const InfiniteFetch = <Data,>({
|
||||
loadMore={fetchNextPage}
|
||||
hasMore={hasNextPage!}
|
||||
isFetching={isFetching}
|
||||
loader={[...Array(12)].map((_, i) => children({ isLoading: true } as any, i.toString(), i))}
|
||||
loader={[...Array(12)].map((_, i) => (
|
||||
<Fragment key={i.toString()}>
|
||||
{(divider === true && i !== 0) ? <HR orientation={horizontal ? "vertical" : "horizontal"} /> : divider}
|
||||
{children({ isLoading: true } as any, i)}
|
||||
</Fragment>
|
||||
))}
|
||||
{...props}
|
||||
>
|
||||
{items?.map((item, i) =>
|
||||
children({ ...item, isLoading: false } as any, (item as any).id?.toString(), i),
|
||||
)}
|
||||
{items?.map((item, i) => (
|
||||
<Fragment key={(item as any).id?.toString()}>
|
||||
{(divider === true && i !== 0) ? <HR orientation={horizontal ? "vertical" : "horizontal"} /> : divider}
|
||||
{children({ ...item, isLoading: false } as any, i)}
|
||||
</Fragment>
|
||||
))}
|
||||
</InfiniteScroll>
|
||||
);
|
||||
};
|
||||
|
@ -77,3 +77,19 @@ export const ErrorView = ({ error }: { error: KyooErrors }) => {
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmptyView = ({ message }: { message: string }) => {
|
||||
const { css } = useYoshiki();
|
||||
|
||||
return (
|
||||
<View
|
||||
{...css({
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
})}
|
||||
>
|
||||
<P {...css({ color: (theme) => theme.heading })}>{message}</P>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
@ -20,4 +20,4 @@
|
||||
|
||||
export * from "./navbar";
|
||||
export { BrowsePage } from "./browse";
|
||||
export { MovieDetails } from "./details";
|
||||
export { MovieDetails, ShowDetails } from "./details";
|
||||
|
@ -15,11 +15,7 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["src/*"]
|
||||
}
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
|
Loading…
x
Reference in New Issue
Block a user