Add an episode list for shows

This commit is contained in:
Zoe Roux 2022-12-16 12:35:55 +09:00
parent de06c7f81f
commit 1ee955fbfe
17 changed files with 346 additions and 329 deletions

View File

@ -18,24 +18,10 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { styled, experimental_sx as sx } from "@mui/system";
import { ShowDetails } from "@kyoo/ui";
import { withRoute } from "../../utils";
export const Container = styled("div")(
sx({
display: "flex",
px: "15px",
mx: "auto",
width: {
sm: "540px",
md: "880px",
lg: "1170px",
},
}),
);
export const containerPadding = {
xs: "15px",
sm: "calc((100vw - 540px) / 2)",
md: "calc((100vw - 880px) / 2)",
lg: "calc((100vw - 1170px) / 2)",
};
export default withRoute(ShowDetails, {
options: { headerTransparent: true, headerStyle: { backgroundColor: "transparent" } },
statusBar: { barStyle: "light-content" },
});

View File

@ -1,93 +0,0 @@
/*
* 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 { Box, Divider, Skeleton, SxProps, Typography } from "@mui/material";
import useTranslation from "next-translate/useTranslation";
import { Episode } from "~/models";
import { Link } from "~/utils/link";
import { Image } from "./poster";
export const episodeDisplayNumber = (
episode: {
seasonNumber?: number | null;
episodeNumber?: number | null;
absoluteNumber?: number | null;
},
def?: string,
) => {
if (typeof episode.seasonNumber === "number" && typeof episode.episodeNumber === "number")
return `S${episode.seasonNumber}:E${episode.episodeNumber}`;
if (episode.absoluteNumber) return episode.absoluteNumber.toString();
return def;
};
export const EpisodeBox = ({ episode, sx }: { episode?: Episode; sx: SxProps }) => {
return (
<Box sx={sx}>
<Image img={episode?.thumbnail} alt="" width="100%" aspectRatio="16/9" />
<Typography>{episode?.name ?? <Skeleton />}</Typography>
<Typography variant="body2">{episode?.overview ?? <Skeleton />}</Typography>
</Box>
);
};
export const EpisodeLine = ({ episode, sx }: { episode?: Episode; sx?: SxProps }) => {
const { t } = useTranslation("browse");
return (
<>
<Link
href={episode ? `/watch/${episode.slug}` : ""}
color="inherit"
underline="none"
sx={{
m: 2,
display: "flex",
alignItems: "center",
"& > *": { m: 1 },
...sx,
}}
>
<Typography variant="overline" align="center" sx={{ width: "4rem", flexShrink: 0 }}>
{episode ? episodeDisplayNumber(episode, "???") : <Skeleton />}
</Typography>
<Image
img={episode?.thumbnail}
alt=""
width="18%"
aspectRatio="16/9"
sx={{ flexShrink: 0 }}
/>
{episode ? (
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h6">{episode.name ?? t("show.episodeNoMetadata")}</Typography>
{episode.overview && <Typography variant="body2">{episode.overview}</Typography>}
</Box>
) : (
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h6">{<Skeleton />}</Typography>
<Typography variant="body2">{<Skeleton />}</Typography>
</Box>
)}
</Link>
<Divider />
</>
);
};

View File

@ -1,72 +0,0 @@
/*
* 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 { Alert, Box, Snackbar, SnackbarCloseReason, Typography, SxProps } from "@mui/material";
import { SyntheticEvent, useState } from "react";
import { KyooErrors } from "~/models";
export const ErrorComponent = ({ errors, sx }: { errors: string[]; sx?: SxProps }) => {
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
backgroundColor: "error.light",
...sx,
}}
>
<Typography variant="h1" component="h1" sx={{ fontWeight: 500 }}>
Error
</Typography>
{errors?.map((x, i) => (
<Typography variant="h2" component="h2" key={i}>
{x}
</Typography>
))}
</Box>
);
};
export const ErrorPage = ({ errors }: { errors: string[] }) => {
return (
<Box sx={{ height: "100vh" }}>
<ErrorComponent errors={errors} sx={{ backgroundColor: "unset" }} />
</Box>
);
};
export const ErrorSnackbar = ({ error }: { error: KyooErrors }) => {
const [isOpen, setOpen] = useState(true);
const close = (_: Event | SyntheticEvent, reason?: SnackbarCloseReason) => {
if (reason !== "clickaway") setOpen(false);
};
if (!isOpen) return null;
return (
<Snackbar open={isOpen} onClose={close} autoHideDuration={6000}>
<Alert severity="error" onClose={close} sx={{ width: "100%" }}>
{error.errors[0]}
</Alert>
</Snackbar>
);
};

View File

@ -18,127 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
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";
import { QueryIdentifier, QueryPage, useFetch, useInfiniteFetch } from "~/utils/query";
import { ShowDetails } from "@kyoo/ui";
import { withRoute } from "~/utils/router";
import { Container } from "~/components/container";
import { makeTitle } from "~/utils/utils";
import { Link } from "~/utils/link";
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 { ShowHeader, ShowStaff } from "../movie/[slug]";
import { Navbar } from "~/components/navbar";
const EpisodeGrid = ({ slug, season }: { slug: string; season: number }) => {
const { items, isError, error, hasNextPage, fetchNextPage } = useInfiniteFetch(
EpisodeGrid.query(slug, season),
);
const { t } = useTranslation("browse");
if (isError) return <ErrorComponent {...error} />;
if (items && items?.length === 0) {
return (
<Box sx={{ display: "flex", justifyContent: "center" }}>
<Typography sx={{ py: 3 }}>{t("show.episode-none")}</Typography>
</Box>
);
}
return (
<InfiniteScroll
dataLength={items?.length ?? 0}
next={fetchNextPage}
hasMore={hasNextPage!}
loader={[...Array(12)].map((_, i) => (
<EpisodeLine key={i} />
))}
>
{(items ?? [...Array(12)]).map((x, i) => (
<EpisodeLine key={x ? x.id : i} episode={x} />
))}
</InfiniteScroll>
);
};
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;
const [season, setSeason] = useState(isNaN(seasonQuery) ? 1 : seasonQuery);
// TODO: handle absolute number only shows (without seasons)
return (
<Container sx={sx}>
<Box sx={{ borderBottom: 1, borderColor: "divider", width: "100%" }}>
<Tabs value={season} onChange={(_, i) => setSeason(i)} aria-label="List of seasons">
{seasons
? seasons.map((x) => (
<Tab
key={x.seasonNumber}
label={x.name}
value={x.seasonNumber}
component={Link}
to={{ query: { ...router.query, season: x.seasonNumber } }}
shallow
replace
/>
))
: [...Array(3)].map((_, i) => (
<Tab key={i} label={<Skeleton width="5rem" />} value={i + 1} disabled />
))}
</Tabs>
<EpisodeGrid slug={slug} season={season} />
</Box>
</Container>
);
};
const query = (slug: string): QueryIdentifier<Show> => ({
parser: ShowP,
path: ["shows", slug],
params: {
fields: ["genres", "studio", "seasons"],
},
});
const ShowDetails: 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} />
<SeasonTab slug={slug} seasons={data?.seasons} sx={{ pt: 3 }} />
</>
);
};
ShowDetails.getFetchUrls = ({ slug, season = 1 }) => [
query(slug),
ShowStaff.query(slug),
EpisodeGrid.query(slug, season),
Navbar.query(),
];
export default withRoute(ShowDetails);

View File

@ -53,7 +53,7 @@ export const Skeleton = ({
children?: JSX.Element | JSX.Element[] | boolean | null;
show?: boolean;
lines?: number;
variant?: "text" | "header" | "round" | "custom";
variant?: "text" | "header" | "round" | "custom" | "fill";
}) => {
const { css, theme } = useYoshiki();
const [width, setWidth] = useState<number | undefined>(undefined);
@ -87,6 +87,10 @@ export const Skeleton = ({
variant === "round" && {
borderRadius: 9999999,
},
variant === "fill" && {
width: percent(100),
height: percent(100),
},
],
props,
)}

View File

@ -41,7 +41,7 @@ export const catppuccin: ThemeBuilder = {
subtext: "#6c6f85",
},
variant: {
background: "#dc8a78",
background: "#e6e9ef",
accent: "#d20f39",
divider: "#dd7878",
heading: "#4c4f69",

View File

@ -93,7 +93,7 @@ export const BrowsePage: QueryPage<{ slug?: string }> = ({ slug }) => {
placeholderCount={15}
layout={LayoutComponent.layout}
>
{(item, key) => <LayoutComponent key={key} {...itemMap(item)} />}
{(item) => <LayoutComponent {...itemMap(item)} />}
</InfiniteFetch>
</>
);

View File

@ -0,0 +1,115 @@
/*
* 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 { H6, Image, Link, P, Skeleton, ts } from "@kyoo/primitives";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Layout, WithLoading } from "../fetch";
import { percent, rem, Stylable, useYoshiki, vw } from "yoshiki/native";
export const episodeDisplayNumber = (
episode: {
seasonNumber?: number | null;
episodeNumber?: number | null;
absoluteNumber?: number | null;
},
def?: string,
) => {
if (typeof episode.seasonNumber === "number" && typeof episode.episodeNumber === "number")
return `S${episode.seasonNumber}:E${episode.episodeNumber}`;
if (episode.absoluteNumber) return episode.absoluteNumber.toString();
return def;
};
export const EpisodeBox = ({
name,
overview,
thumbnail,
isLoading,
...props
}: WithLoading<{
name: string;
overview: string;
thumbnail?: string | null;
}> &
Stylable) => {
return (
<View {...props}>
<Image src={thumbnail} alt="" layout={{ width: percent(100), aspectRatio: 16 / 9 }} />
<Skeleton>{isLoading || <P>{name}</P>}</Skeleton>
<Skeleton>{isLoading || <P>{overview}</P>}</Skeleton>
</View>
);
};
export const EpisodeLine = ({
slug,
displayNumber,
name,
thumbnail,
overview,
isLoading,
...props
}: WithLoading<{
slug: string;
displayNumber: string;
name: string;
overview: string;
thumbnail?: string | null;
}> &
Stylable) => {
const { css } = useYoshiki();
const { t } = useTranslation();
return (
<Link
href={slug ? `/watch/${slug}` : ""}
{...css(
{
m: ts(1),
alignItems: "center",
flexDirection: "row",
},
props,
)}
>
<P {...css({ width: rem(4), flexShrink: 0, m: ts(1) })}>
{isLoading ? <Skeleton variant="fill" /> : displayNumber}
</P>
<Image
src={thumbnail}
alt=""
layout={{
width: percent(18),
aspectRatio: 16 / 9,
}}
{...css({ flexShrink: 0, m: ts(1) })}
/>
<View {...css({ flexGrow: 1, flexShrink: 1, m: ts(1) })}>
<Skeleton>{isLoading || <H6 as="p">{name ?? t("show.episodeNoMetadata")}</H6>}</Skeleton>
<Skeleton>{isLoading || <P numberOfLines={3}>{overview}</P>}</Skeleton>
</View>
</Link>
);
};
EpisodeLine.layout = {
numColumns: 1,
size: 100, //vw(18) / (16 / 9) + ts(2),
} satisfies Layout;

View File

@ -224,6 +224,7 @@ const Description = ({
<P
{...css({
display: { xs: "flex", sm: "none" },
flexWrap: "wrap",
color: (theme: Theme) => theme.user.paragraph,
})}
>

View File

@ -19,3 +19,4 @@
*/
export { MovieDetails } from "./movie";
export { ShowDetails } from "./show";

View File

@ -0,0 +1,103 @@
/*
* 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 { Episode, EpisodeP, QueryIdentifier, Season } from "@kyoo/models";
import { Container, SwitchVariant } from "@kyoo/primitives";
import Svg, { SvgProps, Path } from "react-native-svg";
import { Stylable } from "yoshiki/native";
import { View } from "react-native";
import { InfiniteFetch } from "../fetch-infinite";
import { episodeDisplayNumber, EpisodeLine } from "./episode";
import { useTranslation } from "react-i18next";
const EpisodeGrid = ({ slug, season }: { slug: string; season: string | number }) => {
const { t } = useTranslation();
return (
<InfiniteFetch
query={EpisodeGrid.query(slug, season)}
placeholderCount={15}
layout={EpisodeLine.layout}
empty={t("show.episode-none")}
divider
>
{(item) => (
<EpisodeLine
{...item}
displayNumber={item.isLoading ? undefined : episodeDisplayNumber(item)}
/>
)}
</InfiniteFetch>
);
};
EpisodeGrid.query = (slug: string, season: string | number): QueryIdentifier<Episode> => ({
parser: EpisodeP,
path: ["shows", slug, "episode"],
params: {
seasonNumber: season,
},
infinite: true,
});
const SvgWave = (props: SvgProps) => (
<Svg viewBox="0 372.979 612 52.771" {...props}>
<Path d="M0 375.175c68-5.1 136-.85 204 7.948 68 9.052 136 22.652 204 24.777s136-8.075 170-12.878l34-4.973v35.7H0" />
</Svg>
);
export const SeasonTab = ({
slug,
season,
...props
}: { slug: string; season: number | string } & Stylable) => {
// TODO: handle absolute number only shows (without seasons)
return (
<SwitchVariant>
{({ css, theme }) => (
<View>
<SvgWave fill={theme.background} />
<View {...css({ bg: (theme) => theme.background }, props)}>
<Container>
{/* <Tabs value={season} onChange={(_, i) => setSeason(i)} aria-label="List of seasons"> */}
{/* {seasons */}
{/* ? seasons.map((x) => ( */}
{/* <Tab */}
{/* key={x.seasonNumber} */}
{/* label={x.name} */}
{/* value={x.seasonNumber} */}
{/* component={Link} */}
{/* to={{ query: { ...router.query, season: x.seasonNumber } }} */}
{/* shallow */}
{/* replace */}
{/* /> */}
{/* )) */}
{/* : [...Array(3)].map((_, i) => ( */}
{/* <Tab key={i} label={<Skeleton width="5rem" />} value={i + 1} disabled /> */}
{/* ))} */}
{/* </Tabs> */}
<EpisodeGrid slug={slug} season={season} />
</Container>
</View>
</View>
)}
</SwitchVariant>
);
};

View File

@ -0,0 +1,54 @@
/*
* 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 { QueryIdentifier, QueryPage, Show, ShowP } from "@kyoo/models";
import { Platform, ScrollView } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { TransparentLayout } from "../layout";
import { SeasonTab } from "./season";
import { Header } from "./header";
const query = (slug: string): QueryIdentifier<Show> => ({
parser: ShowP,
path: ["shows", slug],
params: {
fields: ["genres", "studio"],
},
});
export const ShowDetails: QueryPage<{ slug: string; season: string }> = ({ slug, season }) => {
const { css } = useYoshiki();
return (
<ScrollView {...css(Platform.OS === "web" && { overflow: "overlay" as any })}>
<Header slug={slug} query={query(slug)} />
{/* <Staff slug={slug} /> */}
<SeasonTab slug={slug} season={season} />
</ScrollView>
);
};
ShowDetails.getFetchUrls = ({ slug, season = 1 }) => [
query(slug),
// ShowStaff.query(slug),
// EpisodeGrid.query(slug, season),
];
ShowDetails.getLayout = TransparentLayout;

View File

@ -19,10 +19,10 @@
*/
import { Page, QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
import { useBreakpointMap } from "@kyoo/primitives";
import { useBreakpointMap, HR } from "@kyoo/primitives";
import { FlashList } from "@shopify/flash-list";
import { ReactElement } from "react";
import { ErrorView, Layout, WithLoading } from "./fetch";
import { EmptyView, ErrorView, Layout, WithLoading } from "./fetch";
export const InfiniteFetch = <Data,>({
query,
@ -30,6 +30,8 @@ export const InfiniteFetch = <Data,>({
horizontal = false,
children,
layout,
empty,
divider = false,
...props
}: {
query: QueryIdentifier<Data>;
@ -38,9 +40,10 @@ export const InfiniteFetch = <Data,>({
horizontal?: boolean;
children: (
item: Data extends Page<infer Item> ? WithLoading<Item> : WithLoading<Data>,
key: string | undefined,
i: number,
) => ReactElement | null;
empty?: string | JSX.Element;
divider?: boolean | JSX.Element;
}): JSX.Element | null => {
if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch.");
@ -49,12 +52,19 @@ export const InfiniteFetch = <Data,>({
useInfiniteFetch(query);
if (error) return <ErrorView error={error} />;
if (empty && items && items.length === 0) {
if (typeof empty !== "string") return empty;
return <EmptyView message={empty} />;
}
return (
<FlashList
renderItem={({ item, index }) =>
children({ isLoading: false, ...item } as any, undefined, index)
}
renderItem={({ item, index }) => (
<>
{(divider === true && index !== 0) ? <HR orientation={horizontal ? "vertical" : "horizontal"} /> : divider}
{children({ isLoading: false, ...item } as any, index)}
</>
)}
data={
hasNextPage
? [

View File

@ -19,16 +19,17 @@
*/
import { Page, QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
import { ReactElement, useRef } from "react";
import { HR } from "@kyoo/primitives";
import { Fragment, ReactElement, useRef } from "react";
import { Stylable, useYoshiki } from "yoshiki";
import { ErrorView, Layout, WithLoading } from "./fetch";
import { EmptyView, ErrorView, Layout, WithLoading } from "./fetch";
const InfiniteScroll = ({
children,
loader,
layout = "vertical",
loadMore,
hasMore,
hasMore = true,
isFetching,
...props
}: {
@ -91,6 +92,8 @@ export const InfiniteFetch = <Data,>({
children,
layout,
horizontal = false,
empty,
divider = false,
...props
}: {
query: QueryIdentifier<Data>;
@ -99,9 +102,10 @@ export const InfiniteFetch = <Data,>({
horizontal?: boolean;
children: (
item: Data extends Page<infer Item> ? WithLoading<Item> : WithLoading<Data>,
key: string | undefined,
i: number,
) => ReactElement | null;
empty?: string | JSX.Element;
divider?: boolean | JSX.Element;
}): JSX.Element | null => {
if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch.");
@ -109,6 +113,10 @@ export const InfiniteFetch = <Data,>({
const grid = layout.numColumns !== 1;
if (error) return <ErrorView error={error} />;
if (empty && items && items.length === 0) {
if (typeof empty !== "string") return empty;
return <EmptyView message={empty} />;
}
return (
<InfiniteScroll
@ -116,12 +124,20 @@ export const InfiniteFetch = <Data,>({
loadMore={fetchNextPage}
hasMore={hasNextPage!}
isFetching={isFetching}
loader={[...Array(12)].map((_, i) => children({ isLoading: true } as any, i.toString(), i))}
loader={[...Array(12)].map((_, i) => (
<Fragment key={i.toString()}>
{(divider === true && i !== 0) ? <HR orientation={horizontal ? "vertical" : "horizontal"} /> : divider}
{children({ isLoading: true } as any, i)}
</Fragment>
))}
{...props}
>
{items?.map((item, i) =>
children({ ...item, isLoading: false } as any, (item as any).id?.toString(), i),
)}
{items?.map((item, i) => (
<Fragment key={(item as any).id?.toString()}>
{(divider === true && i !== 0) ? <HR orientation={horizontal ? "vertical" : "horizontal"} /> : divider}
{children({ ...item, isLoading: false } as any, i)}
</Fragment>
))}
</InfiniteScroll>
);
};

View File

@ -77,3 +77,19 @@ export const ErrorView = ({ error }: { error: KyooErrors }) => {
</View>
);
};
export const EmptyView = ({ message }: { message: string }) => {
const { css } = useYoshiki();
return (
<View
{...css({
flex: 1,
justifyContent: "center",
alignItems: "center",
})}
>
<P {...css({ color: (theme) => theme.heading })}>{message}</P>
</View>
);
};

View File

@ -20,4 +20,4 @@
export * from "./navbar";
export { BrowsePage } from "./browse";
export { MovieDetails } from "./details";
export { MovieDetails, ShowDetails } from "./details";

View File

@ -15,11 +15,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"baseUrl": ".",
"paths": {
"~/*": ["src/*"]
}
"incremental": true
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]