Rewrite the movie header

This commit is contained in:
Zoe Roux 2022-12-12 15:26:59 +09:00
parent a213c39445
commit e5b236f51c
23 changed files with 604 additions and 762 deletions

View File

@ -1,3 +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 "@kyoo/ui";
export default BrowsePage
export default BrowsePage;

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 { MovieDetails } from "@kyoo/ui";
export default MovieDetails;

View File

@ -31,7 +31,7 @@
"react-native-safe-area-context": "4.4.1",
"react-native-screens": "~3.18.0",
"react-native-svg": "13.4.0",
"yoshiki": "0.2.9"
"yoshiki": "0.2.11"
},
"devDependencies": {
"@babel/core": "^7.19.3",

View File

@ -41,7 +41,7 @@
"react-native-web": "^0.18.10",
"solito": "^2.0.5",
"superjson": "^1.11.0",
"yoshiki": "0.2.9",
"yoshiki": "0.2.11",
"zod": "^3.19.1"
},
"devDependencies": {

View File

@ -18,6 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import BrowsePage from "./index";
import { BrowsePage } from "@kyoo/ui";
import { withRoute } from "~/utils/router";
export default BrowsePage;
export default withRoute(BrowsePage);

View File

@ -22,423 +22,3 @@ import { BrowsePage } from "@kyoo/ui";
import { withRoute } from "~/utils/router";
export default withRoute(BrowsePage);
/* import { FilterList, GridView, North, Sort, South, ViewList } from "@mui/icons-material"; */
/* import { */
/* Box, */
/* Button, */
/* ButtonGroup, */
/* ListItemIcon, */
/* ListItemText, */
/* MenuItem, */
/* Menu, */
/* Skeleton, */
/* Divider, */
/* Tooltip, */
/* Typography, */
/* } from "@mui/material"; */
/* import useTranslation from "next-translate/useTranslation"; */
/* import { useRouter } from "next/router"; */
/* import { useState } from "react"; */
/* import { ErrorPage } from "~/components/errors"; */
/* import { Navbar } from "@kyoo/ui"; */
/* import { Poster, Image } from "@kyoo/primitives"; */
/* import { ItemType, LibraryItem, LibraryItemP } from "~/models"; */
/* import { getDisplayDate } from "@kyoo/models"; */
/* import { InfiniteScroll } from "~/utils/infinite-scroll"; */
/* import { Link } from "~/utils/link"; */
/* import { withRoute } from "~/utils/router"; */
/* import { QueryIdentifier, QueryPage, useInfiniteFetch } from "@kyoo/models"; */
/* import { px } from "yoshiki/native"; */
/* 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 src={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 */
/* src={thumbnail} */
/* alt={name} */
/* width="100%" */
/* height="100%" */
/* radius={px(5)} */
/* css={{ */
/* 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 */
/* src={poster} */
/* alt="" */
/* height="80%" */
/* css={{ */
/* 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 SortByMenu = ({ */
/* sortKey, */
/* setSort, */
/* sortOrd, */
/* setSortOrd, */
/* anchor, */
/* onClose, */
/* }: { */
/* sortKey: SortBy; */
/* setSort: (sort: SortBy) => void; */
/* sortOrd: SortOrd; */
/* setSortOrd: (sort: SortOrd) => void; */
/* anchor: HTMLElement; */
/* onClose: () => void; */
/* }) => { */
/* const router = useRouter(); */
/* const { t } = useTranslation("browse"); */
/* return ( */
/* <Menu */
/* id="sortby-menu" */
/* MenuListProps={{ */
/* "aria-labelledby": "sortby", */
/* }} */
/* anchorEl={anchor} */
/* open={!!anchor} */
/* onClose={onClose} */
/* > */
/* {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 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 { t } = useTranslation("browse"); */
/* const switchViewTitle = */
/* layout === Layout.Grid ? t("browse.switchToList") : t("browse.switchToGrid"); */
/* return ( */
/* <> */
/* <Box sx={{ display: "flex", justifyContent: "space-around" }}> */
/* <ButtonGroup sx={{ m: 1 }}> */
/* <Button disabled> */
/* <FilterList /> */
/* </Button> */
/* <Tooltip title={t("browse.sortby-tt")}> */
/* <Button */
/* id="sortby" */
/* aria-label={t("browse.sortby-tt")} */
/* aria-controls={sortAnchor ? "sorby-menu" : undefined} */
/* aria-haspopup="true" */
/* aria-expanded={sortAnchor ? "true" : undefined} */
/* onClick={(event) => setSortAnchor(event.currentTarget)} */
/* > */
/* <Sort /> */
/* {t("browse.sortby", { key: t(`browse.sortkey.${sortKey}`) })} */
/* {sortOrd === SortOrd.Asc ? <South fontSize="small" /> : <North fontSize="small" />} */
/* </Button> */
/* </Tooltip> */
/* <Tooltip title={switchViewTitle}> */
/* <Button */
/* onClick={() => setLayout(layout === Layout.List ? Layout.Grid : Layout.List)} */
/* aria-label={switchViewTitle} */
/* > */
/* {layout === Layout.List ? <GridView /> : <ViewList />} */
/* </Button> */
/* </Tooltip> */
/* </ButtonGroup> */
/* </Box> */
/* {sortAnchor && ( */
/* <SortByMenu */
/* sortKey={sortKey} */
/* sortOrd={sortOrd} */
/* setSort={setSort} */
/* setSortOrd={setSortOrd} */
/* anchor={sortAnchor} */
/* onClose={() => setSortAnchor(null)} */
/* /> */
/* )} */
/* </> */
/* ); */
/* }; */
/* 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,294 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
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 { MovieDetails } from "@kyoo/ui";
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";
import NextLink from "next/link";
const StudioText = ({
studio,
loading = false,
sx,
}: {
studio?: Studio | null;
loading?: boolean;
sx?: SxProps;
}) => {
const { t } = useTranslation("browse");
if (!loading && !studio) return null;
return (
<Typography sx={sx}>
{t("show.studio")}:{" "}
{loading ? (
<Skeleton width="5rem" sx={{ display: "inline-flex" }} />
) : (
<Link href={`/studio/${studio!.slug}`}>{studio!.name}</Link>
)}
</Typography>
);
};
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 */}
<Navbar
position="fixed"
elevation={0}
sx={{ backgroundColor: `rgba(0, 0, 0, ${0.4 /*+ scroll / 1000*/})` }}
/>
<Image
img={data?.thumbnail}
alt=""
loading={!data}
width="100%"
height={{ xs: "40vh", sm: "60vh", lg: "70vh" }}
sx={{
minHeight: { xs: "350px", sm: "400px", lg: "550px" },
position: "relative",
"&::after": {
content: '""',
position: "absolute",
top: 0,
bottom: 0,
right: 0,
left: 0,
background: "linear-gradient(to bottom, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0.6) 100%)",
},
}}
/>
<Container
sx={{
position: "relative",
marginTop: { xs: "-30%", sm: "-25%", md: "-15rem", lg: "-21rem", xl: "-23rem" },
display: "flex",
flexDirection: { xs: "column", sm: "row" },
alignItems: { xs: "center", sm: "unset" },
textAlign: { xs: "center", sm: "unset" },
}}
>
<Poster
img={data?.poster}
alt={data?.name ?? ""}
loading={!data}
width={{ xs: "50%", md: "25%" }}
sx={{ maxWidth: { xs: "175px", sm: "unset" }, flexShrink: 0 }}
/>
<Box sx={{ alignSelf: { xs: "center", sm: "end", md: "center" }, pl: { sm: "2.5rem" } }}>
<Typography
variant="h3"
component="h1"
sx={{
color: { md: "white" },
fontWeight: { md: 900 },
mb: ".5rem",
}}
>
{data?.name ?? <Skeleton width="15rem" />}
</Typography>
{(!data || getDisplayDate(data)) && (
<Typography
component="p"
variant="h5"
sx={{ color: { md: "white" }, fontWeight: 300, mb: ".5rem" }}
>
{data != undefined ? (
getDisplayDate(data)
) : (
<Skeleton width="5rem" sx={{ mx: { xs: "auto", sm: "unset" } }} />
)}
</Typography>
)}
<Box sx={{ "& > *": { m: ".3rem !important" } }}>
<Tooltip title={t("show.play")}>
<NextLink href={data ? `/watch/${data.slug}` : ""} passHref>
<Fab color="primary" size="small" aria-label={t("show.play")}>
<PlayArrow />
</Fab>
</NextLink>
</Tooltip>
<Tooltip title={t("show.trailer")} aria-label={t("show.trailer")}>
<IconButton>
<LocalMovies sx={{ color: { md: "white" } }} />
</IconButton>
</Tooltip>
</Box>
</Box>
<Box
sx={{
display: { xs: "none", md: "flex" },
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: "25%",
flexDirection: "column",
alignSelf: "end",
pr: "15px",
}}
>
{data?.logo && (
<Image
img={data.logo}
alt=""
width="100%"
height="100px"
sx={{ display: { xs: "none", lg: "unset" } }}
/>
)}
<StudioText loading={!data} studio={data?.studio} sx={{ mt: "auto", mb: 3 }} />
</Box>
</Container>
<Container sx={{ display: { xs: "block", sm: "none" }, pt: 3 }}>
<StudioText loading={!data} studio={data?.studio} sx={{ mb: 1 }} />
<Typography sx={{ mb: 1 }}>
{t("show.genre")}
{": "}
{!data ? (
<Skeleton width="10rem" sx={{ display: "inline-flex" }} />
) : data?.genres && data.genres.length ? (
data.genres.map((genre, i) => [
i > 0 && ", ",
<Link key={genre.id} href={`/genres/${genre.slug}`}>
{genre.name}
</Link>,
])
) : (
t("show.genre-none")
)}
</Typography>
</Container>
<Container sx={{ pt: 2 }}>
<Typography align="justify" sx={{ flexBasis: 0, flexGrow: 1, pt: { sm: 2 } }}>
{data
? data.overview ?? t("show.noOverview")
: [...Array(4)].map((_, i) => <Skeleton key={i} />)}
</Typography>
<Divider
orientation="vertical"
variant="middle"
flexItem
sx={{ mx: 2, display: { xs: "none", sm: "block" } }}
/>
<Box sx={{ flexBasis: "25%", display: { xs: "none", sm: "block" } }}>
<StudioText
loading={!data}
studio={data?.studio}
sx={{ display: { xs: "none", sm: "block", md: "none" }, pb: 2 }}
/>
<Typography variant="h4" component="h2">
{t("show.genre")}
</Typography>
{!data || data.genres?.length ? (
<ul>
{(data ? data.genres! : [...Array(3)]).map((genre, i) => (
<li key={genre?.id ?? i}>
<Typography>
{genre ? (
<Link href={`/genres/${genre.slug}`}>{genre.name}</Link>
) : (
<Skeleton />
)}
</Typography>
</li>
))}
</ul>
) : (
<Typography>{t("show.genre-none")}</Typography>
)}
</Box>
</Container>
</>
);
};
export const ShowStaff = ({ slug }: { slug: string }) => {
const { items, isError, error } = useInfiniteFetch(ShowStaff.query(slug));
const { t } = useTranslation("browse");
// TODO: handle infinite scroll
if (isError) return <ErrorComponent {...error} />;
return (
<HorizontalList title={t("show.staff")} noContent={t("show.staff-none")}>
{(items ?? [...Array(20)]).map((x, i) => (
<PersonAvatar
key={x ? x.id : i}
person={x}
sx={{ width: { xs: "7rem", lg: "10rem" }, flexShrink: 0, px: 2 }}
/>
))}
</HorizontalList>
);
};
ShowStaff.query = (slug: string): QueryIdentifier<Person> => ({
parser: PersonP,
path: ["shows", slug, "people"],
infinite: true,
});
const query = (slug: string): QueryIdentifier<Movie> => ({
parser: MovieP,
path: ["shows", slug],
params: {
fields: ["genres", "studio"],
},
});
const MovieDetails: 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} />
</>
);
};
MovieDetails.getFetchUrls = ({ slug }) => [query(slug), ShowStaff.query(slug), Navbar.query()];
export default withRoute(MovieDetails);

View File

@ -0,0 +1,44 @@
/*
* 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 { View, ViewProps } from "react-native";
import { useYoshiki } from "yoshiki/native";
export const Container = (props: ViewProps) => {
const { css } = useYoshiki();
return (
<View
{...css(
{
display: "flex",
paddingHorizontal: "15px",
marginHorizontal: "auto",
width: {
sm: "540px",
md: "880px",
lg: "1170px",
},
},
props,
)}
/>
);
};

View File

@ -19,28 +19,69 @@
*/
import MIcon from "@expo/vector-icons/MaterialIcons";
import { ComponentProps } from "react";
import { Pressable, useTheme } from "yoshiki/native";
import { ComponentProps, ComponentType } from "react";
import { PressableProps } from "react-native";
import { Pressable, px, useYoshiki } from "yoshiki/native";
import { Breakpoint, ts } from ".";
export type IconProps = {
icon: ComponentProps<typeof MIcon>["name"];
size?: number;
color?: string;
color?: Breakpoint<string>;
};
export const Icon = ({ icon, size, color }: IconProps) => {
return <MIcon name={icon} size={size ?? 24} color={color ?? "white"} />;
export const Icon = ({ icon, size = 24, color }: IconProps) => {
const { css, theme } = useYoshiki();
return (
<MIcon
name={icon}
size={size}
{...css({ color: color ?? theme.colors.white, width: size, height: size })}
/>
);
};
export const IconButton = ({
export const IconButton = <AsProps = PressableProps,>({
icon,
size,
color,
...props
}: ComponentProps<typeof Pressable> & IconProps) => {
as,
...asProps
}: IconProps & { as?: ComponentType<AsProps> } & AsProps) => {
const { css } = useYoshiki();
const Container = as ?? Pressable;
return (
<Pressable {...props}>
<Container
{...(css(
{
p: ts(1),
m: px(2),
borderRadius: 9999,
},
asProps,
) as AsProps)}
>
<Icon icon={icon} size={size} color={color} />
</Pressable>
</Container>
);
};
export const IconFab = <AsProps = PressableProps,>(
props: ComponentProps<typeof IconButton<AsProps>>,
) => {
const { css, theme } = useYoshiki();
return (
<IconButton
colors={theme.colors.black}
{...(css(
{
bg: (theme) => theme.accent,
},
props,
) as any)}
/>
);
};

View File

@ -132,8 +132,8 @@ export const ImageBackground = <AsProps = ViewProps,>({
as?: ComponentType<AsProps>;
gradient?: Partial<LinearGradientProps> | boolean;
children: ReactNode;
containerStyle?: StyleList<ViewStyle>;
imageStyle?: StyleList<ImageStyle>;
containerStyle?: YoshikiEnhanced<ViewStyle>;
imageStyle?: YoshikiEnhanced<ImageStyle>;
} & AsProps &
Props) => {
const [isErrored, setErrored] = useState(false);

View File

@ -18,7 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
export { Header, Nav, Footer } from "@expo/html-elements";
export { Header, Main, Nav, Footer } from "@expo/html-elements";
export * from "./text";
export * from "./themes";
export * from "./icons";
@ -27,23 +27,15 @@ export * from "./avatar";
export * from "./image";
export * from "./skeleton";
export * from "./tooltip";
export * from "./container";
export * from "./animated";
export * from "./utils/breakpoints";
export * from "./utils/nojs";
import { Dimensions } from "react-native";
import { px } from "yoshiki/native";
export const ts = (spacing: number) => {
return px(spacing * 8);
};
export const vw = (spacing: number) => {
return px(spacing * Dimensions.get('window').width / 100);
};
export const vh = (spacing: number) => {
return px(spacing * Dimensions.get('window').height / 100);
};

View File

@ -69,7 +69,6 @@ export const Skeleton = ({
borderRadius: px(6),
},
variant === "text" && {
margin: rem(1),
width: percent(75),
height: rem(1.2),
},

View File

@ -63,7 +63,7 @@ export const catppuccin: ThemeBuilder = {
overlay1: "#9399b2",
default: {
background: "#1e1e2e",
accent: "##f5c2e7",
accent: "#f5c2e7",
divider: "#7f849c",
heading: "#cdd6f4",
paragraph: "#bac2de",

View File

@ -23,6 +23,7 @@ import { Property } from "csstype";
import { Theme, ThemeProvider } from "yoshiki";
import { useTheme, useYoshiki } from "yoshiki/native";
import "yoshiki";
import "yoshiki/native";
import { catppuccin } from "./catppuccin";
type ThemeSettings = {
@ -57,11 +58,19 @@ type Variant = {
};
declare module "yoshiki" {
// TODO: Add specifics colors
export interface Theme extends ThemeSettings, Mode, Variant {
builder: ThemeBuilder;
light: Mode & Variant;
dark: Mode & Variant;
user: Mode & Variant;
}
}
// declare module "yoshiki/native" {
// export interface Theme extends ThemeSettings, Mode, Variant {
// light: Mode & Variant;
// dark: Mode & Variant;
// user: Mode & Variant;
// }
// }
export type { Theme } from "yoshiki";
export type ThemeBuilder = ThemeSettings & {
@ -69,14 +78,21 @@ export type ThemeBuilder = ThemeSettings & {
dark: Mode & { default: Variant };
};
export const selectMode = (theme: ThemeBuilder, mode: "light" | "dark"): Theme => {
const { light, dark, ...options } = theme;
const selectMode = (theme: ThemeBuilder, mode: "light" | "dark"): Theme => {
const { light: lightBuilder, dark: darkBuilder, ...options } = theme;
const light = { ...lightBuilder, ...lightBuilder.default };
const dark = { ...darkBuilder, ...darkBuilder.default };
const value = mode === "light" ? light : dark;
const { default: def, ...modeOpt } = value;
return { ...options, ...modeOpt, ...def, variant: value.variant, builder: theme };
return {
...options,
...value,
light,
dark,
user: value,
};
};
export const switchVariant = (theme: Theme) => {
const switchVariant = (theme: Theme) => {
return {
...theme,
...theme.variant,
@ -122,7 +138,7 @@ export const ContrastArea = ({
contrastText?: boolean;
}) => {
const oldTheme = useTheme();
const theme = selectMode(oldTheme.builder, mode);
const theme: Theme = { ...oldTheme, ...oldTheme[mode] };
return (
<ThemeProvider

View File

@ -18,17 +18,21 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { ToastAndroid, PressableProps } from "react-native";
import { ToastAndroid, Platform } from "react-native";
import { Theme } from "yoshiki/native";
export const tooltip = (tooltip: string) =>
({
dataSet: { tooltip },
onLongPress: () => {
// TODO handle IOS.
ToastAndroid.show(tooltip, ToastAndroid.SHORT);
Platform.select({
web: {
dataSet: { tooltip, label: tooltip },
},
} satisfies PressableProps);
android: {
onLongPress: () => {
ToastAndroid.show(tooltip, ToastAndroid.SHORT);
},
},
default: {},
});
export const WebTooltip = ({ theme }: { theme: Theme }) => {
const background = `${theme.colors.black}CC`;
@ -41,6 +45,7 @@ export const WebTooltip = ({ theme }: { theme: Theme }) => {
[data-tooltip]::after {
content: attr(data-tooltip);
display: flex;
position: absolute;
top: 100%;
@ -55,6 +60,7 @@ export const WebTooltip = ({ theme }: { theme: Theme }) => {
color: ${theme.colors.white};
background-color: ${background};
font-family: ${theme.fonts.paragraph};
text-align: center;
opacity: 0;
visibility: hidden;

View File

@ -18,6 +18,8 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
export {}
// const SortByMenu = ({
// sortKey,
// setSort,

View File

@ -0,0 +1,308 @@
/*
* 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 { Movie, QueryIdentifier, Show, getDisplayDate } from "@kyoo/models";
import {
Container,
H1,
Main,
ImageBackground,
Skeleton,
Poster,
P,
tooltip,
Link,
IconButton,
IconFab,
} from "@kyoo/primitives";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, View } from "react-native";
import { em, percent, rem, vh, useYoshiki, Stylable } from "yoshiki/native";
import { Fetch, WithLoading } from "../fetch";
import { Navbar } from "../navbar";
// const StudioText = ({
// studio,
// loading = false,
// sx,
// }: {
// studio?: Studio | null;
// loading?: boolean;
// sx?: SxProps;
// }) => {
// const { t } = useTranslation("browse");
// if (!loading && !studio) return null;
// return (
// <Typography sx={sx}>
// {t("show.studio")}:{" "}
// {loading ? (
// <Skeleton width="5rem" sx={{ display: "inline-flex" }} />
// ) : (
// <Link href={`/studio/${studio!.slug}`}>{studio!.name}</Link>
// )}
// </Typography>
// );
// };
const TitleLine = ({
isLoading,
slug,
name,
date,
poster,
...props
}: {
isLoading: boolean;
slug: string;
name?: string;
date?: string;
poster?: string | null;
} & Stylable) => {
const { css, theme } = useYoshiki();
const { t } = useTranslation();
return (
<Container
{...css(
{
flexDirection: { xs: "column", sm: "row" },
alignItems: { xs: "center", sm: "flex-start" },
},
props,
)}
>
<Poster
src={poster}
alt={name}
isLoading={isLoading}
layout={{
width: { xs: percent(50), md: percent(25) },
}}
{...css({ maxWidth: { xs: px(175), sm: "unset" }, flexShrink: 0 })}
/>
<View
{...css({
alignSelf: { xs: "center", sm: "flex-end", md: "center" },
alignItems: { xs: "center", sm: "flex-start" },
paddingLeft: { sm: em(2.5) },
flexShrink: 1,
})}
>
<Skeleton {...css({ width: rem(15), height: rem(3), marginBottom: rem(0.5) })}>
{isLoading || (
<H1
{...css({
fontWeight: { md: "900" },
fontSize: rem(3),
marginTop: 0,
marginBottom: rem(0.5),
textAlign: { xs: "center", sm: "flex-start" },
color: (theme) => ({ xs: theme.user.heading, md: theme.heading }),
})}
>
{name}
</H1>
)}
</Skeleton>
{(isLoading || date) && (
<Skeleton
{...css({
width: rem(5),
height: rem(1.5),
marginBottom: rem(0.5),
})}
>
{isLoading || (
<P
{...css({
fontWeight: "300",
fontSize: rem(1.5),
letterSpacing: 0,
marginTop: 0,
marginBottom: rem(0.5),
textAlign: { xs: "center", sm: "flex-start" },
color: (theme) => ({ xs: theme.user.heading, md: theme.heading }),
})}
>
{date}
</P>
)}
</Skeleton>
)}
<View {...css({ flexDirection: "row" })} /*sx={{ "& > *": { m: ".3rem !important" } }} */>
<IconFab
icon="play-arrow"
as={Link}
href={`/watch/${slug}`}
color={{ xs: theme.user.colors.black, md: theme.colors.black }}
{...css({ bg: { xs: theme.user.accent, md: theme.accent } })}
{...tooltip(t("show.play"))}
/>
<IconButton
icon="local-movies"
color={{ xs: theme.user.colors.black, md: theme.colors.white }}
{...tooltip(t("show.trailer"))}
/>
</View>
</View>
{/* <View */}
{/* {...css({ */}
{/* display: { xs: "none", md: "flex" }, */}
{/* flexDirection: "column", */}
{/* alignSelf: "flex-end", */}
{/* paddingRight: px(15), */}
{/* })} */}
{/* > */}
{/* {(isLoading || logo || true) && ( */}
{/* <Image */}
{/* src={logo} */}
{/* alt="" */}
{/* layout={{ */}
{/* width: "100%", */}
{/* height: px(100), */}
{/* }} */}
{/* // sx={{ display: { xs: "none", lg: "unset" } }} */}
{/* /> */}
{/* )} */}
{/* {/1* <StudioText loading={!data} studio={data?.studio} sx={{ mt: "auto", mb: 3 }} /> *1/} */}
{/* </View> */}
</Container>
);
};
// const Tata = () => {
// return (
// <Container sx={{ pt: 2 }}>
// <Typography align="justify" sx={{ flexBasis: 0, flexGrow: 1, pt: { sm: 2 } }}>
// {data
// ? data.overview ?? t("show.noOverview")
// : [...Array(4)].map((_, i) => <Skeleton key={i} />)}
// </Typography>
// <Divider
// orientation="vertical"
// variant="middle"
// flexItem
// sx={{ mx: 2, display: { xs: "none", sm: "block" } }}
// />
// <Box sx={{ flexBasis: "25%", display: { xs: "none", sm: "block" } }}>
// <StudioText
// loading={!data}
// studio={data?.studio}
// sx={{ display: { xs: "none", sm: "block", md: "none" }, pb: 2 }}
// />
// <Typography variant="h4" component="h2">
// {t("show.genre")}
// </Typography>
// {!data || data.genres?.length ? (
// <ul>
// {(data ? data.genres! : [...Array(3)]).map((genre, i) => (
// <li key={genre?.id ?? i}>
// <Typography>
// {genre ? <Link href={`/genres/${genre.slug}`}>{genre.name}</Link> : <Skeleton />}
// </Typography>
// </li>
// ))}
// </ul>
// ) : (
// <Typography>{t("show.genre-none")}</Typography>
// )}
// </Box>
// </Container>
// );
// };
const min = Platform.OS === "web"
? (...values: number[]): number => `min(${values.join(", ")})` as unknown as number
: (...values: number[]): number => Math.min(...values);
const max = Platform.OS === "web"
? (...values: number[]): number => `max(${values.join(", ")})` as unknown as number
: (...values: number[]): number => Math.max(...values);
const px = Platform.OS === "web"
? (value: number): number => `${value}px` as unknown as number
: (value: number): number => value;
export const ShowHeader = ({
query,
slug,
}: {
query: QueryIdentifier<Show | Movie>;
slug: string;
}) => {
/* const scroll = useScroll(); */
const { css } = useYoshiki();
// TODO: tweek the navbar color with the theme.
return (
<>
<Navbar {...css({ bg: "transparent" })} />
<Fetch query={query}>
{({ isLoading, ...data }) => (
<>
{/* TODO: HEAD element for SEO*/}
{/* TODO: Add a shadow on navbar items */}
{/* TODO: Put the navbar outside of the scrollbox */}
<ImageBackground
src={data?.thumbnail}
alt=""
as={Main}
containerStyle={{
height: { xs: vh(40), sm: min(vh(60), px(750)), lg: vh(70) },
minHeight: { xs: px(350), sm: px(500), lg: px(600) },
}}
{...css(StyleSheet.absoluteFillObject)}
>
<TitleLine
isLoading={isLoading}
slug={slug}
name={data?.name}
date={data ? getDisplayDate(data as any) : undefined}
poster={data?.poster}
{...css({
marginTop: { xs: max(vh(20), px(200)), sm: vh(45), md: vh(35) }
})}
/>
{/* <Container sx={{ display: { xs: "block", sm: "none" }, pt: 3 }}> */}
{/* <StudioText loading={!data} studio={data?.studio} sx={{ mb: 1 }} /> */}
{/* <Typography sx={{ mb: 1 }}> */}
{/* {t("show.genre")} */}
{/* {": "} */}
{/* {!data ? ( */}
{/* <Skeleton width="10rem" sx={{ display: "inline-flex" }} /> */}
{/* ) : data?.genres && data.genres.length ? ( */}
{/* data.genres.map((genre, i) => [ */}
{/* i > 0 && ", ", */}
{/* <Link key={genre.id} href={`/genres/${genre.slug}`}> */}
{/* {genre.name} */}
{/* </Link>, */}
{/* ]) */}
{/* ) : ( */}
{/* t("show.genre-none") */}
{/* )} */}
{/* </Typography> */}
{/* </Container> */}
</ImageBackground>
</>
)}
</Fetch>
</>
);
};

View File

@ -0,0 +1,47 @@
/*
* 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 { Movie, MovieP, QueryIdentifier, QueryPage } from "@kyoo/models";
import { ShowHeader } from "./header";
const query = (slug: string): QueryIdentifier<Movie> => ({
parser: MovieP,
path: ["shows", slug],
params: {
fields: ["genres", "studio"],
},
});
export const MovieDetails: QueryPage<{ slug: string }> = ({ slug }) => {
return (
<>
{/* <Head> */}
{/* <title>{makeTitle(data?.name)}</title> */}
{/* <meta name="description" content={data?.overview!} /> */}
{/* </Head> */}
<ShowHeader slug={slug} query={query(slug)} />
{/* <ShowStaff slug={slug} /> */}
</>
);
};
MovieDetails.getFetchUrls = ({ slug }) => [//query(slug),
// ShowStaff.query(slug), Navbar.query()
];

View File

@ -0,0 +1,47 @@
/*
* 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/>.
*/
export {}
// export const ShowStaff = ({ slug }: { slug: string }) => {
// const { items, isError, error } = useInfiniteFetch(ShowStaff.query(slug));
// const { t } = useTranslation("browse");
// // TODO: handle infinite scroll
// if (isError) return <ErrorComponent {...error} />;
// return (
// <HorizontalList title={t("show.staff")} noContent={t("show.staff-none")}>
// {(items ?? [...Array(20)]).map((x, i) => (
// <PersonAvatar
// key={x ? x.id : i}
// person={x}
// sx={{ width: { xs: "7rem", lg: "10rem" }, flexShrink: 0, px: 2 }}
// />
// ))}
// </HorizontalList>
// );
// };
// ShowStaff.query = (slug: string): QueryIdentifier<Person> => ({
// parser: PersonP,
// path: ["shows", slug, "people"],
// infinite: true,
// });

View File

@ -34,11 +34,11 @@ const isPage = <T = unknown,>(obj: unknown): obj is Page<T> =>
export const Fetch = <Data,>({
query,
placeholderCount,
placeholderCount = 1,
children,
}: {
query: QueryIdentifier<Data>;
placeholderCount: number;
placeholderCount?: number;
children: (
item: Data extends Page<infer Item> ? WithLoading<Item> : WithLoading<Data>,
i: number,
@ -52,7 +52,9 @@ export const Fetch = <Data,>({
<>{[...Array(placeholderCount)].map((_, i) => children({ isLoading: true } as any, i))}</>
);
if (!isPage<object>(data))
return children(data ? { ...data, isLoading: false } : ({ isLoading: true } as any), 0);
return (
<> {children(data ? { ...data, isLoading: false } : ({ isLoading: true } as any), 0)} </>
);
return <>{data.items.map((item, i) => children({ ...item, isLoading: false } as any, i))}</>;
};

View File

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

View File

@ -96,7 +96,7 @@ export const Navbar = (props: Stylable) => {
{library.name}
</A>
) : (
<Skeleton key={i} {...css({ width: rem(5) })} />
<Skeleton key={i} {...css({ width: rem(5), marginX: ts(1) })} />
)
}
</Fetch>

View File

@ -9682,7 +9682,7 @@ __metadata:
react-native-screens: ~3.18.0
react-native-svg: 13.4.0
typescript: ^4.6.3
yoshiki: 0.2.9
yoshiki: 0.2.11
languageName: unknown
linkType: soft
@ -13319,7 +13319,7 @@ __metadata:
superjson: ^1.11.0
typescript: ^4.9.3
webpack: ^5.75.0
yoshiki: 0.2.9
yoshiki: 0.2.11
zod: ^3.19.1
languageName: unknown
linkType: soft
@ -13644,9 +13644,9 @@ __metadata:
languageName: node
linkType: hard
"yoshiki@npm:0.2.9":
version: 0.2.9
resolution: "yoshiki@npm:0.2.9"
"yoshiki@npm:0.2.11":
version: 0.2.11
resolution: "yoshiki@npm:0.2.11"
dependencies:
"@types/node": 18.x.x
"@types/react": 18.x.x
@ -13661,7 +13661,7 @@ __metadata:
optional: true
react-native-web:
optional: true
checksum: 41ff5ff7e4cd99b2bc7453749a1f17ceb63bc9e1123285bc62a12315cac1fb087b58366db50e08b275e7dd0b1ce862df4c3acae51872f8e3aa8765ee9b61d4bf
checksum: 5a2bbb62b2270d3456f114cfbb24a84ad6b8a94b147687929ccffe2d179560ef40b46df1d4054eda91310d295a9a674bbb201765deb86dc96a2133bfd702235a
languageName: node
linkType: hard