Add a browse page

This commit is contained in:
Zoe Roux 2022-09-22 17:08:23 +09:00
parent aeef6bd44d
commit 649eee099e
16 changed files with 567 additions and 102 deletions

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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;

View File

@ -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<Theme> }) => {
const KyooTitle = (props: { sx: SxProps<Theme> }) => {
const { t } = useTranslation("common");
return (
@ -74,14 +74,9 @@ export const KyooTitle = (props: { sx: SxProps<Theme> }) => {
);
};
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 (
<AppBar position="sticky" {...barProps}>
@ -103,7 +98,7 @@ export const Navbar = (barProps: AppBarProps) => {
{isSuccess
? data.items.map((library) => (
<ButtonLink
href={`/library/${library.slug}`}
href={`/browse/${library.slug}`}
key={library.slug}
sx={{ color: "white" }}
>
@ -126,3 +121,9 @@ export const Navbar = (barProps: AppBarProps) => {
</AppBar>
);
};
Navbar.query = (): QueryIdentifier<Page<Library>> => ({
parser: Paged(LibraryP),
path: ["libraries"],
});

View File

@ -18,19 +18,22 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
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<typeof CollectionP>;

View File

@ -18,17 +18,10 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
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<typeof LibraryItemP>;

View File

@ -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();
}
};

View File

@ -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 (
<>
{/* <Navbar /> */}
{/* TODO: add an option to disable the navbar in the component */}
<Box>{children}</Box>
</>
);
};
const App = ({ Component, pageProps }: AppProps) => {
const [queryClient] = useState(() => createQueryClient());
const { queryState, ...props } = superjson.deserialize<any>(pageProps ?? {});
const getLayout = (Component as QueryPage).getLayout ?? ((page) => page);
// TODO: tranform date string to date instances in the queryState
return (
<>
<style jsx global>{`
@ -73,12 +62,13 @@ const App = ({ Component, pageProps }: AppProps) => {
background-color: rgb(134, 127, 127);
}
`}</style>
<Head>
<title>Kyoo</title>
</Head>
<QueryClientProvider client={queryClient}>
<Hydrate state={queryState}>
<ThemeProvider theme={defaultTheme}>
<AppWithNavbar>
<Component {...props} />
</AppWithNavbar>
{getLayout(<Component {...props} />)}
</ThemeProvider>
</Hydrate>
</QueryClientProvider>
@ -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) };

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
import BrowsePage from "./index";
export default BrowsePage;

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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 (
<Link
href={href ?? ""}
color="inherit"
sx={{
display: "flex",
alignItems: "center",
textAlign: "center",
width: ["18%", "25%"],
minWidth: ["90px", "120px"],
maxWidth: "168px",
flexDirection: "column",
m: [1, 2],
}}
>
<Poster img={poster} alt={name} width="100%" />
<Typography minWidth="80%">{name ?? <Skeleton />}</Typography>
{(loading || subtitle) && (
<Typography variant="caption" minWidth="50%">
{subtitle ?? <Skeleton />}
</Typography>
)}
</Link>
);
};
const ItemList = ({
href,
name,
subtitle,
thumbnail,
poster,
loading,
}: {
href?: string;
name?: string;
subtitle?: string | null;
poster?: string | null;
thumbnail?: string | null;
loading?: boolean;
}) => {
return (
<Link
href={href ?? ""}
color="inherit"
sx={{
display: "flex",
textAlign: "center",
alignItems: "center",
justifyContent: "space-evenly",
width: "100%",
height: "300px",
flexDirection: "row",
m: 1,
position: "relative",
color: "white",
"&:hover .poster": {
transform: "scale(1.3)",
},
}}
>
<Image
img={thumbnail}
alt={name}
width="100%"
height="100%"
radius="5px"
sx={{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
zIndex: -1,
"&::after": {
content: '""',
position: "absolute",
top: 0,
bottom: 0,
right: 0,
left: 0,
/* background: "rgba(0, 0, 0, 0.4)", */
background: "linear-gradient(to bottom, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 0.6) 100%)",
},
}}
/>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: { xs: "50%", lg: "30%" },
}}
>
<Typography
variant="button"
sx={{
fontSize: "2rem",
letterSpacing: "0.002rem",
fontWeight: 900,
}}
>
{name ?? <Skeleton />}
</Typography>
{(loading || subtitle) && (
<Typography variant="caption" sx={{ fontSize: "1rem" }}>
{subtitle ?? <Skeleton />}
</Typography>
)}
</Box>
<Poster
img={poster}
alt=""
height="80%"
className="poster"
sx={{
transition: "transform .2s",
}}
/>
</Link>
);
};
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 (
<ItemGrid
href={href}
name={item?.name}
subtitle={item && item.type !== ItemType.Collection ? getDisplayDate(item) : null}
poster={item?.poster}
loading={!item}
/>
);
case Layout.List:
return (
<ItemList
href={href}
name={item?.name}
subtitle={item && item.type !== ItemType.Collection ? getDisplayDate(item) : null}
poster={item?.poster}
thumbnail={item?.thumbnail}
loading={!item}
/>
);
}
};
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<HTMLElement | null>(null);
const router = useRouter();
const { t } = useTranslation("browse");
return (
<>
<Box sx={{ display: "flex", justifyContent: "space-around" }}>
<ButtonGroup sx={{ m: 1 }}>
<Button disabled>
<FilterList />
</Button>
<Button
id="sortby"
aria-controls={sortAnchor ? "sorby-menu" : undefined}
aria-haspopup="true"
aria-expanded={sortAnchor ? "true" : undefined}
onClick={(event: MouseEvent<HTMLElement>) => setSortAnchor(event.currentTarget)}
>
<Sort />
{t("browse.sortby", { key: t(`browse.sortkey.${sortKey}`) })}
{sortOrd === SortOrd.Asc ? <South fontSize="small" /> : <North fontSize="small" />}
</Button>
<Button onClick={() => setLayout(layout === Layout.List ? Layout.Grid : Layout.List)}>
{layout === Layout.List ? <GridView /> : <ViewList />}
</Button>
</ButtonGroup>
</Box>
<Menu
id="sortby-menu"
MenuListProps={{
"aria-labelledby": "sortby",
}}
anchorEl={sortAnchor}
open={!!sortAnchor}
onClose={() => setSortAnchor(null)}
>
{Object.values(SortBy).map((x) => (
<MenuItem
key={x}
selected={sortKey === x}
onClick={() => setSort(x)}
component={Link}
to={{ query: { ...router.query, sortBy: `${sortKey}-${sortOrd}` } }}
shallow
replace
>
<ListItemText>{t(`browse.sortkey.${x}`)}</ListItemText>
</MenuItem>
))}
<Divider />
<MenuItem
selected={sortOrd === SortOrd.Asc}
onClick={() => setSortOrd(SortOrd.Asc)}
component={Link}
to={{ query: { ...router.query, sortBy: `${sortKey}-${sortOrd}` } }}
shallow
replace
>
<ListItemIcon>
<South fontSize="small" />
</ListItemIcon>
<ListItemText>{t("browse.sortord.asc")}</ListItemText>
</MenuItem>
<MenuItem
selected={sortOrd === SortOrd.Desc}
onClick={() => setSortOrd(SortOrd.Desc)}
component={Link}
to={{ query: { ...router.query, sortBy: `${sortKey}-${sortOrd}` } }}
shallow
replace
>
<ListItemIcon>
<North fontSize="small" />
</ListItemIcon>
<ListItemText>{t("browse.sortord.desc")}</ListItemText>
</MenuItem>
</Menu>
</>
);
};
const query = (
slug?: string,
sortKey?: SortBy,
sortOrd?: SortOrd,
): QueryIdentifier<LibraryItem> => ({
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 <ErrorPage {...error} />;
return (
<>
<BrowseSettings
sortKey={sortKey}
setSort={setSort}
sortOrd={sortOrd}
setSortOrd={setSortOrd}
layout={layout}
setLayout={setLayout}
/>
<InfiniteScroll
dataLength={items?.length ?? 0}
next={fetchNextPage}
hasMore={hasNextPage!}
loader={[...Array(12).map((_, i) => <Item key={i} layout={layout} />)]}
sx={{
display: "flex",
flexWrap: "wrap",
alignItems: "flex-start",
justifyContent: "center",
}}
>
{(items ?? [...Array(12)]).map((x, i) => (
<Item key={x?.id ?? i} item={x} layout={layout} />
))}
</InfiniteScroll>
</>
);
};
BrowsePage.getLayout = (page) => {
return (
<>
<Navbar />
<main>{page}</main>
</>
);
};
BrowsePage.getFetchUrls = ({ slug, sortBy }) => [
query(slug, sortBy?.split("-")[0] as SortBy, sortBy?.split("-")[1] as SortOrd),
Navbar.query(),
];
export default withRoute(BrowsePage);

View File

@ -18,27 +18,6 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
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 (
<div>
<main>
<h1>
Welcome to <a href="https://nextjs.org">Next.js!</a>
</h1>
<Stack spacing={2} direction="row">
<Button variant="text">Text</Button>
<Button variant="contained">Contained</Button>
<Button variant="outlined">Outlined</Button>
<Link href="toto">Toto</Link>
</Stack>
</main>
</div>
);
};
export default Home;
export default BrowsePage;

View File

@ -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 (
<HorizontalList title={t("show.staff")} noContent={t("show.staff-none")}>
{(data ? data.pages.flatMap((x) => x.items) : [...Array(20)]).map((x, i) => (
{(items ?? [...Array(20)]).map((x, i) => (
<PersonAvatar
key={x ? x.id : i}
person={x}
@ -304,6 +304,7 @@ const MovieDetails: QueryPage<{ slug: string }> = ({ slug }) => {
MovieDetails.getFetchUrls = ({ slug }) => [
query(slug),
ShowStaff.query(slug),
Navbar.query(),
];
export default withRoute(MovieDetails);

View File

@ -18,14 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
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 <ErrorComponent {...error} />;
if (data && data.pages.at(0)?.count === 0) {
if (items && items?.length === 0) {
return (
<Box sx={{ display: "flex", justifyContent: "center" }}>
<Typography sx={{ py: 3 }}>{t("show.episode-none")}</Typography>
@ -60,14 +53,14 @@ const EpisodeGrid = ({ slug, season }: { slug: string; season: number }) => {
return (
<InfiniteScroll
dataLength={data?.pages.flatMap((x) => x.items).length ?? 0}
dataLength={items?.length ?? 0}
next={fetchNextPage}
hasMore={hasNextPage!}
loader={[...Array(12)].map((_, i) => (
<EpisodeLine key={i} />
))}
>
{(data ? data.pages.flatMap((x) => x.items) : [...Array(12)]).map((x, i) => (
{(items ?? [...Array(12)]).map((x, i) => (
<EpisodeLine key={x ? x.id : i} episode={x} />
))}
</InfiniteScroll>
@ -83,7 +76,6 @@ EpisodeGrid.query = (slug: string, season: string | number): QueryIdentifier<Epi
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;
@ -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);

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
import { withThemeProps } from "./with-theme";
import _InfiniteScroll from "react-infinite-scroll-component";
export const InfiniteScroll = withThemeProps(_InfiniteScroll, {
name: "InfiniteScroll",
});

View File

@ -36,11 +36,11 @@ export const ButtonLink = forwardRef<ButtonRef, ButtonLinkProps>(NextButton);
type LinkRef = HTMLAnchorElement;
type LinkProps = Omit<MLinkProps, "href"> &
Pick<NLinkProps, "as" | "prefetch" | "locale" | "shallow"> &
Pick<NLinkProps, "as" | "prefetch" | "locale" | "shallow" | "replace"> &
({ to: NLinkProps["href"], href?: undefined } | { href: NLinkProps["href"], to?: undefined });
const NextLink = ({ href, to, as, prefetch, locale, shallow, ...props }: LinkProps, ref: Ref<LinkRef>) => (
<NLink href={href ?? to} as={as} prefetch={prefetch} locale={locale} shallow={shallow} passHref>
const NextLink = ({ href, to, as, prefetch, locale, shallow, replace, ...props }: LinkProps, ref: Ref<LinkRef>) => (
<NLink href={href ?? to} as={as} prefetch={prefetch} locale={locale} shallow={shallow} replace={replace} passHref>
<MLink ref={ref} {...props} />
</NLink>
);

View File

@ -18,7 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { ComponentType } from "react";
import { ComponentType, ReactElement, ReactNode } from "react";
import {
dehydrate,
QueryClient,
@ -57,9 +57,10 @@ const queryFn = async <Data>(
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<T = unknown> = {
parser: z.ZodType<T>;
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<Props = {}> = ComponentType<Props> & {
getFetchUrls?: (route: { [key: string]: string }) => QueryIdentifier[];
getLayout?: (page: ReactElement) => ReactNode;
};
const toQueryKey = <Data>(query: QueryIdentifier<Data>) => {
@ -106,6 +108,7 @@ const toQueryKey = <Data>(query: QueryIdentifier<Data>) => {
...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 = <Data>(query: QueryIdentifier<Data>) => {
};
export const useInfiniteFetch = <Data>(query: QueryIdentifier<Data>) => {
return useInfiniteQuery<Page<Data>, KyooErrors>({
const ret = useInfiniteQuery<Page<Data>, KyooErrors>({
queryKey: toQueryKey(query),
queryFn: (ctx) => queryFn(Paged(query.parser), ctx),
getNextPageParam: (page: Page<Data>) => page?.next || undefined,
});
return {...ret, items: ret.data?.pages.flatMap((x) => x.items)}
};
export const fetchQuery = async (queries: QueryIdentifier[]) => {