Add movie page

This commit is contained in:
Zoe Roux 2022-09-19 20:09:12 +09:00
parent 9f6cdcdf91
commit aeef6bd44d
4 changed files with 395 additions and 282 deletions

View File

@ -18,34 +18,62 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { Resource, Images } from "../traits";
import { z } from "zod";
import { zdate } from "~/utils/zod";
import { ImagesP, ResourceP } from "../traits";
import { GenreP } from "./genre";
import { StudioP } from "./studio";
/**
* A series or a movie.
* The enum containing movie's status.
*/
export interface Movie extends Resource, Images {
/**
* The title of this show.
*/
name: string;
/**
* The list of alternative titles of this movie.
*/
aliases: string[];
/**
* The summary of this show.
*/
overview: string;
/**
* Is this movie aired or planned
*/
isPlanned: boolean;
/**
* The date this mavie aired. It can also be null if this is unknown.
*/
airDate: Date | null;
export enum MovieStatus {
Unknown = 0,
Finished = 1,
Planned = 3,
}
export const MovieP = z.preprocess(
(x: any) => {
// Waiting for the API to be updaded
x.name = x.title;
if (x.aliases === null) x.aliases = [];
x.airDate = x.startAir;
return x;
},
ResourceP.merge(ImagesP).extend({
/**
* The title of this movie.
*/
name: z.string(),
/**
* The list of alternative titles of this movie.
*/
aliases: z.array(z.string()),
/**
* The summary of this movie.
*/
overview: z.string().nullable(),
/**
* Is this movie not aired yet or finished?
*/
status: z.nativeEnum(MovieStatus),
/**
* The date this movie aired. It can also be null if this is unknown.
*/
airDate: zdate().nullable(),
/**
* The list of genres (themes) this movie has.
*/
genres: z.array(GenreP).optional(),
/**
* The studio that made this movie.
*/
studio: StudioP.optional().nullable(),
}),
);
/**
* A Movie type
*/
export type Movie = z.infer<typeof MovieP>;

View File

@ -18,9 +18,22 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
export const getDisplayDate = (startAir: Date, endAir?: Date | null) => {
if (!endAir || startAir.getFullYear() === endAir.getFullYear()) {
return startAir.getFullYear();
import { Movie, Show } from "./resources";
export const getDisplayDate = (data: Show | Movie) => {
const {
startAir,
endAir,
airDate,
}: { startAir?: Date | null; endAir?: Date | null; airDate?: Date | null } = data;
if (startAir) {
if (!endAir || startAir.getFullYear() === endAir.getFullYear()) {
return startAir.getFullYear();
}
return startAir.getFullYear() + (endAir ? ` - ${endAir.getFullYear()}` : "");
}
else if (airDate) {
return airDate.getFullYear();
}
return startAir.getFullYear() + (endAir ? ` - ${endAir.getFullYear()}` : "");
};

View File

@ -0,0 +1,309 @@
/*
* 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 { LocalMovies, PlayArrow } from "@mui/icons-material";
import {
Box,
Divider,
Fab,
IconButton,
Skeleton,
SxProps,
Tooltip,
Typography,
} from "@mui/material";
import useTranslation from "next-translate/useTranslation";
import Head from "next/head";
import { Navbar } from "~/components/navbar";
import { Image, Poster } from "~/components/poster";
import { Movie, MovieP, Show } from "~/models";
import { QueryIdentifier, QueryPage, useFetch, useInfiniteFetch } from "~/utils/query";
import { getDisplayDate } from "~/models/utils";
import { withRoute } from "~/utils/router";
import { Container } from "~/components/container";
import { makeTitle } from "~/utils/utils";
import { Link } from "~/utils/link";
import { Studio } from "~/models/resources/studio";
import { Person, PersonP } from "~/models";
import { PersonAvatar } from "~/components/person";
import { ErrorComponent, ErrorPage } from "~/components/errors";
import { HorizontalList } from "~/components/horizontal-list";
const StudioText = ({
studio,
loading = false,
sx,
}: {
studio?: Studio | null;
loading?: boolean;
sx?: SxProps;
}) => {
const { t } = useTranslation("browse");
if (!loading && !studio) return null;
return (
<Typography sx={sx}>
{t("show.studio")}:{" "}
{loading ? (
<Skeleton width="5rem" sx={{ display: "inline-flex" }} />
) : (
<Link href={`/studio/${studio!.slug}`}>{studio!.name}</Link>
)}
</Typography>
);
};
export const ShowHeader = ({ data }: { data?: Show | Movie }) => {
/* const scroll = useScroll(); */
const { t } = useTranslation("browse");
// TODO: tweek the navbar color with the theme.
return (
<>
{/* TODO: Add a shadow on navbar items */}
{/* TODO: Put the navbar outside of the scrollbox */}
<Navbar
position="fixed"
elevation={0}
sx={{ backgroundColor: `rgba(0, 0, 0, ${0.4 /*+ scroll / 1000*/})` }}
/>
<Image
img={data?.thumbnail}
alt=""
loading={!data}
width="100%"
height={{ xs: "40vh", sm: "60vh", lg: "70vh" }}
sx={{
minHeight: { xs: "350px", sm: "400px", lg: "550px" },
position: "relative",
"&::after": {
content: '""',
position: "absolute",
top: 0,
bottom: 0,
right: 0,
left: 0,
background: "linear-gradient(to bottom, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0.6) 100%)",
},
}}
/>
<Container
sx={{
position: "relative",
marginTop: { xs: "-30%", sm: "-25%", md: "-15rem", lg: "-21rem", xl: "-23rem" },
display: "flex",
flexDirection: { xs: "column", sm: "row" },
alignItems: { xs: "center", sm: "unset" },
textAlign: { xs: "center", sm: "unset" },
}}
>
<Poster
img={data?.poster}
alt={data?.name ?? ""}
loading={!data}
width={{ xs: "50%", md: "25%" }}
sx={{ maxWidth: { xs: "175px", sm: "unset" }, flexShrink: 0 }}
/>
<Box sx={{ alignSelf: { xs: "center", sm: "end", md: "center" }, pl: { sm: "2.5rem" } }}>
<Typography
variant="h3"
component="h1"
sx={{
color: { md: "white" },
fontWeight: { md: 900 },
mb: ".5rem",
}}
>
{data?.name ?? <Skeleton width="15rem" />}
</Typography>
{(!data || getDisplayDate(data)) && (
<Typography variant="h5" sx={{ color: { md: "white" }, fontWeight: 300, mb: ".5rem" }}>
{data != undefined ? (
getDisplayDate(data)
) : (
<Skeleton width="5rem" sx={{ mx: { xs: "auto", sm: "unset" } }} />
)}
</Typography>
)}
<Box sx={{ "& > *": { m: ".3rem !important" } }}>
<Tooltip title={t("show.play")}>
<Fab color="primary" size="small" aria-label={t("show.play")}>
<PlayArrow />
</Fab>
</Tooltip>
<Tooltip title={t("show.trailer")} aria-label={t("show.trailer")}>
<IconButton>
<LocalMovies sx={{ color: { md: "white" } }} />
</IconButton>
</Tooltip>
</Box>
</Box>
<Box
sx={{
display: { xs: "none", md: "flex" },
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: "25%",
flexDirection: "column",
alignSelf: "end",
pr: "15px",
}}
>
{data?.logo && (
<Image
img={data.logo}
alt=""
width="100%"
height="100px"
sx={{ display: { xs: "none", lg: "unset" } }}
/>
)}
<StudioText loading={!data} studio={data?.studio} sx={{ mt: "auto", mb: 3 }} />
</Box>
</Container>
<Container sx={{ display: { xs: "block", sm: "none" }, pt: 3 }}>
<StudioText loading={!data} studio={data?.studio} sx={{ mb: 1 }} />
<Typography sx={{ mb: 1 }}>
{t("show.genre")}
{": "}
{!data ? (
<Skeleton width="10rem" sx={{ display: "inline-flex" }} />
) : data?.genres && data.genres.length ? (
data.genres.map((genre, i) => [
i > 0 && ", ",
<Link key={genre.id} href={`/genres/${genre.slug}`}>
{genre.name}
</Link>,
])
) : (
t("show.genre-none")
)}
</Typography>
</Container>
<Container sx={{ pt: 2 }}>
<Typography align="justify" sx={{ flexBasis: 0, flexGrow: 1, pt: { sm: 2 } }}>
{data
? data.overview ?? t("show.noOverview")
: [...Array(4)].map((_, i) => <Skeleton key={i} />)}
</Typography>
<Divider
orientation="vertical"
variant="middle"
flexItem
sx={{ mx: 2, display: { xs: "none", sm: "block" } }}
/>
<Box sx={{ flexBasis: "25%", display: { xs: "none", sm: "block" } }}>
<StudioText
loading={!data}
studio={data?.studio}
sx={{ display: { xs: "none", sm: "block", md: "none" }, pb: 2 }}
/>
<Typography variant="h4" component="h2">
{t("show.genre")}
</Typography>
{!data || data.genres?.length ? (
<ul>
{(data ? data.genres! : [...Array(3)]).map((genre, i) => (
<li key={genre?.id ?? i}>
<Typography>
{genre ? (
<Link href={`/genres/${genre.slug}`}>{genre.name}</Link>
) : (
<Skeleton />
)}
</Typography>
</li>
))}
</ul>
) : (
<Typography>{t("show.genre-none")}</Typography>
)}
</Box>
</Container>
</>
);
};
export const ShowStaff = ({ slug }: { slug: string }) => {
const { data, isError, error } = useInfiniteFetch(ShowStaff.query(slug));
const { t } = useTranslation("browse");
// TODO: handle infinite scroll
if (isError) return <ErrorComponent {...error} />;
return (
<HorizontalList title={t("show.staff")} noContent={t("show.staff-none")}>
{(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 }}
/>
))}
</HorizontalList>
);
};
ShowStaff.query = (slug: string): QueryIdentifier<Person> => ({
parser: PersonP,
path: ["shows", slug, "people"],
infinite: true,
});
const query = (slug: string): QueryIdentifier<Movie> => ({
parser: MovieP,
path: ["shows", slug],
params: {
fields: ["genres", "studio"],
},
});
const MovieDetails: 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} />
</>
);
};
MovieDetails.getFetchUrls = ({ slug }) => [
query(slug),
ShowStaff.query(slug),
];
export default withRoute(MovieDetails);

View File

@ -18,280 +18,33 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { LocalMovies, PlayArrow } from "@mui/icons-material";
import {
Box,
Divider,
Fab,
IconButton,
Skeleton,
SxProps,
Tab,
Tabs,
Tooltip,
Typography,
} from "@mui/material";
import useTranslation from "next-translate/useTranslation";
import Head from "next/head";
import { Navbar } from "~/components/navbar";
import { Image, Poster } from "~/components/poster";
import { Episode, EpisodeP, Season, Show, ShowP } from "~/models";
import { QueryIdentifier, QueryPage, useFetch, useInfiniteFetch } from "~/utils/query";
import { getDisplayDate } from "~/models/utils";
import { withRoute } from "~/utils/router";
import { Container } from "~/components/container";
import { makeTitle } from "~/utils/utils";
import { Link } from "~/utils/link";
import { Studio } from "~/models/resources/studio";
import { Person, PersonP } from "~/models";
import { PersonAvatar } from "~/components/person";
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 { HorizontalList } from "~/components/horizontal-list";
import { ShowHeader, ShowStaff } from "../movie/[slug]";
const StudioText = ({
studio,
loading = false,
sx,
}: {
studio?: Studio | null;
loading?: boolean;
sx?: SxProps;
}) => {
const { t } = useTranslation("browse");
if (!loading && !studio) return null;
return (
<Typography sx={sx}>
{t("show.studio")}:{" "}
{loading ? (
<Skeleton width="5rem" sx={{ display: "inline-flex" }} />
) : (
<Link href={`/studio/${studio!.slug}`}>{studio!.name}</Link>
)}
</Typography>
);
};
const ShowHeader = ({ data }: { data?: Show }) => {
/* const scroll = useScroll(); */
const { t } = useTranslation("browse");
// TODO: tweek the navbar color with the theme.
return (
<>
{/* TODO: Add a shadow on navbar items */}
{/* TODO: Put the navbar outside of the scrollbox */}
<Navbar
position="fixed"
elevation={0}
sx={{ backgroundColor: `rgba(0, 0, 0, ${0.4 /*+ scroll / 1000*/})` }}
/>
<Image
img={data?.thumbnail}
alt=""
loading={!data}
width="100%"
height={{ xs: "40vh", sm: "60vh", lg: "70vh" }}
sx={{
minHeight: { xs: "350px", sm: "400px", lg: "550px" },
position: "relative",
"&::after": {
content: '""',
position: "absolute",
top: 0,
bottom: 0,
right: 0,
left: 0,
background: "linear-gradient(to bottom, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0.6) 100%)",
},
}}
/>
<Container
sx={{
position: "relative",
marginTop: { xs: "-30%", sm: "-25%", md: "-15rem", lg: "-21rem", xl: "-23rem" },
display: "flex",
flexDirection: { xs: "column", sm: "row" },
alignItems: { xs: "center", sm: "unset" },
textAlign: { xs: "center", sm: "unset" },
}}
>
<Poster
img={data?.poster}
alt={data?.name ?? ""}
loading={!data}
width={{ xs: "50%", md: "25%" }}
sx={{ maxWidth: { xs: "175px", sm: "unset" }, flexShrink: 0 }}
/>
<Box sx={{ alignSelf: { xs: "center", sm: "end", md: "center" }, pl: { sm: "2.5rem" } }}>
<Typography
variant="h3"
component="h1"
sx={{
color: { md: "white" },
fontWeight: { md: 900 },
mb: ".5rem",
}}
>
{data?.name ?? <Skeleton width="15rem" />}
</Typography>
{(!data || data.startAir) && (
<Typography variant="h5" sx={{ color: { md: "white" }, fontWeight: 300, mb: ".5rem" }}>
{data != undefined ? (
getDisplayDate(data.startAir!, data.endAir)
) : (
<Skeleton width="5rem" sx={{ mx: { xs: "auto", sm: "unset" } }} />
)}
</Typography>
)}
<Box sx={{ "& > *": { m: ".3rem !important" } }}>
<Tooltip title={t("show.play")}>
<Fab color="primary" size="small" aria-label={t("show.play")}>
<PlayArrow />
</Fab>
</Tooltip>
<Tooltip title={t("show.trailer")} aria-label={t("show.trailer")}>
<IconButton>
<LocalMovies sx={{ color: { md: "white" } }} />
</IconButton>
</Tooltip>
</Box>
</Box>
<Box
sx={{
display: { xs: "none", md: "flex" },
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: "25%",
flexDirection: "column",
alignSelf: "end",
pr: "15px",
}}
>
{data?.logo && (
<Image
img={data.logo}
alt=""
width="100%"
height="100px"
sx={{ display: { xs: "none", lg: "unset" } }}
/>
)}
<StudioText loading={!data} studio={data?.studio} sx={{ mt: "auto", mb: 3 }} />
</Box>
</Container>
<Container sx={{ display: { xs: "block", sm: "none" }, pt: 3 }}>
<StudioText loading={!data} studio={data?.studio} sx={{ mb: 1 }} />
<Typography sx={{ mb: 1 }}>
{t("show.genre")}
{": "}
{!data ? (
<Skeleton width="10rem" sx={{ display: "inline-flex" }} />
) : data?.genres && data.genres.length ? (
data.genres.map((genre, i) => [
i > 0 && ", ",
<Link key={genre.id} href={`/genres/${genre.slug}`}>
{genre.name}
</Link>,
])
) : (
t("show.genre-none")
)}
</Typography>
</Container>
<Container sx={{ pt: 2 }}>
<Typography align="justify" sx={{ flexBasis: 0, flexGrow: 1, pt: { sm: 2 } }}>
{data
? data.overview ?? t("show.noOverview")
: [...Array(4)].map((_, i) => <Skeleton key={i} />)}
</Typography>
<Divider
orientation="vertical"
variant="middle"
flexItem
sx={{ mx: 2, display: { xs: "none", sm: "block" } }}
/>
<Box sx={{ flexBasis: "25%", display: { xs: "none", sm: "block" } }}>
<StudioText
loading={!data}
studio={data?.studio}
sx={{ display: { xs: "none", sm: "block", md: "none" }, pb: 2 }}
/>
<Typography variant="h4" component="h2">
{t("show.genre")}
</Typography>
{!data || data.genres?.length ? (
<ul>
{(data ? data.genres! : [...Array(3)]).map((genre, i) => (
<li key={genre?.id ?? i}>
<Typography>
{genre ? (
<Link href={`/genres/${genre.slug}`}>{genre.name}</Link>
) : (
<Skeleton />
)}
</Typography>
</li>
))}
</ul>
) : (
<Typography>{t("show.genre-none")}</Typography>
)}
</Box>
</Container>
</>
);
};
const staffQuery = (slug: string): QueryIdentifier<Person> => ({
parser: PersonP,
path: ["shows", slug, "people"],
infinite: true,
});
const ShowStaff = ({ slug }: { slug: string }) => {
const { data, isError, error } = useInfiniteFetch(staffQuery(slug));
const { t } = useTranslation("browse");
// TODO: handle infinite scroll
if (isError) return <ErrorComponent {...error} />;
return (
<HorizontalList title={t("show.staff")} noContent={t("show.staff-none")}>
{(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 }}
/>
))}
</HorizontalList>
);
};
const episodesQuery = (slug: string, season: string | number): QueryIdentifier<Episode> => ({
parser: EpisodeP,
path: ["shows", slug, "episode"],
params: {
seasonNumber: season,
},
infinite: true,
});
const EpisodeGrid = ({ slug, season }: { slug: string; season: number }) => {
const { data, isError, error, hasNextPage, fetchNextPage } = useInfiniteFetch(
episodesQuery(slug, season),
EpisodeGrid.query(slug, season),
);
const { t } = useTranslation("browse");
@ -321,6 +74,16 @@ const EpisodeGrid = ({ slug, season }: { slug: string; season: number }) => {
);
};
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;
@ -380,8 +143,8 @@ const ShowDetails: QueryPage<{ slug: string }> = ({ slug }) => {
ShowDetails.getFetchUrls = ({ slug, season = 1 }) => [
query(slug),
staffQuery(slug),
episodesQuery(slug, season),
ShowStaff.query(slug),
EpisodeGrid.query(slug, season),
];
export default withRoute(ShowDetails);