From 649eee099e0d7cde6d40d084e5f929339e9d3ad1 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 22 Sep 2022 17:08:23 +0900 Subject: [PATCH] Add a browse page --- front/locales/en/browse.json | 12 + front/locales/fr/browse.json | 12 + front/next.config.js | 9 + front/src/components/navbar.tsx | 21 +- front/src/models/resources/collection.ts | 27 +- front/src/models/resources/library-item.ts | 32 +- front/src/models/utils.ts | 4 +- front/src/pages/_app.tsx | 24 +- front/src/pages/browse/[slug].tsx | 23 ++ front/src/pages/browse/index.tsx | 403 +++++++++++++++++++++ front/src/pages/index.tsx | 25 +- front/src/pages/movie/[slug].tsx | 5 +- front/src/pages/show/[slug].tsx | 24 +- front/src/utils/infinite-scroll.tsx | 26 ++ front/src/utils/link.tsx | 6 +- front/src/utils/query.ts | 16 +- 16 files changed, 567 insertions(+), 102 deletions(-) create mode 100644 front/src/pages/browse/[slug].tsx create mode 100644 front/src/pages/browse/index.tsx create mode 100644 front/src/utils/infinite-scroll.tsx diff --git a/front/locales/en/browse.json b/front/locales/en/browse.json index 9bdabda1..baefbd39 100644 --- a/front/locales/en/browse.json +++ b/front/locales/en/browse.json @@ -10,5 +10,17 @@ "noOverview": "No overview available", "episode-none": "There is no episodes in this season", "episodeNoMetadata": "No metadata available" + }, + "browse": { + "sortby": "Sort by {{key}}", + "sortkey": { + "name": "Name", + "startAir": "Start air", + "endAir": "End air" + }, + "sortord": { + "asc": "asc", + "desc": "decs" + } } } diff --git a/front/locales/fr/browse.json b/front/locales/fr/browse.json index 6fe3e1dd..21eea5c3 100644 --- a/front/locales/fr/browse.json +++ b/front/locales/fr/browse.json @@ -10,5 +10,17 @@ "noOverview": "Aucune description disponible", "episode-none": "Il n'y a pas d'episodes dans cette saison", "episodeNoMetadata": "Aucune metadonnée disponible" + }, + "browse": { + "sortby": "Trier par {{key}}", + "sortkey": { + "name": "Nom", + "startAir": "Date de sortie", + "endAir": "Date de fin de sortie" + }, + "sortord": { + "asc": "asc", + "desc": "decs" + } } } diff --git a/front/next.config.js b/front/next.config.js index 6c813fc2..a069d7e8 100755 --- a/front/next.config.js +++ b/front/next.config.js @@ -30,6 +30,15 @@ const nextConfig = { rewrites: async () => [ { source: "/api/:path*", destination: process.env.KYOO_URL ?? "http://localhost:5000/:path*" }, ], + async redirects() { + return [ + { + source: "/", + destination: "/browse", + permanent: true, + }, + ]; + }, }; module.exports = nextConfig; diff --git a/front/src/components/navbar.tsx b/front/src/components/navbar.tsx index 375ba18e..3ddf0600 100644 --- a/front/src/components/navbar.tsx +++ b/front/src/components/navbar.tsx @@ -36,11 +36,11 @@ import logo from "../../public/icons/icon.svg"; import useTranslation from "next-translate/useTranslation"; import Image from "next/image"; import { ButtonLink } from "~/utils/link"; -import { LibraryP, Paged } from "~/models"; -import { useFetch } from "~/utils/query"; +import { Library, LibraryP, Page, Paged } from "~/models"; +import { QueryIdentifier, useFetch } from "~/utils/query"; import { ErrorSnackbar } from "./errors"; -export const KyooTitle = (props: { sx: SxProps }) => { +const KyooTitle = (props: { sx: SxProps }) => { const { t } = useTranslation("common"); return ( @@ -74,14 +74,9 @@ export const KyooTitle = (props: { sx: SxProps }) => { ); }; -export const NavbarQuery = { - parser: Paged(LibraryP), - path: ["libraries"], -}; - export const Navbar = (barProps: AppBarProps) => { const { t } = useTranslation("common"); - const { data, error, isSuccess, isError } = useFetch(NavbarQuery); + const { data, error, isSuccess, isError } = useFetch(Navbar.query()); return ( @@ -103,7 +98,7 @@ export const Navbar = (barProps: AppBarProps) => { {isSuccess ? data.items.map((library) => ( @@ -126,3 +121,9 @@ export const Navbar = (barProps: AppBarProps) => { ); }; + +Navbar.query = (): QueryIdentifier> => ({ + parser: Paged(LibraryP), + path: ["libraries"], +}); + diff --git a/front/src/models/resources/collection.ts b/front/src/models/resources/collection.ts index 7646cf9a..d1ccacdc 100644 --- a/front/src/models/resources/collection.ts +++ b/front/src/models/resources/collection.ts @@ -18,19 +18,22 @@ * along with Kyoo. If not, see . */ -import { Images, Resource } from "../traits"; +import { z } from "zod"; +import { ImagesP, ResourceP } from "../traits"; + + +export const CollectionP = ResourceP.merge(ImagesP).extend({ + /** + * The title of this collection. + */ + name: z.string(), + /** + * The summary of this show. + */ + overview: z.string().nullable(), +}); /** * A class representing collections of show or movies. */ -export interface Collection extends Resource, Images { - /** - * The name of this collection. - */ - name: string; - - /** - * The description of this collection. - */ - overview: string; -} +export type Collection = z.infer; diff --git a/front/src/models/resources/library-item.ts b/front/src/models/resources/library-item.ts index e8ead6d6..745ba11c 100644 --- a/front/src/models/resources/library-item.ts +++ b/front/src/models/resources/library-item.ts @@ -18,17 +18,10 @@ * along with Kyoo. If not, see . */ -import { Collection } from "./collection"; -import { Movie } from "./movie"; -import { Show } from "./show"; - -/** - * An item that can be contained by a Library (so a Show, a Movie or a Collection). - */ -export type LibraryItem = - | (Show & { type: ItemType.Show }) - | (Movie & { type: ItemType.Movie }) - | (Collection & { type: ItemType.Collection }); +import { z } from "zod"; +import { CollectionP } from "./collection"; +import { MovieP } from "./movie"; +import { ShowP } from "./show"; /** * The type of item, ether a show, a movie or a collection. @@ -38,3 +31,20 @@ export enum ItemType { Movie = 1, Collection = 2, } + +export const LibraryItemP = z.preprocess( + (x: any) => { + x.aliases ??= []; + return x; + }, + z.union([ + ShowP.and(z.object({ type: z.literal(ItemType.Show) })), + MovieP.and(z.object({ type: z.literal(ItemType.Movie) })), + CollectionP.and(z.object({ type: z.literal(ItemType.Collection) })), + ]), +); + +/** + * An item that can be contained by a Library (so a Show, a Movie or a Collection). + */ +export type LibraryItem = z.infer; diff --git a/front/src/models/utils.ts b/front/src/models/utils.ts index 447bd22f..b791e5f1 100644 --- a/front/src/models/utils.ts +++ b/front/src/models/utils.ts @@ -29,11 +29,11 @@ export const getDisplayDate = (data: Show | Movie) => { if (startAir) { if (!endAir || startAir.getFullYear() === endAir.getFullYear()) { - return startAir.getFullYear(); + return startAir.getFullYear().toString(); } return startAir.getFullYear() + (endAir ? ` - ${endAir.getFullYear()}` : ""); } else if (airDate) { - return airDate.getFullYear(); + return airDate.getFullYear().toString(); } }; diff --git a/front/src/pages/_app.tsx b/front/src/pages/_app.tsx index 46d485c4..478c7603 100755 --- a/front/src/pages/_app.tsx +++ b/front/src/pages/_app.tsx @@ -26,30 +26,19 @@ import type { AppProps } from "next/app"; import { Hydrate, QueryClientProvider } from "react-query"; import { createQueryClient, fetchQuery, QueryIdentifier, QueryPage } from "~/utils/query"; import { defaultTheme } from "~/utils/themes/default-theme"; -import { Navbar, NavbarQuery } from "~/components/navbar"; -import { Box } from "@mui/system"; import superjson from "superjson"; +import Head from "next/head"; // Simply silence a SSR warning (see https://github.com/facebook/react/issues/14927 for more details) if (typeof window === "undefined") { React.useLayoutEffect = React.useEffect; } -const AppWithNavbar = ({ children }: { children: JSX.Element }) => { - return ( - <> - {/* */} - {/* TODO: add an option to disable the navbar in the component */} - {children} - - ); -}; - const App = ({ Component, pageProps }: AppProps) => { const [queryClient] = useState(() => createQueryClient()); const { queryState, ...props } = superjson.deserialize(pageProps ?? {}); + const getLayout = (Component as QueryPage).getLayout ?? ((page) => page); - // TODO: tranform date string to date instances in the queryState return ( <> + + Kyoo + - - - + {getLayout()} @@ -91,8 +81,6 @@ App.getInitialProps = async (ctx: AppContext) => { 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(NavbarQuery); appProps.pageProps.queryState = await fetchQuery(urls); return { pageProps: superjson.serialize(appProps.pageProps) }; diff --git a/front/src/pages/browse/[slug].tsx b/front/src/pages/browse/[slug].tsx new file mode 100644 index 00000000..47273d31 --- /dev/null +++ b/front/src/pages/browse/[slug].tsx @@ -0,0 +1,23 @@ +/* + * 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 BrowsePage from "./index"; + +export default BrowsePage; diff --git a/front/src/pages/browse/index.tsx b/front/src/pages/browse/index.tsx new file mode 100644 index 00000000..3b16a5be --- /dev/null +++ b/front/src/pages/browse/index.tsx @@ -0,0 +1,403 @@ +/* + * 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 { + FilterList, + GridView, + North, + Sort, + South, + ViewList, +} from "@mui/icons-material"; +import { + Box, + Button, + ButtonGroup, + ListItemIcon, + ListItemText, + MenuItem, + Menu, + Skeleton, + Divider, + Typography, +} from "@mui/material"; +import useTranslation from "next-translate/useTranslation"; +import { useRouter } from "next/router"; +import { MouseEvent, useState } from "react"; +import { ErrorPage } from "~/components/errors"; +import { Navbar } from "~/components/navbar"; +import { Poster, Image } from "~/components/poster"; +import { ItemType, LibraryItem, LibraryItemP } from "~/models"; +import { getDisplayDate } from "~/models/utils"; +import { InfiniteScroll } from "~/utils/infinite-scroll"; +import { Link } from "~/utils/link"; +import { withRoute } from "~/utils/router"; +import { QueryIdentifier, QueryPage, useInfiniteFetch } from "~/utils/query"; + +enum SortBy { + Name = "name", + StartAir = "startAir", + EndAir = "endAir", +} + +enum SortOrd { + Asc = "asc", + Desc = "desc", +} + +enum Layout { + Grid, + List, +} + +const ItemGrid = ({ + href, + name, + subtitle, + poster, + loading, +}: { + href?: string; + name?: string; + subtitle?: string | null; + poster?: string | null; + loading?: boolean; +}) => { + return ( + + + {name ?? } + {(loading || subtitle) && ( + + {subtitle ?? } + + )} + + ); +}; + +const ItemList = ({ + href, + name, + subtitle, + thumbnail, + poster, + loading, +}: { + href?: string; + name?: string; + subtitle?: string | null; + poster?: string | null; + thumbnail?: string | null; + loading?: boolean; +}) => { + return ( + + {name} + + + {name ?? } + + {(loading || subtitle) && ( + + {subtitle ?? } + + )} + + + + ); +}; + +const Item = ({ item, layout }: { item?: LibraryItem; layout: Layout }) => { + let href; + if (item?.type === ItemType.Movie) href = `/movie/${item.slug}`; + else if (item?.type === ItemType.Show) href = `/show/${item.slug}`; + else if (item?.type === ItemType.Collection) href = `/collection/${item.slug}`; + + switch (layout) { + case Layout.Grid: + return ( + + ); + case Layout.List: + return ( + + ); + } +}; + +const BrowseSettings = ({ + sortKey, + setSort, + sortOrd, + setSortOrd, + layout, + setLayout, +}: { + sortKey: SortBy; + setSort: (sort: SortBy) => void; + sortOrd: SortOrd; + setSortOrd: (sort: SortOrd) => void; + layout: Layout; + setLayout: (layout: Layout) => void; +}) => { + const [sortAnchor, setSortAnchor] = useState(null); + const router = useRouter(); + const { t } = useTranslation("browse"); + + return ( + <> + + + + + + + + setSortAnchor(null)} + > + {Object.values(SortBy).map((x) => ( + setSort(x)} + component={Link} + to={{ query: { ...router.query, sortBy: `${sortKey}-${sortOrd}` } }} + shallow + replace + > + {t(`browse.sortkey.${x}`)} + + ))} + + setSortOrd(SortOrd.Asc)} + component={Link} + to={{ query: { ...router.query, sortBy: `${sortKey}-${sortOrd}` } }} + shallow + replace + > + + + + {t("browse.sortord.asc")} + + setSortOrd(SortOrd.Desc)} + component={Link} + to={{ query: { ...router.query, sortBy: `${sortKey}-${sortOrd}` } }} + shallow + replace + > + + + + {t("browse.sortord.desc")} + + + + ); +}; + +const query = ( + slug?: string, + sortKey?: SortBy, + sortOrd?: SortOrd, +): QueryIdentifier => ({ + parser: LibraryItemP, + path: slug ? ["library", slug, "items"] : ["items"], + infinite: true, + params: { + // The API still uses title isntead of name + sortBy: sortKey + ? `${sortKey === SortBy.Name ? "title" : sortKey}:${sortOrd ?? "asc"}` + : "title:asc", + }, +}); + +const BrowsePage: QueryPage<{ slug?: string }> = ({ slug }) => { + const [sortKey, setSort] = useState(SortBy.Name); + const [sortOrd, setSortOrd] = useState(SortOrd.Asc); + const [layout, setLayout] = useState(Layout.Grid); + const { items, fetchNextPage, hasNextPage, error } = useInfiniteFetch( + query(slug, sortKey, sortOrd), + ); + + if (error) return ; + + return ( + <> + + )]} + sx={{ + display: "flex", + flexWrap: "wrap", + alignItems: "flex-start", + justifyContent: "center", + }} + > + {(items ?? [...Array(12)]).map((x, i) => ( + + ))} + + + ); +}; + +BrowsePage.getLayout = (page) => { + return ( + <> + +
{page}
+ + ); +}; + +BrowsePage.getFetchUrls = ({ slug, sortBy }) => [ + query(slug, sortBy?.split("-")[0] as SortBy, sortBy?.split("-")[1] as SortOrd), + Navbar.query(), +]; + +export default withRoute(BrowsePage); diff --git a/front/src/pages/index.tsx b/front/src/pages/index.tsx index a25e391a..476b0e1a 100755 --- a/front/src/pages/index.tsx +++ b/front/src/pages/index.tsx @@ -18,27 +18,6 @@ * along with Kyoo. If not, see . */ -import type { NextPage } from "next"; -import { Stack, Button } from "@mui/material"; -import Link from "next/link"; -// import { Link } from "~/utils/link"; +import BrowsePage from "./browse" -const Home: NextPage = () => { - return ( -
-
-

- Welcome to Next.js! -

- - - - - Toto - -
-
- ); -}; - -export default Home; +export default BrowsePage; diff --git a/front/src/pages/movie/[slug].tsx b/front/src/pages/movie/[slug].tsx index bab2ec71..d9048f9c 100644 --- a/front/src/pages/movie/[slug].tsx +++ b/front/src/pages/movie/[slug].tsx @@ -249,7 +249,7 @@ export const ShowHeader = ({ data }: { data?: Show | Movie }) => { export const ShowStaff = ({ slug }: { slug: string }) => { - const { data, isError, error } = useInfiniteFetch(ShowStaff.query(slug)); + const { items, isError, error } = useInfiniteFetch(ShowStaff.query(slug)); const { t } = useTranslation("browse"); // TODO: handle infinite scroll @@ -258,7 +258,7 @@ export const ShowStaff = ({ slug }: { slug: string }) => { return ( - {(data ? data.pages.flatMap((x) => x.items) : [...Array(20)]).map((x, i) => ( + {(items ?? [...Array(20)]).map((x, i) => ( = ({ slug }) => { MovieDetails.getFetchUrls = ({ slug }) => [ query(slug), ShowStaff.query(slug), + Navbar.query(), ]; export default withRoute(MovieDetails); diff --git a/front/src/pages/show/[slug].tsx b/front/src/pages/show/[slug].tsx index 73538729..ab129f43 100644 --- a/front/src/pages/show/[slug].tsx +++ b/front/src/pages/show/[slug].tsx @@ -18,14 +18,7 @@ * along with Kyoo. If not, see . */ -import { - Box, - Skeleton, - SxProps, - Tab, - Tabs, - Typography, -} from "@mui/material"; +import { Box, Skeleton, SxProps, Tab, Tabs, Typography } from "@mui/material"; import useTranslation from "next-translate/useTranslation"; import Head from "next/head"; import { Episode, EpisodeP, Season, Show, ShowP } from "~/models"; @@ -40,17 +33,17 @@ 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 { data, isError, error, hasNextPage, fetchNextPage } = useInfiniteFetch( + const { items, isError, error, hasNextPage, fetchNextPage } = useInfiniteFetch( EpisodeGrid.query(slug, season), ); const { t } = useTranslation("browse"); if (isError) return ; - if (data && data.pages.at(0)?.count === 0) { + if (items && items?.length === 0) { return ( {t("show.episode-none")} @@ -60,14 +53,14 @@ const EpisodeGrid = ({ slug, season }: { slug: string; season: number }) => { return ( x.items).length ?? 0} + dataLength={items?.length ?? 0} next={fetchNextPage} hasMore={hasNextPage!} loader={[...Array(12)].map((_, i) => ( ))} > - {(data ? data.pages.flatMap((x) => x.items) : [...Array(12)]).map((x, i) => ( + {(items ?? [...Array(12)]).map((x, i) => ( ))} @@ -83,7 +76,6 @@ EpisodeGrid.query = (slug: string, season: string | number): QueryIdentifier { const router = useRouter(); const seasonQuery = typeof router.query.season === "string" ? parseInt(router.query.season) : NaN; @@ -101,8 +93,9 @@ const SeasonTab = ({ slug, seasons, sx }: { slug: string; seasons?: Season[]; sx label={x.name} value={x.seasonNumber} component={Link} - to={`/show/${slug}?season=${x.seasonNumber}`} + to={{ query: { ...router.query, season: x.seasonNumber } }} shallow + replace /> )) : [...Array(3)].map((_, i) => ( @@ -145,6 +138,7 @@ ShowDetails.getFetchUrls = ({ slug, season = 1 }) => [ query(slug), ShowStaff.query(slug), EpisodeGrid.query(slug, season), + Navbar.query(), ]; export default withRoute(ShowDetails); diff --git a/front/src/utils/infinite-scroll.tsx b/front/src/utils/infinite-scroll.tsx new file mode 100644 index 00000000..b5ecf724 --- /dev/null +++ b/front/src/utils/infinite-scroll.tsx @@ -0,0 +1,26 @@ +/* + * 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 { withThemeProps } from "./with-theme"; +import _InfiniteScroll from "react-infinite-scroll-component"; + +export const InfiniteScroll = withThemeProps(_InfiniteScroll, { + name: "InfiniteScroll", +}); diff --git a/front/src/utils/link.tsx b/front/src/utils/link.tsx index c3ec7001..77123f81 100644 --- a/front/src/utils/link.tsx +++ b/front/src/utils/link.tsx @@ -36,11 +36,11 @@ export const ButtonLink = forwardRef(NextButton); type LinkRef = HTMLAnchorElement; type LinkProps = Omit & - Pick & + Pick & ({ to: NLinkProps["href"], href?: undefined } | { href: NLinkProps["href"], to?: undefined }); -const NextLink = ({ href, to, as, prefetch, locale, shallow, ...props }: LinkProps, ref: Ref) => ( - +const NextLink = ({ href, to, as, prefetch, locale, shallow, replace, ...props }: LinkProps, ref: Ref) => ( + ); diff --git a/front/src/utils/query.ts b/front/src/utils/query.ts index 16403213..e0a4d643 100644 --- a/front/src/utils/query.ts +++ b/front/src/utils/query.ts @@ -18,7 +18,7 @@ * along with Kyoo. If not, see . */ -import { ComponentType } from "react"; +import { ComponentType, ReactElement, ReactNode } from "react"; import { dehydrate, QueryClient, @@ -57,9 +57,10 @@ const queryFn = async ( try { data = JSON.parse(error); } catch (e) { - data = { errors: [error] }; + data = { errors: [error] } as KyooErrors; } - throw data; + console.log("Invalid response:", data) + throw data as KyooErrors; } let data; @@ -91,13 +92,14 @@ export const createQueryClient = () => export type QueryIdentifier = { parser: z.ZodType; - path: string[]; - params?: { [query: string]: boolean | number | string | string[] }; + path: (string | undefined)[]; + params?: { [query: string]: boolean | number | string | string[] | undefined }; infinite?: boolean; }; export type QueryPage = ComponentType & { getFetchUrls?: (route: { [key: string]: string }) => QueryIdentifier[]; + getLayout?: (page: ReactElement) => ReactNode; }; const toQueryKey = (query: QueryIdentifier) => { @@ -106,6 +108,7 @@ const toQueryKey = (query: QueryIdentifier) => { ...query.path, "?" + Object.entries(query.params) + .filter(([k, v]) => v !== undefined) .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`) .join("&"), ]; @@ -122,11 +125,12 @@ export const useFetch = (query: QueryIdentifier) => { }; export const useInfiniteFetch = (query: QueryIdentifier) => { - return useInfiniteQuery, KyooErrors>({ + const ret = useInfiniteQuery, KyooErrors>({ queryKey: toQueryKey(query), queryFn: (ctx) => queryFn(Paged(query.parser), ctx), getNextPageParam: (page: Page) => page?.next || undefined, }); + return {...ret, items: ret.data?.pages.flatMap((x) => x.items)} }; export const fetchQuery = async (queries: QueryIdentifier[]) => {