diff --git a/front/next.config.js b/front/next.config.js index 6c813fc2..5ac27b9a 100755 --- a/front/next.config.js +++ b/front/next.config.js @@ -24,6 +24,16 @@ const nextConfig = { reactStrictMode: true, swcMinify: true, + experimental: { + swcPlugins: [ + [ + "next-superjson-plugin", + { + excluded: [], + }, + ], + ], + }, env: { KYOO_URL: process.env.KYOO_URL ?? "http://localhost:5000", }, diff --git a/front/package.json b/front/package.json index cf424a1d..2c3affe7 100644 --- a/front/package.json +++ b/front/package.json @@ -26,10 +26,12 @@ "@mui/icons-material": "^5.8.4", "@mui/material": "^5.8.7", "next": "12.2.2", + "next-superjson-plugin": "^0.3.0", "next-translate": "^1.5.0", "react": "18.2.0", "react-dom": "18.2.0", - "react-query": "^4.0.0-beta.23" + "react-query": "^4.0.0-beta.23", + "zod": "^3.18.0" }, "devDependencies": { "@types/node": "18.0.3", diff --git a/front/src/components/navbar.tsx b/front/src/components/navbar.tsx index f26a273c..a138781b 100644 --- a/front/src/components/navbar.tsx +++ b/front/src/components/navbar.tsx @@ -29,13 +29,14 @@ import { Tooltip, Box, Skeleton, + AppBarProps, } from "@mui/material"; import MenuIcon from "@mui/icons-material/Menu"; import logo from "../../public/icons/icon.svg"; import useTranslation from "next-translate/useTranslation"; import Image from "next/image"; import { ButtonLink } from "~/utils/link"; -import { Library, Page } from "~/models"; +import { LibraryP, Paged } from "~/models"; import { useFetch } from "~/utils/query"; import { ErrorSnackbar } from "./error-snackbar"; @@ -63,6 +64,7 @@ export const KyooTitle = (props: { sx: SxProps }) => { mr: 2, fontFamily: "monospace", fontWeight: 700, + color: "white", }} > Kyoo @@ -72,9 +74,14 @@ export const KyooTitle = (props: { sx: SxProps }) => { ); }; -export const Navbar = () => { +export const NavbarQuery = { + parser: Paged(LibraryP), + path: ["libraries"], +}; + +export const Navbar = (barProps: AppBarProps) => { const { t } = useTranslation("common"); - const { data, error, isSuccess, isError } = useFetch>("libraries"); + const { data, error, isSuccess, isError } = useFetch(NavbarQuery); return ( @@ -111,7 +118,7 @@ export const Navbar = () => { - + diff --git a/front/src/components/poster.tsx b/front/src/components/poster.tsx index fc8294b8..b6d74851 100644 --- a/front/src/components/poster.tsx +++ b/front/src/components/poster.tsx @@ -18,9 +18,8 @@ * along with Kyoo. If not, see . */ -import { Box, NoSsr, Skeleton, styled } from "@mui/material"; +import { Box, Skeleton, styled } from "@mui/material"; import { - MutableRefObject, SyntheticEvent, useEffect, useLayoutEffect, diff --git a/front/src/models/index.ts b/front/src/models/index.ts index 6a3d05fe..4932e1fc 100644 --- a/front/src/models/index.ts +++ b/front/src/models/index.ts @@ -18,7 +18,7 @@ * along with Kyoo. If not, see . */ -export type { Page } from "./page"; -export type { KyooErrors } from "./kyoo-errors"; +export * from "./page"; +export * from "./kyoo-errors"; export * from "./traits"; export * from "./resources"; diff --git a/front/src/models/page.ts b/front/src/models/page.ts index bd2c28db..178cb422 100644 --- a/front/src/models/page.ts +++ b/front/src/models/page.ts @@ -18,6 +18,8 @@ * along with Kyoo. If not, see . */ +import { z } from "zod"; + /** * A page of resource that contains information about the pagination of resources. */ @@ -53,3 +55,12 @@ export interface Page { */ items: T[]; } + +export const Paged = (item: z.ZodType): z.ZodSchema> => + z.object({ + this: z.string(), + first: z.string(), + next: z.string().optional(), + count: z.number(), + items: z.array(item), + }); diff --git a/front/src/models/resources/genre.ts b/front/src/models/resources/genre.ts new file mode 100644 index 00000000..60686494 --- /dev/null +++ b/front/src/models/resources/genre.ts @@ -0,0 +1,34 @@ +/* + * 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 { z } from "zod"; +import { ResourceP } from "../traits/resource"; + +export const GenreP = ResourceP.extend({ + /** + * The name of this genre. + */ + name: z.string(), +}); + +/** + * A genre that allow one to specify categories for shows. + */ +export type Genre = z.infer; diff --git a/front/src/models/resources/library.ts b/front/src/models/resources/library.ts index cf1f0097..8453b72a 100644 --- a/front/src/models/resources/library.ts +++ b/front/src/models/resources/library.ts @@ -18,19 +18,22 @@ * along with Kyoo. If not, see . */ -import { Resource } from "../traits/resource"; +import { z } from "zod"; +import { ResourceP } from "../traits/resource"; /** * The library that will contain Shows, Collections... */ -export interface Library extends Resource { +export const LibraryP = ResourceP.extend({ /** * The name of this library. */ - name: string; + name: z.string(), /** * The list of paths that this library is responsible for. This is mainly used by the Scan task. */ - paths: string[]; -} + paths: z.array(z.string()), +}); + +export type Library = z.infer; diff --git a/front/src/models/resources/show.ts b/front/src/models/resources/show.ts index ff0e90a4..0d3b4e37 100644 --- a/front/src/models/resources/show.ts +++ b/front/src/models/resources/show.ts @@ -18,42 +18,10 @@ * along with Kyoo. If not, see . */ -import { Resource, Images } from "../traits"; - -/** - * A series or a movie. - */ -export interface Show extends Resource, Images { - /** - * The title of this show. - */ - name: string; - - /** - * The list of alternative titles of this show. - */ - aliases: string[]; - - /** - * The summary of this show. - */ - overview: string; - - /** - * Is this show airing, not aired yet or finished? - */ - status: Status; - - /** - * The date this show started airing. It can be null if this is unknown. - */ - startAir: Date | null; - - /** - * The date this show finished airing. It can also be null if this is unknown. - */ - endAir: Date | null; -} +import { z } from "zod"; +import { zdate } from "~/utils/zod"; +import { ImagesP, ResourceP } from "../traits"; +import { GenreP } from "./genre"; /** * The enum containing show's status. @@ -64,3 +32,45 @@ export enum Status { Airing = 2, Planned = 3, } + +export const ShowP = z.preprocess( + (x: any) => { + x.name = x.title; + return x; + }, + ResourceP.merge(ImagesP).extend({ + /** + * The title of this show. + */ + name: z.string(), + /** + * The list of alternative titles of this show. + */ + aliases: z.array(z.string()), + /** + * The summary of this show. + */ + overview: z.string(), + /** + * Is this show airing, not aired yet or finished? + */ + status: z.nativeEnum(Status), + /** + * The date this show started airing. It can be null if this is unknown. + */ + startAir: zdate().optional(), + /** + * The date this show finished airing. It can also be null if this is unknown. + */ + endAir: zdate().nullable(), + /** + * The list of genres (themes) this show has. + */ + genres: z.array(GenreP).optional(), + }), +); + +/** + * A tv serie or an anime. + */ +export type Show = z.infer; diff --git a/front/src/models/traits/images.ts b/front/src/models/traits/images.ts index 8fba648a..228727ac 100644 --- a/front/src/models/traits/images.ts +++ b/front/src/models/traits/images.ts @@ -18,38 +18,43 @@ * along with Kyoo. If not, see . */ -/** - * Base traits for items that has image resources. - */ -export interface Images { +import { z } from "zod"; + +const imageFn = (url: string) => (url.startsWith("/api") ? url : `/api${url}`); + +export const ImagesP = z.object({ /** * An url to the poster of this resource. If this resource does not have an image, the link will * be null. If the kyoo's instance is not capable of handling this kind of image for the specific * resource, this field won't be present. */ - poster?: string; + poster: z.string().transform(imageFn).optional(), /** * An url to the thumbnail of this resource. If this resource does not have an image, the link * will be null. If the kyoo's instance is not capable of handling this kind of image for the * specific resource, this field won't be present. */ - thumbnail?: string; + thumbnail: z.string().transform(imageFn).optional(), /** * An url to the logo of this resource. If this resource does not have an image, the link will be * null. If the kyoo's instance is not capable of handling this kind of image for the specific * resource, this field won't be present. */ - logo?: string; + logo: z.string().transform(imageFn).optional(), /** * An url to the thumbnail of this resource. If this resource does not have an image, the link * will be null. If the kyoo's instance is not capable of handling this kind of image for the * specific resource, this field won't be present. */ + trailer: z.string().transform(imageFn).optional(), +}); - trailer?: string; -}; +/** + * Base traits for items that has image resources. + */ +export type Images = z.infer; export const imageList = ["poster", "thumbnail", "logo", "trailer"]; diff --git a/front/src/models/traits/resource.ts b/front/src/models/traits/resource.ts index 78372b0b..7afea33d 100644 --- a/front/src/models/traits/resource.ts +++ b/front/src/models/traits/resource.ts @@ -18,18 +18,22 @@ * along with Kyoo. If not, see . */ -/** - * The base trait used to represent identifiable resources. - */ -export interface Resource { +import { z } from "zod"; + +export const ResourceP = z.object({ /** * A unique ID for this type of resource. This can't be changed and duplicates are not allowed. */ - id: number; + id: z.number(), /** * A human-readable identifier that can be used instead of an ID. A slug must be unique for a type * of resource but it can be changed. */ - slug: string; -} + slug: z.string(), +}); + +/** + * The base trait used to represent identifiable resources. + */ +export type Resource = z.infer; diff --git a/front/src/pages/_app.tsx b/front/src/pages/_app.tsx index 0f5a78ed..a190bf8f 100755 --- a/front/src/pages/_app.tsx +++ b/front/src/pages/_app.tsx @@ -24,9 +24,9 @@ import { ThemeProvider } from "@mui/material"; import NextApp, { AppContext } from "next/app"; import type { AppProps } from "next/app"; import { Hydrate, QueryClientProvider } from "react-query"; -import { createQueryClient, fetchQuery } from "~/utils/query"; +import { createQueryClient, fetchQuery, QueryIdentifier, QueryPage } from "~/utils/query"; import { defaultTheme } from "~/utils/themes/default-theme"; -import { Navbar } from "~/components/navbar"; +import { Navbar, NavbarQuery } from "~/components/navbar"; import "../global.css"; import { Box } from "@mui/system"; @@ -35,9 +35,7 @@ const AppWithNavbar = ({ children }: { children: JSX.Element }) => { <> {/* TODO: add an option to disable the navbar in the component */} - - {children} - + {children} ); }; @@ -45,6 +43,8 @@ const AppWithNavbar = ({ children }: { children: JSX.Element }) => { const App = ({ Component, pageProps }: AppProps) => { const [queryClient] = useState(() => createQueryClient()); const { queryState, ...props } = pageProps; + + // TODO: tranform date string to date instances in the queryState return ( @@ -61,10 +61,10 @@ const App = ({ Component, pageProps }: AppProps) => { App.getInitialProps = async (ctx: AppContext) => { const appProps = await NextApp.getInitialProps(ctx); - const getUrl = (ctx.Component as any).getFetchUrls; - const urls: string[][] = getUrl ? getUrl(ctx.router.query) : []; + const getUrl = (ctx.Component as QueryPage).getFetchUrls; + const urls: QueryIdentifier[] = getUrl ? getUrl(ctx.router.query as any) : []; // TODO: check if the navbar is needed for this - urls.push(["libraries"]); + urls.push(NavbarQuery); appProps.pageProps.queryState = await fetchQuery(urls); return appProps; diff --git a/front/src/pages/show/[slug].tsx b/front/src/pages/show/[slug].tsx index 56c026fa..a706b1c3 100644 --- a/front/src/pages/show/[slug].tsx +++ b/front/src/pages/show/[slug].tsx @@ -20,8 +20,8 @@ import { Box, Typography } from "@mui/material"; import { Image, Poster } from "~/components/poster"; -import { Show } from "~/models"; -import { QueryPage, useFetch } from "~/utils/query"; +import { Show, ShowP } from "~/models"; +import { QueryIdentifier, QueryPage, useFetch } from "~/utils/query"; import { withRoute } from "~/utils/router"; const ShowHeader = (data: Show) => { @@ -36,8 +36,17 @@ const ShowHeader = (data: Show) => { ); }; +const query = (slug: string): QueryIdentifier => ({ + parser: ShowP, + path: ["shows", slug], + params: { + fields: ["genres"], + }, +}); + const ShowDetails: QueryPage<{ slug: string }> = ({ slug }) => { - const { data } = useFetch("shows", slug); + const { data, error } = useFetch(query(slug)); + console.log("error", data); if (!data) return

oups

; @@ -48,6 +57,6 @@ const ShowDetails: QueryPage<{ slug: string }> = ({ slug }) => { ); }; -ShowDetails.getFetchUrls = ({ slug }) => [["shows", slug]]; +ShowDetails.getFetchUrls = ({ slug }) => [query(slug)]; export default withRoute(ShowDetails); diff --git a/front/src/utils/query.ts b/front/src/utils/query.ts index fb21217e..ca7c0d57 100644 --- a/front/src/utils/query.ts +++ b/front/src/utils/query.ts @@ -26,22 +26,37 @@ import { useInfiniteQuery, useQuery, } from "react-query"; +import { z } from "zod"; import { imageList, KyooErrors, Page } from "~/models"; +import { Paged } from "~/models/page"; -const queryFn = async (context: QueryFunctionContext): Promise => { +const queryFn = async ( + type: z.ZodType, + context: QueryFunctionContext, +): Promise => { try { const resp = await fetch( [typeof window === "undefined" ? process.env.KYOO_URL : "/api"] - .concat(context.pageParam ? [context.pageParam] : (context.queryKey as string[])) - .join("/"), + .concat( + context.pageParam ? [context.pageParam] : (context.queryKey.filter((x) => x) as string[]), + ) + .join("/") + .replace("/?", "?"), ); if (!resp.ok) { throw await resp.json(); } - return await resp.json(); + + const data = await resp.json(); + const parsed = await type.safeParseAsync(data); + if (!parsed.success) { + console.log("Parse error: ", parsed.error); + throw { errors: parsed.error.errors.map((x) => x.message) } as KyooErrors; + } + return parsed.data; } catch (e) { - console.error(e); - throw { errors: ["Could not reach Kyoo's server."] }; // as KyooErrors; + console.error("Fetch error: ", e); + throw { errors: ["Could not reach Kyoo's server."] } as KyooErrors; } }; @@ -53,52 +68,57 @@ export const createQueryClient = () => refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, - queryFn: queryFn, }, }, }); +export type QueryIdentifier = { + parser: z.ZodType; + path: string[]; + params?: { [query: string]: boolean | number | string | string[] }; +}; + export type QueryPage = ComponentType & { - getFetchUrls?: (route: { [key: string]: string }) => string[][]; + getFetchUrls?: (route: { [key: string]: string }) => QueryIdentifier[]; }; -const imageSelector = (obj: T): T => { - // TODO: remove this - // @ts-ignore - if ("title" in obj) obj.name = obj.title; - - for (const img of imageList) { - // @ts-ignore - if (img in obj && obj[img] && !obj[img].startsWith("/api")) { - // @ts-ignore - obj[img] = `/api${obj[img]}`; - } - } - return obj; +const toQuery = (params?: { [query: string]: boolean | number | string | string[] }) => { + if (!params) return undefined; + return ( + "?" + + Object.entries(params) + .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`) + .join("&") + ); }; -export const useFetch = (...params: string[]) => { - return useQuery(params, { - select: imageSelector, +export const useFetch = (query: QueryIdentifier) => { + return useQuery({ + queryKey: [...query.path, toQuery(query.params)], + queryFn: (ctx) => queryFn(query.parser, ctx), }); }; -export const useInfiniteFetch = (...params: string[]) => { - return useInfiniteQuery, KyooErrors>(params, { - select: (pages) => { - pages.pages.map((x) => x.items.map(imageSelector)); - return pages; - }, +export const useInfiniteFetch = (query: QueryIdentifier) => { + return useInfiniteQuery, KyooErrors>({ + queryKey: [...query.path, toQuery(query.params)], + queryFn: (ctx) => queryFn(Paged(query.parser), ctx), }); }; -export const fetchQuery = async (queries: string[][]) => { +export const fetchQuery = async (queries: QueryIdentifier[]) => { // we can't put this check in a function because we want build time optimizations // see https://github.com/vercel/next.js/issues/5354 for details if (typeof window !== "undefined") return {}; - console.log(queries) const client = createQueryClient(); - await Promise.all(queries.map((x) => client.prefetchQuery(x))); + await Promise.all( + queries.map((query) => + client.prefetchQuery({ + queryKey: [...query.path, toQuery(query.params)], + queryFn: (ctx) => queryFn(query.parser, ctx), + }), + ), + ); return dehydrate(client); }; diff --git a/front/src/utils/zod.ts b/front/src/utils/zod.ts new file mode 100644 index 00000000..58881d91 --- /dev/null +++ b/front/src/utils/zod.ts @@ -0,0 +1,33 @@ +/* + * 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 { z } from "zod"; + +export const zdate = () => { + return z.preprocess((arg) => { + if (arg instanceof Date) return arg; + + if (typeof arg === "string" && /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/.test(arg)) { + return new Date(arg); + } + + return undefined; + }, z.date()); +}; diff --git a/front/yarn.lock b/front/yarn.lock index 7264ae77..a2343720 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -1385,7 +1385,7 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" -hoist-non-react-statics@^3.3.1: +hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -1938,6 +1938,13 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +next-superjson-plugin@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/next-superjson-plugin/-/next-superjson-plugin-0.3.0.tgz#81145f275c1e555be68867c104cc21113f96c675" + integrity sha512-M0Soj1P2t9peCyzNndEJiS48O2m88X9UGsCXDy8WHyGwWw1S7eCOEg9MiMqR+X1GD5C3hsdtHKMsKqrZOzr+SQ== + dependencies: + hoist-non-react-statics "^3.3.2" + next-translate@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/next-translate/-/next-translate-1.5.0.tgz#b1e5c4a8e55e31b3ed1b9428529f27c289c6b7bc" @@ -2583,3 +2590,8 @@ yaml@^1.7.2: version "1.10.2" resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== + +zod@^3.18.0: + version "3.18.0" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.18.0.tgz#2eed58b3cafb8d9a67aa2fee69279702f584f3bc" + integrity sha512-gwTm8RfUCe8l9rDwN5r2A17DkAa8Ez4Yl4yXqc5VqeGaXaJahzYYXbTwvhroZi0SNBqTwh/bKm2N0mpCzuw4bA==