diff --git a/front/src/models/resources/movie.ts b/front/src/models/resources/movie.ts index 0f7f4505..44fa0305 100644 --- a/front/src/models/resources/movie.ts +++ b/front/src/models/resources/movie.ts @@ -18,34 +18,62 @@ * along with Kyoo. If not, see . */ -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; diff --git a/front/src/models/utils.ts b/front/src/models/utils.ts index a8a1cab1..447bd22f 100644 --- a/front/src/models/utils.ts +++ b/front/src/models/utils.ts @@ -18,9 +18,22 @@ * along with Kyoo. If not, see . */ -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()}` : ""); }; diff --git a/front/src/pages/movie/[slug].tsx b/front/src/pages/movie/[slug].tsx new file mode 100644 index 00000000..bab2ec71 --- /dev/null +++ b/front/src/pages/movie/[slug].tsx @@ -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 . + */ + +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 ( + + {t("show.studio")}:{" "} + {loading ? ( + + ) : ( + {studio!.name} + )} + + ); +}; + +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 */} + + + + + + + + {data?.name ?? } + + {(!data || getDisplayDate(data)) && ( + + {data != undefined ? ( + getDisplayDate(data) + ) : ( + + )} + + )} + *": { m: ".3rem !important" } }}> + + + + + + + + + + + + + + {data?.logo && ( + + )} + + + + + + + + {t("show.genre")} + {": "} + {!data ? ( + + ) : data?.genres && data.genres.length ? ( + data.genres.map((genre, i) => [ + i > 0 && ", ", + + {genre.name} + , + ]) + ) : ( + t("show.genre-none") + )} + + + + + + {data + ? data.overview ?? t("show.noOverview") + : [...Array(4)].map((_, i) => )} + + + + + + + {t("show.genre")} + + {!data || data.genres?.length ? ( +
    + {(data ? data.genres! : [...Array(3)]).map((genre, i) => ( +
  • + + {genre ? ( + {genre.name} + ) : ( + + )} + +
  • + ))} +
+ ) : ( + {t("show.genre-none")} + )} +
+
+ + ); +}; + + +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 ; + + return ( + + {(data ? data.pages.flatMap((x) => x.items) : [...Array(20)]).map((x, i) => ( + + ))} + + ); +}; + +ShowStaff.query = (slug: string): QueryIdentifier => ({ + parser: PersonP, + path: ["shows", slug, "people"], + infinite: true, +}); + +const query = (slug: string): QueryIdentifier => ({ + parser: MovieP, + path: ["shows", slug], + params: { + fields: ["genres", "studio"], + }, +}); + +const MovieDetails: QueryPage<{ slug: string }> = ({ slug }) => { + const { data, error } = useFetch(query(slug)); + + if (error) return ; + + return ( + <> + + {makeTitle(data?.name)} + + + + + + ); +}; + + +MovieDetails.getFetchUrls = ({ slug }) => [ + query(slug), + ShowStaff.query(slug), +]; + +export default withRoute(MovieDetails); diff --git a/front/src/pages/show/[slug].tsx b/front/src/pages/show/[slug].tsx index fa0932ee..73538729 100644 --- a/front/src/pages/show/[slug].tsx +++ b/front/src/pages/show/[slug].tsx @@ -18,280 +18,33 @@ * along with Kyoo. If not, see . */ -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 ( - - {t("show.studio")}:{" "} - {loading ? ( - - ) : ( - {studio!.name} - )} - - ); -}; - -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 */} - - - - - - - - {data?.name ?? } - - {(!data || data.startAir) && ( - - {data != undefined ? ( - getDisplayDate(data.startAir!, data.endAir) - ) : ( - - )} - - )} - *": { m: ".3rem !important" } }}> - - - - - - - - - - - - - - {data?.logo && ( - - )} - - - - - - - - {t("show.genre")} - {": "} - {!data ? ( - - ) : data?.genres && data.genres.length ? ( - data.genres.map((genre, i) => [ - i > 0 && ", ", - - {genre.name} - , - ]) - ) : ( - t("show.genre-none") - )} - - - - - - {data - ? data.overview ?? t("show.noOverview") - : [...Array(4)].map((_, i) => )} - - - - - - - {t("show.genre")} - - {!data || data.genres?.length ? ( -
    - {(data ? data.genres! : [...Array(3)]).map((genre, i) => ( -
  • - - {genre ? ( - {genre.name} - ) : ( - - )} - -
  • - ))} -
- ) : ( - {t("show.genre-none")} - )} -
-
- - ); -}; - -const staffQuery = (slug: string): QueryIdentifier => ({ - 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 ; - - return ( - - {(data ? data.pages.flatMap((x) => x.items) : [...Array(20)]).map((x, i) => ( - - ))} - - ); -}; - -const episodesQuery = (slug: string, season: string | number): QueryIdentifier => ({ - 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 => ({ + 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);