Fix skeletons and errors on show page

This commit is contained in:
Zoe Roux 2022-09-19 12:31:54 +09:00
parent 6cd976e057
commit f95d8b565e
13 changed files with 146 additions and 75 deletions

View File

@ -5,6 +5,10 @@
"studio": "Studio", "studio": "Studio",
"genre": "Genres", "genre": "Genres",
"genre-none": "No genres", "genre-none": "No genres",
"staff": "Staff" "staff": "Staff",
"staff-none": "The staff is unknown",
"noOverview": "No overview available",
"episode-none": "There is no episodes in this season",
"episodeNoMetadata": "No metadata available"
} }
} }

View File

@ -5,6 +5,10 @@
"studio": "Studio", "studio": "Studio",
"genre": "Genres", "genre": "Genres",
"genre-none": "Aucun genres", "genre-none": "Aucun genres",
"staff": "Staff" "staff": "Staff",
"staff-none": "Aucun membre du staff connu",
"noOverview": "Aucune description disponible",
"episode-none": "Il n'y a pas d'episodes dans cette saison",
"episodeNoMetadata": "Aucune metadonnée disponible"
} }
} }

View File

@ -29,7 +29,7 @@
"next-translate": "^1.5.0", "next-translate": "^1.5.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-intersection-observer": "^9.4.0", "react-infinite-scroll-component": "^6.1.0",
"react-query": "^4.0.0-beta.23", "react-query": "^4.0.0-beta.23",
"superjson": "^1.9.1", "superjson": "^1.9.1",
"zod": "^3.18.0" "zod": "^3.18.0"

View File

@ -20,14 +20,22 @@
import { styled, experimental_sx as sx } from "@mui/system"; import { styled, experimental_sx as sx } from "@mui/system";
export const Container = styled("div")(sx({ export const Container = styled("div")(
display: "flex", sx({
pl: "15px", display: "flex",
pr: "15px", px: "15px",
mx: "auto", mx: "auto",
width: { width: {
sm: "540px", sm: "540px",
md: "880px", md: "880px",
lg: "1170px", lg: "1170px",
}, },
})); }),
);
export const containerPadding = {
xs: "15px",
sm: "calc((100vw - 540px) / 2)",
md: "calc((100vw - 880px) / 2)",
lg: "calc((100vw - 1170px) / 2)",
};

View File

@ -19,12 +19,13 @@
*/ */
import { Box, Divider, Skeleton, SxProps, Typography } from "@mui/material"; import { Box, Divider, Skeleton, SxProps, Typography } from "@mui/material";
import useTranslation from "next-translate/useTranslation";
import { Episode } from "~/models"; import { Episode } from "~/models";
import { Link } from "~/utils/link"; import { Link } from "~/utils/link";
import { Image } from "./poster"; import { Image } from "./poster";
const displayNumber = (episode: Episode) => { const displayNumber = (episode: Episode) => {
if (episode.seasonNumber && episode.episodeNumber) if (typeof episode.seasonNumber === "number" && typeof episode.episodeNumber === "number")
return `S${episode.seasonNumber}:E${episode.episodeNumber}`; return `S${episode.seasonNumber}:E${episode.episodeNumber}`;
if (episode.absoluteNumber) return episode.absoluteNumber.toString(); if (episode.absoluteNumber) return episode.absoluteNumber.toString();
return "???"; return "???";
@ -41,6 +42,8 @@ export const EpisodeBox = ({ episode, sx }: { episode?: Episode; sx: SxProps })
}; };
export const EpisodeLine = ({ episode, sx }: { episode?: Episode; sx?: SxProps }) => { export const EpisodeLine = ({ episode, sx }: { episode?: Episode; sx?: SxProps }) => {
const { t } = useTranslation("browse");
return ( return (
<> <>
<Link <Link
@ -59,10 +62,17 @@ export const EpisodeLine = ({ episode, sx }: { episode?: Episode; sx?: SxProps }
{episode ? displayNumber(episode) : <Skeleton />} {episode ? displayNumber(episode) : <Skeleton />}
</Typography> </Typography>
<Image img={episode?.thumbnail} width="18%" aspectRatio="16/9" sx={{ flexShrink: 0 }} /> <Image img={episode?.thumbnail} width="18%" aspectRatio="16/9" sx={{ flexShrink: 0 }} />
<Box> {episode ? (
<Typography variant="h6">{episode?.name ?? <Skeleton />}</Typography> <Box sx={{ flexGrow: 1 }}>
<Typography variant="body2">{episode?.overview ?? <Skeleton />}</Typography> <Typography variant="h6">{episode.name ?? t("show.episodeNoMetadata")}</Typography>
</Box> {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> </Link>
<Divider /> <Divider />
</> </>

View File

@ -18,20 +18,38 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Alert, Snackbar, SnackbarCloseReason, Typography } from "@mui/material"; import { Alert, Box, Snackbar, SnackbarCloseReason, Typography, SxProps } from "@mui/material";
import { SyntheticEvent, useState } from "react"; import { SyntheticEvent, useState } from "react";
import { KyooErrors } from "~/models"; import { KyooErrors } from "~/models";
export const ErrorPage = ({ errors }: { errors: string[] }) => { export const ErrorComponent = ({ errors, sx }: { errors: string[], sx?: SxProps }) => {
return ( return (
<> <Box
<Typography variant="h1">Error</Typography> 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) => ( {errors.map((x, i) => (
<Typography variant="h2" key={i}> <Typography variant="h2" component="h2" key={i}>
{x} {x}
</Typography> </Typography>
))} ))}
</> </Box>
);
};
export const ErrorPage = ({ errors }: { errors: string[] }) => {
return (
<Box sx={{ height: "100vh" }}>
<ErrorComponent errors={errors} sx={{ backgroundColor: "unset" }} />
</Box>
); );
}; };

View File

@ -26,7 +26,7 @@ export const PersonAvatar = ({ person, sx }: { person?: Person; sx?: SxProps })
if (!person) { if (!person) {
return ( return (
<Box sx={sx}> <Box sx={sx}>
<Skeleton variant="circular" sx={{ width: "100%", aspectRatio: "1/1" }}/> <Skeleton variant="circular" sx={{ width: "100%", aspectRatio: "1/1", height: "unset" }}/>
<Typography align="center"><Skeleton/></Typography> <Typography align="center"><Skeleton/></Typography>
<Typography variant="body2" align="center"><Skeleton/></Typography> <Typography variant="body2" align="center"><Skeleton/></Typography>
</Box> </Box>

View File

@ -62,8 +62,9 @@ const _Image = ({
// This allow the loading bool to be false with SSR but still be on client-side // This allow the loading bool to be false with SSR but still be on client-side
useLayoutEffect(() => { useLayoutEffect(() => {
if (!imgRef.current?.complete) setLoading(true); if (!imgRef.current?.complete && img) setLoading(true);
}, []); if (!img && !loading) setLoading(false);
}, [img, loading]);
return ( return (
<Box <Box
@ -73,7 +74,7 @@ const _Image = ({
aspectRatio, aspectRatio,
width, width,
height, height,
backgroundColor: "primary.dark", backgroundColor: "grey.300",
"& > *": { width: "100%", height: "100%" }, "& > *": { width: "100%", height: "100%" },
}} }}
{...others} {...others}
@ -100,9 +101,9 @@ const _Image = ({
export const Image = styled(_Image)({}); export const Image = styled(_Image)({});
// eslint-disable-next-line jsx-a11y/alt-text // eslint-disable-next-line jsx-a11y/alt-text
const _Poster = ( const _Poster = (props: ImagePropsWithLoading & { width?: Width; height?: Height }) => (
props: ImagePropsWithLoading & { width?: Width; height?: Height }, <_Image aspectRatio="2 / 3" {...props} />
) => <_Image aspectRatio="2 / 3" {...props} />; );
declare module "@mui/material/styles" { declare module "@mui/material/styles" {
interface ComponentsPropsList { interface ComponentsPropsList {

View File

@ -47,17 +47,17 @@ export const EpisodeP = z.preprocess(
/** /**
* The title of this episode. * The title of this episode.
*/ */
name: z.string(), name: z.string().nullable(),
/** /**
* The overview of this episode. * The overview of this episode.
*/ */
overview: z.string(), overview: z.string().nullable(),
/** /**
* The release date of this episode. It can be null if unknown. * The release date of this episode. It can be null if unknown.
*/ */
releaseDate: zdate(), releaseDate: zdate().nullable(),
}), }),
); );

View File

@ -41,7 +41,7 @@ export const SeasonP = z.preprocess(
/** /**
* A quick overview of this season. * A quick overview of this season.
*/ */
overview: z.string(), overview: z.string().nullable(),
/** /**
* The starting air date of this season. * The starting air date of this season.

View File

@ -37,7 +37,9 @@ export enum Status {
export const ShowP = z.preprocess( export const ShowP = z.preprocess(
(x: any) => { (x: any) => {
// Waiting for the API to be updaded
x.name = x.title; x.name = x.title;
if (x.aliases === null) x.aliases = [];
return x; return x;
}, },
ResourceP.merge(ImagesP).extend({ ResourceP.merge(ImagesP).extend({
@ -52,7 +54,7 @@ export const ShowP = z.preprocess(
/** /**
* The summary of this show. * The summary of this show.
*/ */
overview: z.string(), overview: z.string().nullable(),
/** /**
* Is this show airing, not aired yet or finished? * Is this show airing, not aired yet or finished?
*/ */
@ -72,7 +74,7 @@ export const ShowP = z.preprocess(
/** /**
* The studio that made this show. * The studio that made this show.
*/ */
studio: StudioP.optional(), studio: StudioP.optional().nullable(),
/** /**
* The list of seasons of this show. * The list of seasons of this show.
*/ */

View File

@ -42,14 +42,13 @@ import { QueryIdentifier, QueryPage, useFetch, useInfiniteFetch } from "~/utils/
import { getDisplayDate } from "~/models/utils"; import { getDisplayDate } from "~/models/utils";
import { useScroll } from "~/utils/hooks/use-scroll"; import { useScroll } from "~/utils/hooks/use-scroll";
import { withRoute } from "~/utils/router"; import { withRoute } from "~/utils/router";
import { Container } from "~/components/container"; import { Container, containerPadding } from "~/components/container";
import { makeTitle } from "~/utils/utils"; import { makeTitle } from "~/utils/utils";
import { Link } from "~/utils/link"; import { Link } from "~/utils/link";
import { Studio } from "~/models/resources/studio"; import { Studio } from "~/models/resources/studio";
import { Paged, Person, PersonP } from "~/models"; import { Paged, Person, PersonP } from "~/models";
import { PersonAvatar } from "~/components/person"; import { PersonAvatar } from "~/components/person";
import { useInView } from "react-intersection-observer"; import { ErrorComponent, ErrorPage } from "~/components/errors";
import { ErrorPage } from "~/components/errors";
import { useState } from "react"; import { useState } from "react";
import { EpisodeBox, EpisodeLine } from "~/components/episode"; import { EpisodeBox, EpisodeLine } from "~/components/episode";
@ -58,7 +57,7 @@ const StudioText = ({
loading = false, loading = false,
sx, sx,
}: { }: {
studio?: Studio; studio?: Studio | null;
loading?: boolean; loading?: boolean;
sx?: SxProps; sx?: SxProps;
}) => { }) => {
@ -196,7 +195,7 @@ const ShowHeader = ({ data }: { data?: Show }) => {
{": "} {": "}
{!data ? ( {!data ? (
<Skeleton width="10rem" sx={{ display: "inline-flex" }} /> <Skeleton width="10rem" sx={{ display: "inline-flex" }} />
) : data?.genres ? ( ) : data?.genres && data.genres.length ? (
data.genres.map((genre, i) => [ data.genres.map((genre, i) => [
i > 0 && ", ", i > 0 && ", ",
<Link key={genre.id} href={`/genres/${genre.slug}`}> <Link key={genre.id} href={`/genres/${genre.slug}`}>
@ -211,7 +210,9 @@ const ShowHeader = ({ data }: { data?: Show }) => {
<Container sx={{ pt: 2 }}> <Container sx={{ pt: 2 }}>
<Typography align="justify" sx={{ flexBasis: 0, flexGrow: 1, pt: { sm: 2 } }}> <Typography align="justify" sx={{ flexBasis: 0, flexGrow: 1, pt: { sm: 2 } }}>
{data?.overview ?? [...Array(4)].map((_, i) => <Skeleton key={i} />)} {data
? data.overview ?? t("show.noOverview")
: [...Array(4)].map((_, i) => <Skeleton key={i} />)}
</Typography> </Typography>
<Divider <Divider
orientation="vertical" orientation="vertical"
@ -229,7 +230,7 @@ const ShowHeader = ({ data }: { data?: Show }) => {
<Typography variant="h4" component="h2"> <Typography variant="h4" component="h2">
{t("show.genre")} {t("show.genre")}
</Typography> </Typography>
{!data || data.genres ? ( {!data || data.genres?.length ? (
<ul> <ul>
{(data ? data.genres! : [...Array(3)]).map((genre, i) => ( {(data ? data.genres! : [...Array(3)]).map((genre, i) => (
<li key={genre?.id ?? i}> <li key={genre?.id ?? i}>
@ -262,19 +263,17 @@ const ShowStaff = ({ slug }: { slug: string }) => {
const { data, isError, error, isFetching, hasNextPage, fetchNextPage } = useInfiniteFetch( const { data, isError, error, isFetching, hasNextPage, fetchNextPage } = useInfiniteFetch(
staffQuery(slug), staffQuery(slug),
); );
const { ref } = useInView({
onChange: () => !isFetching && hasNextPage && fetchNextPage(),
});
const { t } = useTranslation("browse"); const { t } = useTranslation("browse");
// TODO: Unsure that the fetchNextPage is only used when needed (currently called way too mutch) // TODO: Unsure that the fetchNextPage is only used when needed (currently called way too mutch)
// TODO: Handle errors
/* if (isError) return null; */ if (isError) return <ErrorComponent {...error} />;
return ( return (
<> <>
<Container sx={{ display: "flex", flexDirection: "row", justifyContent: "space-between", py: 3 }}> <Container
sx={{ display: "flex", flexDirection: "row", justifyContent: "space-between", py: 3 }}
>
<Typography variant="h4" component="h2"> <Typography variant="h4" component="h2">
{t("show.staff")} {t("show.staff")}
</Typography> </Typography>
@ -287,16 +286,31 @@ const ShowStaff = ({ slug }: { slug: string }) => {
</IconButton> </IconButton>
</Box> </Box>
</Container> </Container>
<Box sx={{ display: "flex", flexDirection: "row", maxWidth: "100%", overflowY: "auto", py: 1, }}> {data && data?.pages.at(0)?.count === 0 ? (
{(data ? data.pages.flatMap((x) => x.items) : [...Array(6)]).map((x, i) => ( <Box sx={{ display: "flex", justifyContent: "center" }}>
<PersonAvatar <Typography sx={{ py: 3 }}>{t("show.staff-none")}</Typography>
key={x ? x.id : i} </Box>
person={x} ) : (
sx={{ width: { xs: "7rem", lg: "10rem" }, flexShrink: 0, px: 2 }} <Container
/> sx={{
))} display: "flex",
<div ref={ref} /> flexDirection: "row",
</Box> maxWidth: "100%",
overflowY: "auto",
pt: 1,
pb: 2,
overflowX: "visible",
}}
>
{(data ? data.pages.flatMap((x) => x.items) : [...Array(20)]).map((x, i) => (
<PersonAvatar
key={x ? x.id : i}
person={x}
sx={{ width: { xs: "7rem", lg: "10rem" }, flexShrink: 0, px: 2 }}
/>
))}
</Container>
)}
</> </>
); );
}; };
@ -314,14 +328,19 @@ const EpisodeGrid = ({ slug, season }: { slug: string; season: number }) => {
const { data, isError, error, isFetching, hasNextPage, fetchNextPage } = useInfiniteFetch( const { data, isError, error, isFetching, hasNextPage, fetchNextPage } = useInfiniteFetch(
episodesQuery(slug, season), episodesQuery(slug, season),
); );
const { ref } = useInView({ const { t } = useTranslation("browse");
onChange: () => !isFetching && hasNextPage && fetchNextPage(),
});
// TODO: Unsure that the fetchNextPage is only used when needed (currently called way too mutch) // TODO: Unsure that the fetchNextPage is only used when needed (currently called way too mutch)
// TODO: Handle errors
/* if (isError) return null; */ if (isError) return <ErrorComponent {...error} />;
if (data && data.pages.at(0)?.count === 0) {
return (
<Box sx={{ display: "flex", justifyContent: "center" }}>
<Typography sx={{ py: 3 }}>{t("show.episode-none")}</Typography>
</Box>
);
}
return ( return (
<Box sx={{ display: "flex", flexDirection: "column", backgroundColor: "background.paper" }}> <Box sx={{ display: "flex", flexDirection: "column", backgroundColor: "background.paper" }}>
@ -339,14 +358,12 @@ const SeasonTab = ({ slug, seasons, sx }: { slug: string; seasons?: Season[]; sx
// TODO: handle absolute number only shows (without seasons) // TODO: handle absolute number only shows (without seasons)
return ( return (
<Container sx={sx}> <Container sx={sx}>
<Box sx={{ borderBottom: 1, borderColor: "divider" }}> <Box sx={{ borderBottom: 1, borderColor: "divider", width: "100%" }}>
<Tabs value={season} onChange={(_, i) => setSeason(i)} aria-label="List of seasons"> <Tabs value={season} onChange={(_, i) => setSeason(i)} aria-label="List of seasons">
{seasons {seasons
? seasons.map((x) => <Tab key={x.seasonNumber} label={x.name} value={x.seasonNumber} />) ? seasons.map((x) => <Tab key={x.seasonNumber} label={x.name} value={x.seasonNumber} />)
: [...Array(3)].map((_, i) => ( : [...Array(3)].map((_, i) => (
<Typography key={i} variant="button"> <Tab key={i} label={<Skeleton width="5rem" />} value={i + 1} disabled />
<Skeleton />
</Typography>
))} ))}
</Tabs> </Tabs>
<EpisodeGrid slug={slug} season={season} /> <EpisodeGrid slug={slug} season={season} />
@ -372,7 +389,7 @@ const ShowDetails: QueryPage<{ slug: string }> = ({ slug }) => {
<> <>
<Head> <Head>
<title>{makeTitle(data?.name)}</title> <title>{makeTitle(data?.name)}</title>
<meta name="description" content={data?.overview} /> <meta name="description" content={data?.overview!} />
</Head> </Head>
<ShowHeader data={data} /> <ShowHeader data={data} />
<ShowStaff slug={slug} /> <ShowStaff slug={slug} />

View File

@ -2191,10 +2191,12 @@ react-dom@18.2.0:
loose-envify "^1.1.0" loose-envify "^1.1.0"
scheduler "^0.23.0" scheduler "^0.23.0"
react-intersection-observer@^9.4.0: react-infinite-scroll-component@^6.1.0:
version "9.4.0" version "6.1.0"
resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-9.4.0.tgz#f6b6e616e625f9bf255857c5cba9dbf7b1825ec7" resolved "https://registry.yarnpkg.com/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz#7e511e7aa0f728ac3e51f64a38a6079ac522407f"
integrity sha512-v0403CmomOVlzhqFXlzOxg0ziLcVq8mfbP0AwAcEQWgZmR2OulOT79Ikznw4UlB3N+jlUYqLMe4SDHUOyp0t2A== integrity sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==
dependencies:
throttle-debounce "^2.1.0"
react-is@^16.13.1, react-is@^16.7.0: react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1" version "16.13.1"
@ -2457,6 +2459,11 @@ text-table@^0.2.0:
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
throttle-debounce@^2.1.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.3.0.tgz#fd31865e66502071e411817e241465b3e9c372e2"
integrity sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==
to-fast-properties@^2.0.0: to-fast-properties@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"