mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-31 20:24:27 -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/>.
|
* 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")(
|
export default withRoute(ShowDetails, {
|
||||||
sx({
|
options: { headerTransparent: true, headerStyle: { backgroundColor: "transparent" } },
|
||||||
display: "flex",
|
statusBar: { barStyle: "light-content" },
|
||||||
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)",
|
|
||||||
};
|
|
@ -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/>.
|
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Box, Skeleton, SxProps, Tab, Tabs, Typography } from "@mui/material";
|
import { ShowDetails } from "@kyoo/ui";
|
||||||
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 { withRoute } from "~/utils/router";
|
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);
|
export default withRoute(ShowDetails);
|
||||||
|
@ -53,7 +53,7 @@ export const Skeleton = ({
|
|||||||
children?: JSX.Element | JSX.Element[] | boolean | null;
|
children?: JSX.Element | JSX.Element[] | boolean | null;
|
||||||
show?: boolean;
|
show?: boolean;
|
||||||
lines?: number;
|
lines?: number;
|
||||||
variant?: "text" | "header" | "round" | "custom";
|
variant?: "text" | "header" | "round" | "custom" | "fill";
|
||||||
}) => {
|
}) => {
|
||||||
const { css, theme } = useYoshiki();
|
const { css, theme } = useYoshiki();
|
||||||
const [width, setWidth] = useState<number | undefined>(undefined);
|
const [width, setWidth] = useState<number | undefined>(undefined);
|
||||||
@ -87,6 +87,10 @@ export const Skeleton = ({
|
|||||||
variant === "round" && {
|
variant === "round" && {
|
||||||
borderRadius: 9999999,
|
borderRadius: 9999999,
|
||||||
},
|
},
|
||||||
|
variant === "fill" && {
|
||||||
|
width: percent(100),
|
||||||
|
height: percent(100),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
props,
|
props,
|
||||||
)}
|
)}
|
||||||
|
@ -41,7 +41,7 @@ export const catppuccin: ThemeBuilder = {
|
|||||||
subtext: "#6c6f85",
|
subtext: "#6c6f85",
|
||||||
},
|
},
|
||||||
variant: {
|
variant: {
|
||||||
background: "#dc8a78",
|
background: "#e6e9ef",
|
||||||
accent: "#d20f39",
|
accent: "#d20f39",
|
||||||
divider: "#dd7878",
|
divider: "#dd7878",
|
||||||
heading: "#4c4f69",
|
heading: "#4c4f69",
|
||||||
|
@ -93,7 +93,7 @@ export const BrowsePage: QueryPage<{ slug?: string }> = ({ slug }) => {
|
|||||||
placeholderCount={15}
|
placeholderCount={15}
|
||||||
layout={LayoutComponent.layout}
|
layout={LayoutComponent.layout}
|
||||||
>
|
>
|
||||||
{(item, key) => <LayoutComponent key={key} {...itemMap(item)} />}
|
{(item) => <LayoutComponent {...itemMap(item)} />}
|
||||||
</InfiniteFetch>
|
</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
|
<P
|
||||||
{...css({
|
{...css({
|
||||||
display: { xs: "flex", sm: "none" },
|
display: { xs: "flex", sm: "none" },
|
||||||
|
flexWrap: "wrap",
|
||||||
color: (theme: Theme) => theme.user.paragraph,
|
color: (theme: Theme) => theme.user.paragraph,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
@ -19,3 +19,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { MovieDetails } from "./movie";
|
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 { Page, QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
|
||||||
import { useBreakpointMap } from "@kyoo/primitives";
|
import { useBreakpointMap, HR } from "@kyoo/primitives";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { ReactElement } from "react";
|
import { ReactElement } from "react";
|
||||||
import { ErrorView, Layout, WithLoading } from "./fetch";
|
import { EmptyView, ErrorView, Layout, WithLoading } from "./fetch";
|
||||||
|
|
||||||
export const InfiniteFetch = <Data,>({
|
export const InfiniteFetch = <Data,>({
|
||||||
query,
|
query,
|
||||||
@ -30,6 +30,8 @@ export const InfiniteFetch = <Data,>({
|
|||||||
horizontal = false,
|
horizontal = false,
|
||||||
children,
|
children,
|
||||||
layout,
|
layout,
|
||||||
|
empty,
|
||||||
|
divider = false,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
query: QueryIdentifier<Data>;
|
query: QueryIdentifier<Data>;
|
||||||
@ -38,9 +40,10 @@ export const InfiniteFetch = <Data,>({
|
|||||||
horizontal?: boolean;
|
horizontal?: boolean;
|
||||||
children: (
|
children: (
|
||||||
item: Data extends Page<infer Item> ? WithLoading<Item> : WithLoading<Data>,
|
item: Data extends Page<infer Item> ? WithLoading<Item> : WithLoading<Data>,
|
||||||
key: string | undefined,
|
|
||||||
i: number,
|
i: number,
|
||||||
) => ReactElement | null;
|
) => ReactElement | null;
|
||||||
|
empty?: string | JSX.Element;
|
||||||
|
divider?: boolean | JSX.Element;
|
||||||
}): 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.");
|
||||||
|
|
||||||
@ -49,12 +52,19 @@ export const InfiniteFetch = <Data,>({
|
|||||||
useInfiniteFetch(query);
|
useInfiniteFetch(query);
|
||||||
|
|
||||||
if (error) return <ErrorView error={error} />;
|
if (error) return <ErrorView error={error} />;
|
||||||
|
if (empty && items && items.length === 0) {
|
||||||
|
if (typeof empty !== "string") return empty;
|
||||||
|
return <EmptyView message={empty} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
renderItem={({ item, index }) =>
|
renderItem={({ item, index }) => (
|
||||||
children({ isLoading: false, ...item } as any, undefined, index)
|
<>
|
||||||
}
|
{(divider === true && index !== 0) ? <HR orientation={horizontal ? "vertical" : "horizontal"} /> : divider}
|
||||||
|
{children({ isLoading: false, ...item } as any, index)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
data={
|
data={
|
||||||
hasNextPage
|
hasNextPage
|
||||||
? [
|
? [
|
||||||
|
@ -19,16 +19,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Page, QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
|
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 { Stylable, useYoshiki } from "yoshiki";
|
||||||
import { ErrorView, Layout, WithLoading } from "./fetch";
|
import { EmptyView, ErrorView, Layout, WithLoading } from "./fetch";
|
||||||
|
|
||||||
const InfiniteScroll = ({
|
const InfiniteScroll = ({
|
||||||
children,
|
children,
|
||||||
loader,
|
loader,
|
||||||
layout = "vertical",
|
layout = "vertical",
|
||||||
loadMore,
|
loadMore,
|
||||||
hasMore,
|
hasMore = true,
|
||||||
isFetching,
|
isFetching,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
@ -91,6 +92,8 @@ export const InfiniteFetch = <Data,>({
|
|||||||
children,
|
children,
|
||||||
layout,
|
layout,
|
||||||
horizontal = false,
|
horizontal = false,
|
||||||
|
empty,
|
||||||
|
divider = false,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
query: QueryIdentifier<Data>;
|
query: QueryIdentifier<Data>;
|
||||||
@ -99,9 +102,10 @@ export const InfiniteFetch = <Data,>({
|
|||||||
horizontal?: boolean;
|
horizontal?: boolean;
|
||||||
children: (
|
children: (
|
||||||
item: Data extends Page<infer Item> ? WithLoading<Item> : WithLoading<Data>,
|
item: Data extends Page<infer Item> ? WithLoading<Item> : WithLoading<Data>,
|
||||||
key: string | undefined,
|
|
||||||
i: number,
|
i: number,
|
||||||
) => ReactElement | null;
|
) => ReactElement | null;
|
||||||
|
empty?: string | JSX.Element;
|
||||||
|
divider?: boolean | JSX.Element;
|
||||||
}): 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.");
|
||||||
|
|
||||||
@ -109,6 +113,10 @@ export const InfiniteFetch = <Data,>({
|
|||||||
const grid = layout.numColumns !== 1;
|
const grid = layout.numColumns !== 1;
|
||||||
|
|
||||||
if (error) return <ErrorView error={error} />;
|
if (error) return <ErrorView error={error} />;
|
||||||
|
if (empty && items && items.length === 0) {
|
||||||
|
if (typeof empty !== "string") return empty;
|
||||||
|
return <EmptyView message={empty} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InfiniteScroll
|
<InfiniteScroll
|
||||||
@ -116,12 +124,20 @@ export const InfiniteFetch = <Data,>({
|
|||||||
loadMore={fetchNextPage}
|
loadMore={fetchNextPage}
|
||||||
hasMore={hasNextPage!}
|
hasMore={hasNextPage!}
|
||||||
isFetching={isFetching}
|
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}
|
{...props}
|
||||||
>
|
>
|
||||||
{items?.map((item, i) =>
|
{items?.map((item, i) => (
|
||||||
children({ ...item, isLoading: false } as any, (item as any).id?.toString(), 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>
|
</InfiniteScroll>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -77,3 +77,19 @@ export const ErrorView = ({ error }: { error: KyooErrors }) => {
|
|||||||
</View>
|
</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 * from "./navbar";
|
||||||
export { BrowsePage } from "./browse";
|
export { BrowsePage } from "./browse";
|
||||||
export { MovieDetails } from "./details";
|
export { MovieDetails, ShowDetails } from "./details";
|
||||||
|
@ -15,11 +15,7 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"~/*": ["src/*"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "**/*.tsx"],
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
Loading…
x
Reference in New Issue
Block a user