Rewrite the browse page (part 1)

This commit is contained in:
Zoe Roux 2022-12-08 17:38:42 +09:00
parent 1f049952cc
commit 43ed65bc76
20 changed files with 1032 additions and 551 deletions

View File

@ -0,0 +1,3 @@
import { BrowsePage } from "@kyoo/ui";
export default BrowsePage

View File

@ -18,19 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { Navbar } from "@kyoo/ui";
import { Text, View } from "react-native";
import { useYoshiki } from "yoshiki/native";
import BrowsePage from "./browse";
const App = () => {
const { css } = useYoshiki();
return (
<View {...css({ backgroundColor: (theme) => theme.background })}>
<Navbar />
<Text>toto</Text>
</View>
);
};
export default App;
// While there is no home page, show the browse page.
export default BrowsePage;

View File

@ -1,33 +0,0 @@
{
"show": {
"play": "Play",
"trailer": "Play Trailer",
"studio": "Studio",
"genre": "Genres",
"genre-none": "No genres",
"staff": "Staff",
"staff-none": "The staff is unknown",
"noOverview": "No overview available",
"episode-none": "There is no episodes in this season",
"episodeNoMetadata": "No metadata available"
},
"browse": {
"sortby": "Sort by {{key}}",
"sortby-tt": "Sort by",
"sortkey": {
"name": "Name",
"startAir": "Start air",
"endAir": "End air"
},
"sortord": {
"asc": "asc",
"desc": "decs"
},
"switchToGrid": "Switch to grid view",
"switchToList": "Switch to list view"
},
"misc": {
"prev-page": "Previous page",
"next-page": "Next page"
}
}

View File

@ -1,6 +0,0 @@
{
"navbar": {
"home": "Home",
"login": "Login"
}
}

View File

@ -1,12 +0,0 @@
{
"back": "Back",
"previous": "Previous episode",
"next": "Next episode",
"play": "Play",
"pause": "Pause",
"mute": "Toggle mute",
"volume": "Volume",
"subtitles": "Subtitles",
"subtitle-none": "None",
"fullscreen": "Fullscreen"
}

View File

@ -1,33 +0,0 @@
{
"show": {
"play": "Lecture",
"trailer": "Jouer le trailer",
"studio": "Studio",
"genre": "Genres",
"genre-none": "Aucun genres",
"staff": "Staff",
"staff-none": "Aucun membre du staff connu",
"noOverview": "Aucune description disponible",
"episode-none": "Il n'y a pas d'épisodes dans cette saison",
"episodeNoMetadata": "Aucune metadonnée disponible"
},
"browse": {
"sortby": "Trier par {{key}}",
"sortby-tt": "Trier par",
"sortkey": {
"name": "Nom",
"startAir": "Date de sortie",
"endAir": "Date de fin de sortie"
},
"sortord": {
"asc": "asc",
"desc": "decs"
},
"switchToGrid": "Passer en vue grille",
"switchToList": "Passer en vue liste"
},
"misc": {
"prev-page": "Page précédente",
"next-page": "Page suivante"
}
}

View File

@ -1,6 +0,0 @@
{
"navbar": {
"home": "Accueil",
"login": "Connexion"
}
}

View File

@ -1,12 +0,0 @@
{
"back": "Retour",
"previous": "Episode précédent",
"next": "Episode suivant",
"play": "Jouer",
"pause": "Pause",
"mute": "Muet",
"volume": "Volume",
"subtitles": "Sous titres",
"subtitle-none": "Aucun",
"fullscreen": "Plein-écran"
}

View File

@ -100,11 +100,15 @@ App.getInitialProps = async (ctx: AppContext) => {
const appProps = await NextApp.getInitialProps(ctx);
const getUrl = (ctx.Component as QueryPage).getFetchUrls;
const urls: QueryIdentifier[] = getUrl ? getUrl(ctx.router.query as any) : [];
const getLayoutUrl = ((ctx.Component as QueryPage).getLayout as QueryPage)?.getFetchUrls;
const urls: QueryIdentifier[] = [
...(getUrl ? getUrl(ctx.router.query as any) : []),
...(getLayoutUrl ? getLayoutUrl(ctx.router.query as any) : []),
];
appProps.pageProps.queryState = await fetchQuery(urls);
return { pageProps: superjson.serialize(appProps.pageProps) };
};
export default withTranslations(App);

View File

@ -18,422 +18,427 @@
* 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,
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 { BrowsePage } from "@kyoo/ui";
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);
/* 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

@ -23,11 +23,19 @@ import { Skeleton as MSkeleton } from "moti/skeleton";
import { ComponentProps } from "react";
import { useYoshiki, rem, px, Stylable } from "yoshiki/native";
export const Skeleton = ({ style, ...props }: ComponentProps<typeof MSkeleton> & Stylable) => {
export const Skeleton = ({
style,
children,
...props
}: Omit<ComponentProps<typeof MSkeleton>, "children"> & {
children: ComponentProps<typeof MSkeleton>["children"] | boolean;
} & Stylable) => {
const { css } = useYoshiki();
return (
<View {...css({ margin: px(2) }, { style })}>
<MSkeleton colorMode="light" radius={6} height={rem(1.2)} {...props} />
<MSkeleton colorMode="light" radius={6} height={rem(1.2)} {...props}>
{children !== true ? children || undefined : undefined}
</MSkeleton>
</View>
);
};

View File

@ -31,17 +31,23 @@ import {
P as EP,
} from "@expo/html-elements";
const styleText = (Component: ComponentType<ComponentProps<typeof EP>>, heading?: boolean) => {
const styleText = (
Component: ComponentType<ComponentProps<typeof EP>>,
type?: "header" | "sub",
) => {
const Text = (props: ComponentProps<typeof EP>) => {
const { css, theme } = useYoshiki();
return (
<Component
{...css(
{
fontFamily: heading ? theme.fonts.heading : theme.fonts.paragraph,
color: heading ? theme.heading : theme.paragraph,
},
[
{
fontFamily: type === "header" ? theme.fonts.heading : theme.fonts.paragraph,
color: type === "header" ? theme.heading : theme.paragraph,
},
type === "sub" && { fontWeight: "300" },
],
props as TextProps,
)}
/>
@ -50,10 +56,11 @@ const styleText = (Component: ComponentType<ComponentProps<typeof EP>>, heading?
return Text;
};
export const H1 = styleText(EH1, true);
export const H2 = styleText(EH2, true);
export const H3 = styleText(EH3, true);
export const H4 = styleText(EH4, true);
export const H5 = styleText(EH5, true);
export const H6 = styleText(EH6, true);
export const H1 = styleText(EH1, "header");
export const H2 = styleText(EH2, "header");
export const H3 = styleText(EH3, "header");
export const H4 = styleText(EH4, "header");
export const H5 = styleText(EH5, "header");
export const H6 = styleText(EH6, "header");
export const P = styleText(EP);
export const SubP = styleText(EP, "sub");

View File

@ -0,0 +1,64 @@
/*
* 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 { A, Skeleton, Poster, ts, P, SubP } from "@kyoo/primitives";
import { percent, px, Stylable, useYoshiki } from "yoshiki/native";
import { WithLoading } from "../fetch";
export const ItemGrid = ({
href,
name,
subtitle,
poster,
isLoading,
...props
}: WithLoading<{
href: string;
name: string;
subtitle?: string;
poster?: string | null;
}> &
Stylable<"text">) => {
const { css } = useYoshiki();
return (
<A
href={href ?? ""}
{...css(
{
display: "flex",
flexDirection: "column",
alignItems: "center",
width: { xs: percent(18), sm: percent(25) },
minWidth: { xs: px(90), sm: px(120) },
maxWidth: px(168),
m: { xs: ts(1), sm: ts(2) },
},
props,
)}
>
<Poster src={poster} alt={name} width={percent(100)} />
<Skeleton width={percent(80)}>{isLoading || <P>{name}</P>}</Skeleton>
{(isLoading || subtitle) && (
<Skeleton width={percent(50)}>{isLoading || <SubP>{subtitle}</SubP>}</Skeleton>
)}
</A>
);
};

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 { ComponentProps, useState } from "react";
import {
QueryIdentifier,
QueryPage,
LibraryItem,
LibraryItemP,
ItemType,
getDisplayDate,
} from "@kyoo/models";
import { DefaultLayout } from "../layout";
import { InfiniteFetch, WithLoading } from "../fetch";
import { ItemGrid } from "./grid";
import { SortBy, SortOrd, Layout } from "./types";
const itemMap = (item: WithLoading<LibraryItem>): WithLoading<ComponentProps<typeof ItemGrid>> => {
if (item.isLoading) return item;
let href;
if (item?.type === ItemType.Movie) href = `/movie/${item.slug}`;
else if (item?.type === ItemType.Show) href = `/show/${item.slug}`;
else href = `/collection/${item.slug}`;
return {
isLoading: item.isLoading,
name: item.name,
subtitle: item.type !== ItemType.Collection ? getDisplayDate(item) : undefined,
href,
poster: item.poster,
};
};
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",
},
});
export const BrowsePage: QueryPage<{ slug?: string }> = ({ slug }) => {
const [sortKey, setSort] = useState(SortBy.Name);
const [sortOrd, setSortOrd] = useState(SortOrd.Asc);
const [layout, setLayout] = useState(Layout.Grid);
return (
<>
{/* <BrowseSettings */}
{/* sortKey={sortKey} */}
{/* setSort={setSort} */}
{/* sortOrd={sortOrd} */}
{/* setSortOrd={setSortOrd} */}
{/* layout={layout} */}
{/* setLayout={setLayout} */}
{/* /> */}
<InfiniteFetch
query={query(slug, sortKey, sortOrd)}
placeholderCount={15}
/* sx={{ */
/* display: "flex", */
/* flexWrap: "wrap", */
/* alignItems: "flex-start", */
/* justifyContent: "center", */
/* }} */
>
{(item, i) => <ItemGrid key={item?.id ?? i} {...itemMap(item)} />}
</InfiniteFetch>
</>
);
};
BrowsePage.getLayout = DefaultLayout;
BrowsePage.getFetchUrls = ({ slug, sortBy }) => [
query(slug, sortBy?.split("-")[0] as SortBy, sortBy?.split("-")[1] as SortOrd),
];

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/>.
*/
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>
);
};

View File

@ -0,0 +1,188 @@
/*
* 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/>.
*/
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)}
/>
)}
</>
);
};

View File

@ -0,0 +1,35 @@
/*
* 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 enum SortBy {
Name = "name",
StartAir = "startAir",
EndAir = "endAir",
}
export enum SortOrd {
Asc = "asc",
Desc = "desc",
}
export enum Layout {
Grid,
List,
}

View File

@ -18,12 +18,14 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { Page, QueryIdentifier, useFetch, KyooErrors } from "@kyoo/models";
import { Page, QueryIdentifier, useFetch, KyooErrors, useInfiniteFetch } from "@kyoo/models";
import { P } from "@kyoo/primitives";
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
export type WithLoading<Item> = (Item & { isLoading: false }) | { isLoading: true };
export type WithLoading<Item> =
| (Item & { isLoading: false })
| (Partial<Item> & { isLoading: true });
const isPage = <T = unknown,>(obj: unknown): obj is Page<T> =>
(typeof obj === "object" && obj && "items" in obj) || false;
@ -52,6 +54,29 @@ export const Fetch = <Data,>({
return <>{data.items.map((item, i) => children({ ...item, isLoading: false } as any, i))}</>;
};
export const InfiniteFetch = <Data,>({
query,
placeholderCount = 15,
children,
}: {
query: QueryIdentifier<Data>;
placeholderCount?: number;
children: (
item: Data extends Page<infer Item> ? WithLoading<Item> : WithLoading<Data>,
i: number,
) => JSX.Element | null;
}): JSX.Element | null => {
if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch.");
const { items, error } = useInfiniteFetch(query);
if (error) return <ErrorView error={error} />;
if (!items)
return (
<>{[...Array(placeholderCount)].map((_, i) => children({ isLoading: true } as any, i))}</>
);
return <>{items.map((item, i) => children({ ...item, isLoading: false } as any, i))}</>;
};
export const ErrorView = ({ error }: { error: KyooErrors }) => {
const { css } = useYoshiki();
@ -60,7 +85,8 @@ export const ErrorView = ({ error }: { error: KyooErrors }) => {
{...css({
backgroundColor: (theme) => theme.colors.red,
flex: 1,
alignItems: "center"
justifyContent: "center",
alignItems: "center",
})}
>
{error.errors.map((x, i) => (

View File

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

View File

@ -0,0 +1,36 @@
/*
* 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 { ReactElement } from "react";
import { Navbar } from "./navbar";
import { useYoshiki } from "yoshiki";
export const DefaultLayout = (page: ReactElement) => {
const { css } = useYoshiki();
return (
<>
<Navbar />
<main {...css({ flex: 1, display: "flex" })}>{page}</main>
</>
);
};
DefaultLayout.query = () => [Navbar.query()];