mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-09 03:04:20 -04:00
Remake details page for movies
This commit is contained in:
parent
26410734fc
commit
c7103410dc
@ -10,6 +10,6 @@ pkgs.mkShell {
|
|||||||
postgresql_15
|
postgresql_15
|
||||||
pgformatter
|
pgformatter
|
||||||
# to run tests
|
# to run tests
|
||||||
hurl
|
# hurl
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
3
front/app/(app)/movies/[slug].tsx
Normal file
3
front/app/(app)/movies/[slug].tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { MovieDetails } from "~/ui/details";
|
||||||
|
|
||||||
|
export default MovieDetails;
|
@ -41,8 +41,8 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { percent, px, useYoshiki } from "yoshiki/native";
|
import { percent, px, useYoshiki } from "yoshiki/native";
|
||||||
import { ItemGrid } from "../browse/grid";
|
import { ItemGrid } from "../browse/grid";
|
||||||
import { Header as ShowHeader, TitleLine } from "../details/header";
|
import { Header as ShowHeader, TitleLine } from "../../../../src/ui/details/headeri/details/header";
|
||||||
import { SvgWave } from "../details/show";
|
import { SvgWave } from "../../../../src/ui/details/show/ui/details/show";
|
||||||
import { Fetch } from "../fetch";
|
import { Fetch } from "../fetch";
|
||||||
import { InfiniteFetch } from "../fetch-infinite";
|
import { InfiniteFetch } from "../fetch-infinite";
|
||||||
import { ItemDetails } from "../home/recommended";
|
import { ItemDetails } from "../home/recommended";
|
||||||
|
@ -1,39 +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 { type Breakpoint, Icon, P, Skeleton, ts } from "@kyoo/primitives";
|
|
||||||
import Star from "@material-symbols/svg-400/rounded/star-fill.svg";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { rem, useYoshiki } from "yoshiki/native";
|
|
||||||
|
|
||||||
export const Rating = ({ rating, color }: { rating?: number; color: Breakpoint<string> }) => {
|
|
||||||
const { css } = useYoshiki();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...css({ flexDirection: "row", alignItems: "center" })}>
|
|
||||||
<Icon icon={Star} color={color} {...css({ marginRight: ts(0.5) })} />
|
|
||||||
<Skeleton {...css({ width: rem(2) })}>
|
|
||||||
{rating !== undefined && (
|
|
||||||
<P {...css({ color, verticalAlign: "middle" })}>{rating ? rating / 10 : "??"} / 10</P>
|
|
||||||
)}
|
|
||||||
</Skeleton>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,590 +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 {
|
|
||||||
type Genre,
|
|
||||||
type KyooImage,
|
|
||||||
type Movie,
|
|
||||||
type QueryIdentifier,
|
|
||||||
type Show,
|
|
||||||
type Studio,
|
|
||||||
getDisplayDate,
|
|
||||||
queryFn,
|
|
||||||
useAccount,
|
|
||||||
} from "@kyoo/models";
|
|
||||||
import type { WatchStatusV } from "@kyoo/models/src/resources/watch-status";
|
|
||||||
import {
|
|
||||||
A,
|
|
||||||
Chip,
|
|
||||||
Container,
|
|
||||||
DottedSeparator,
|
|
||||||
GradientImageBackground,
|
|
||||||
H1,
|
|
||||||
H2,
|
|
||||||
HR,
|
|
||||||
Head,
|
|
||||||
IconButton,
|
|
||||||
IconFab,
|
|
||||||
LI,
|
|
||||||
Link,
|
|
||||||
Menu,
|
|
||||||
P,
|
|
||||||
Poster,
|
|
||||||
Skeleton,
|
|
||||||
UL,
|
|
||||||
capitalize,
|
|
||||||
tooltip,
|
|
||||||
ts,
|
|
||||||
usePopup,
|
|
||||||
} from "@kyoo/primitives";
|
|
||||||
import Refresh from "@material-symbols/svg-400/rounded/autorenew.svg";
|
|
||||||
import Download from "@material-symbols/svg-400/rounded/download.svg";
|
|
||||||
import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg";
|
|
||||||
import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg";
|
|
||||||
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
|
|
||||||
import Theaters from "@material-symbols/svg-400/rounded/theaters-fill.svg";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { Fragment } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { type ImageStyle, Platform, View } from "react-native";
|
|
||||||
import {
|
|
||||||
type Stylable,
|
|
||||||
type Theme,
|
|
||||||
em,
|
|
||||||
max,
|
|
||||||
md,
|
|
||||||
min,
|
|
||||||
percent,
|
|
||||||
px,
|
|
||||||
rem,
|
|
||||||
useYoshiki,
|
|
||||||
vh,
|
|
||||||
} from "yoshiki/native";
|
|
||||||
import { MediaInfoPopup } from "../components/media-info";
|
|
||||||
import { Rating } from "../components/rating";
|
|
||||||
import { WatchListInfo } from "../components/watchlist-info";
|
|
||||||
import { useDownloader } from "../downloads";
|
|
||||||
import { Fetch } from "../fetch";
|
|
||||||
import { displayRuntime } from "./episode";
|
|
||||||
import { ShowWatchStatusCard } from "./show";
|
|
||||||
|
|
||||||
const ButtonList = ({
|
|
||||||
playHref,
|
|
||||||
trailerUrl,
|
|
||||||
watchStatus,
|
|
||||||
type,
|
|
||||||
slug,
|
|
||||||
}: {
|
|
||||||
type: "movie" | "show" | "collection";
|
|
||||||
slug?: string;
|
|
||||||
playHref?: string | null;
|
|
||||||
trailerUrl?: string | null;
|
|
||||||
watchStatus?: WatchStatusV | null;
|
|
||||||
}) => {
|
|
||||||
const account = useAccount();
|
|
||||||
const { css, theme } = useYoshiki();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const downloader = useDownloader();
|
|
||||||
const [setPopup, close] = usePopup();
|
|
||||||
|
|
||||||
const metadataRefreshMutation = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
queryFn({
|
|
||||||
path: [type, slug, "refresh"],
|
|
||||||
method: "POST",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...css({ flexDirection: "row", alignItems: "center", justifyContent: "center" })}>
|
|
||||||
{playHref !== null && (
|
|
||||||
<IconFab
|
|
||||||
icon={PlayArrow}
|
|
||||||
as={Link}
|
|
||||||
href={playHref}
|
|
||||||
color={{ xs: theme.user.colors.black, md: theme.colors.black }}
|
|
||||||
{...css({
|
|
||||||
bg: theme.user.accent,
|
|
||||||
fover: { self: { bg: theme.user.accent } },
|
|
||||||
})}
|
|
||||||
{...tooltip(t("show.play"))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{trailerUrl && (
|
|
||||||
<IconButton
|
|
||||||
icon={Theaters}
|
|
||||||
as={Link}
|
|
||||||
href={trailerUrl}
|
|
||||||
target="_blank"
|
|
||||||
color={{ xs: theme.user.contrast, md: theme.colors.white }}
|
|
||||||
{...tooltip(t("show.trailer"))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{watchStatus !== undefined && type !== "collection" && slug && (
|
|
||||||
<WatchListInfo
|
|
||||||
type={type}
|
|
||||||
slug={slug}
|
|
||||||
status={watchStatus}
|
|
||||||
color={{ xs: theme.user.contrast, md: theme.colors.white }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{((type === "movie" && slug) || account?.isAdmin === true) && (
|
|
||||||
<Menu Trigger={IconButton} icon={MoreHoriz} {...tooltip(t("misc.more"))}>
|
|
||||||
{type === "movie" && slug && (
|
|
||||||
<>
|
|
||||||
<Menu.Item
|
|
||||||
icon={Download}
|
|
||||||
onSelect={() => downloader(type, slug)}
|
|
||||||
label={t("home.episodeMore.download")}
|
|
||||||
/>
|
|
||||||
<Menu.Item
|
|
||||||
icon={MovieInfo}
|
|
||||||
label={t("home.episodeMore.mediainfo")}
|
|
||||||
onSelect={() =>
|
|
||||||
setPopup(<MediaInfoPopup mediaType={"movie"} mediaSlug={slug!} close={close} />)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{account?.isAdmin === true && (
|
|
||||||
<>
|
|
||||||
{type === "movie" && <HR />}
|
|
||||||
<Menu.Item
|
|
||||||
label={t("home.refreshMetadata")}
|
|
||||||
icon={Refresh}
|
|
||||||
onSelect={() => metadataRefreshMutation.mutate()}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TitleLine = ({
|
|
||||||
isLoading,
|
|
||||||
playHref,
|
|
||||||
name,
|
|
||||||
tagline,
|
|
||||||
date,
|
|
||||||
rating,
|
|
||||||
runtime,
|
|
||||||
poster,
|
|
||||||
studio,
|
|
||||||
trailerUrl,
|
|
||||||
type,
|
|
||||||
watchStatus,
|
|
||||||
slug,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
isLoading: boolean;
|
|
||||||
playHref?: string | null;
|
|
||||||
name?: string;
|
|
||||||
tagline?: string | null;
|
|
||||||
date?: string | null;
|
|
||||||
rating?: number | null;
|
|
||||||
runtime?: number | null;
|
|
||||||
poster?: KyooImage | null;
|
|
||||||
studio?: Studio | null;
|
|
||||||
trailerUrl?: string | null;
|
|
||||||
watchStatus?: WatchStatusV | null;
|
|
||||||
type: "movie" | "show" | "collection";
|
|
||||||
slug?: string;
|
|
||||||
} & Stylable) => {
|
|
||||||
const { css, theme } = useYoshiki();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container
|
|
||||||
{...css(
|
|
||||||
{
|
|
||||||
flexDirection: { xs: "column", md: "row" },
|
|
||||||
},
|
|
||||||
props,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
{...css({
|
|
||||||
flexDirection: { xs: "column", sm: "row" },
|
|
||||||
alignItems: { xs: "center", sm: "flex-start" },
|
|
||||||
flexGrow: 1,
|
|
||||||
maxWidth: percent(100),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Poster
|
|
||||||
src={poster}
|
|
||||||
alt={name}
|
|
||||||
quality="medium"
|
|
||||||
forcedLoading={isLoading}
|
|
||||||
layout={{
|
|
||||||
width: { xs: percent(50), md: percent(25) },
|
|
||||||
}}
|
|
||||||
{...(css({
|
|
||||||
maxWidth: { xs: px(175), sm: Platform.OS === "web" ? ("unset" as any) : 99999999 },
|
|
||||||
flexShrink: 0,
|
|
||||||
}) as { style: ImageStyle })}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
{...css({
|
|
||||||
alignSelf: { xs: "center", sm: "flex-end", md: "center" },
|
|
||||||
alignItems: { xs: "center", sm: "flex-start" },
|
|
||||||
paddingLeft: { sm: em(2.5) },
|
|
||||||
flexShrink: 1,
|
|
||||||
flexGrow: 1,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<P
|
|
||||||
{...css({
|
|
||||||
textAlign: { xs: "center", sm: "left" },
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Skeleton
|
|
||||||
variant="header"
|
|
||||||
{...css({ width: rem(15), height: rem(2.5), marginBottom: rem(1) })}
|
|
||||||
>
|
|
||||||
{isLoading || (
|
|
||||||
<>
|
|
||||||
<H1
|
|
||||||
{...css({
|
|
||||||
color: (theme: Theme) => ({ xs: theme.user.heading, md: theme.heading }),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</H1>
|
|
||||||
{date && (
|
|
||||||
<P
|
|
||||||
{...css({
|
|
||||||
fontSize: rem(2.5),
|
|
||||||
color: (theme: Theme) => ({
|
|
||||||
xs: theme.user.paragraph,
|
|
||||||
md: theme.paragraph,
|
|
||||||
}),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{" "}
|
|
||||||
({date})
|
|
||||||
</P>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Skeleton>
|
|
||||||
</P>
|
|
||||||
{(isLoading || tagline) && (
|
|
||||||
<Skeleton
|
|
||||||
{...css({
|
|
||||||
width: rem(5),
|
|
||||||
height: rem(1.5),
|
|
||||||
marginBottom: rem(0.5),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{isLoading || (
|
|
||||||
<P
|
|
||||||
{...css({
|
|
||||||
fontWeight: "300",
|
|
||||||
fontSize: rem(1.5),
|
|
||||||
marginTop: 0,
|
|
||||||
letterSpacing: 0,
|
|
||||||
textAlign: { xs: "center", sm: "left" },
|
|
||||||
color: (theme: Theme) => ({ xs: theme.user.heading, md: theme.heading }),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{tagline}
|
|
||||||
</P>
|
|
||||||
)}
|
|
||||||
</Skeleton>
|
|
||||||
)}
|
|
||||||
<View
|
|
||||||
{...css({
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
justifyContent: "center",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<ButtonList
|
|
||||||
type={type}
|
|
||||||
slug={slug}
|
|
||||||
playHref={playHref}
|
|
||||||
trailerUrl={trailerUrl}
|
|
||||||
watchStatus={watchStatus}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
{...css({ flexDirection: "row", alignItems: "center", justifyContent: "center" })}
|
|
||||||
>
|
|
||||||
{rating !== null && rating !== 0 && (
|
|
||||||
<>
|
|
||||||
<DottedSeparator
|
|
||||||
{...css({ color: { xs: theme.user.contrast, md: theme.colors.white } })}
|
|
||||||
/>
|
|
||||||
<Rating
|
|
||||||
rating={rating}
|
|
||||||
color={{ xs: theme.user.contrast, md: theme.colors.white }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{runtime && (
|
|
||||||
<>
|
|
||||||
<DottedSeparator
|
|
||||||
{...css({ color: { xs: theme.user.contrast, md: theme.colors.white } })}
|
|
||||||
/>
|
|
||||||
<P {...css({ color: { xs: theme.user.contrast, md: theme.colors.white } })}>
|
|
||||||
{displayRuntime(runtime)}
|
|
||||||
</P>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
{...css([
|
|
||||||
{
|
|
||||||
paddingTop: { xs: ts(3), sm: ts(8) },
|
|
||||||
alignSelf: { xs: "flex-start", md: "flex-end" },
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
flexDirection: "column",
|
|
||||||
},
|
|
||||||
md({
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
right: 0,
|
|
||||||
width: percent(25),
|
|
||||||
height: percent(100),
|
|
||||||
paddingRight: ts(3),
|
|
||||||
}),
|
|
||||||
])}
|
|
||||||
>
|
|
||||||
{isLoading ||
|
|
||||||
(studio && (
|
|
||||||
<P
|
|
||||||
{...css({
|
|
||||||
color: (theme: Theme) => theme.user.paragraph,
|
|
||||||
display: "flex",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t("show.studio")}:{" "}
|
|
||||||
{isLoading ? (
|
|
||||||
<Skeleton {...css({ width: rem(5) })} />
|
|
||||||
) : (
|
|
||||||
<A href={`/studio/${studio.slug}`} {...css({ color: (theme) => theme.user.link })}>
|
|
||||||
{studio.name}
|
|
||||||
</A>
|
|
||||||
)}
|
|
||||||
</P>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Description = ({
|
|
||||||
isLoading,
|
|
||||||
overview,
|
|
||||||
tags,
|
|
||||||
genres,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
isLoading: boolean;
|
|
||||||
overview?: string | null;
|
|
||||||
tags?: string[];
|
|
||||||
genres?: Genre[];
|
|
||||||
} & Stylable) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { css } = useYoshiki();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container
|
|
||||||
{...css({ paddingBottom: ts(1), flexDirection: { xs: "column", sm: "row" } }, props)}
|
|
||||||
>
|
|
||||||
<P
|
|
||||||
{...css({
|
|
||||||
display: { xs: "flex", sm: "none" },
|
|
||||||
flexWrap: "wrap",
|
|
||||||
color: (theme: Theme) => theme.user.paragraph,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t("show.genre")}:{" "}
|
|
||||||
{(isLoading ? [...Array<Genre>(3)] : genres!).map((genre, i) => (
|
|
||||||
<Fragment key={genre ?? i.toString()}>
|
|
||||||
<P {...css({ m: 0 })}>{i !== 0 && ", "}</P>
|
|
||||||
{isLoading ? (
|
|
||||||
<Skeleton {...css({ width: rem(5) })} />
|
|
||||||
) : (
|
|
||||||
<A href={`/genres/${genre.toLowerCase()}`}>{t(`genres.${genre}`)}</A>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</P>
|
|
||||||
|
|
||||||
<View
|
|
||||||
{...css({
|
|
||||||
flexDirection: "column",
|
|
||||||
flexGrow: 1,
|
|
||||||
flexBasis: { sm: 0 },
|
|
||||||
paddingTop: ts(4),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Skeleton lines={4}>
|
|
||||||
{isLoading || (
|
|
||||||
<P {...css({ textAlign: "justify" })}>{overview ?? t("show.noOverview")}</P>
|
|
||||||
)}
|
|
||||||
</Skeleton>
|
|
||||||
<View
|
|
||||||
{...css({
|
|
||||||
flexWrap: "wrap",
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
marginTop: ts(0.5),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<P {...css({ marginRight: ts(0.5) })}>{t("show.tags")}:</P>
|
|
||||||
{(isLoading ? [...Array<string>(3)] : tags!).map((tag, i) => (
|
|
||||||
<Chip
|
|
||||||
key={tag ?? i}
|
|
||||||
label={tag && capitalize(tag)}
|
|
||||||
href={`/search?q=${tag}`}
|
|
||||||
size="small"
|
|
||||||
{...css({ m: ts(0.5) })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<HR
|
|
||||||
orientation="vertical"
|
|
||||||
{...css({ marginX: ts(2), display: { xs: "none", sm: "flex" } })}
|
|
||||||
/>
|
|
||||||
<View {...css({ flexBasis: percent(25), display: { xs: "none", sm: "flex" } })}>
|
|
||||||
<H2>{t("show.genre")}</H2>
|
|
||||||
{isLoading || genres?.length ? (
|
|
||||||
<UL>
|
|
||||||
{(isLoading ? [...Array<Genre>(3)] : genres!).map((genre, i) => (
|
|
||||||
<LI key={genre ?? i}>
|
|
||||||
{isLoading ? (
|
|
||||||
<Skeleton {...css({ marginBottom: 0 })} />
|
|
||||||
) : (
|
|
||||||
<A href={`/genres/${genre.toLowerCase()}`}>{t(`genres.${genre}`)}</A>
|
|
||||||
)}
|
|
||||||
</LI>
|
|
||||||
))}
|
|
||||||
</UL>
|
|
||||||
) : (
|
|
||||||
<P>{t("show.genre-none")}</P>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Header = ({
|
|
||||||
query,
|
|
||||||
type,
|
|
||||||
}: {
|
|
||||||
query: QueryIdentifier<Show | Movie>;
|
|
||||||
type: "movie" | "show";
|
|
||||||
}) => {
|
|
||||||
const { css } = useYoshiki();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fetch query={query}>
|
|
||||||
{({ isLoading, ...data }) => (
|
|
||||||
<>
|
|
||||||
<Head title={data?.name} description={data?.overview} image={data?.thumbnail?.high} />
|
|
||||||
<GradientImageBackground
|
|
||||||
src={data?.thumbnail}
|
|
||||||
quality="high"
|
|
||||||
alt=""
|
|
||||||
containerStyle={Header.containerStyle}
|
|
||||||
>
|
|
||||||
<TitleLine
|
|
||||||
isLoading={isLoading}
|
|
||||||
type={type}
|
|
||||||
slug={data?.slug}
|
|
||||||
playHref={data?.playHref}
|
|
||||||
name={data?.name}
|
|
||||||
tagline={data?.tagline}
|
|
||||||
date={data ? getDisplayDate(data as any) : undefined}
|
|
||||||
rating={data?.rating}
|
|
||||||
runtime={"runtime" in data ? data.runtime : null}
|
|
||||||
poster={data?.poster}
|
|
||||||
trailerUrl={data?.trailer}
|
|
||||||
studio={data?.studio}
|
|
||||||
watchStatus={data?.watchStatus?.status ?? null}
|
|
||||||
{...css(Header.childStyle)}
|
|
||||||
/>
|
|
||||||
</GradientImageBackground>
|
|
||||||
<Description
|
|
||||||
isLoading={isLoading}
|
|
||||||
overview={data?.overview}
|
|
||||||
genres={data?.genres}
|
|
||||||
tags={data?.tags}
|
|
||||||
{...css({ paddingTop: { xs: 0, md: ts(2) } })}
|
|
||||||
/>
|
|
||||||
<Container
|
|
||||||
{...css({
|
|
||||||
flexWrap: "wrap",
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
marginTop: ts(0.5),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<P {...css({ marginRight: ts(0.5), textAlign: "center" })}>{t("show.links")}:</P>
|
|
||||||
{(!isLoading
|
|
||||||
? Object.entries(data.externalId!).filter(([_, data]) => data.link)
|
|
||||||
: [...Array(3)].map((_) => [undefined, undefined] as const)
|
|
||||||
).map(([name, data], i) => (
|
|
||||||
<Chip
|
|
||||||
key={name ?? i}
|
|
||||||
label={name}
|
|
||||||
href={data?.link || undefined}
|
|
||||||
target="_blank"
|
|
||||||
size="small"
|
|
||||||
outline
|
|
||||||
{...css({ m: ts(0.5) })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Container>
|
|
||||||
{type === "show" && <ShowWatchStatusCard {...(data?.watchStatus as any)} />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Fetch>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Header.containerStyle = {
|
|
||||||
height: {
|
|
||||||
xs: vh(40),
|
|
||||||
sm: min(vh(60), px(750)),
|
|
||||||
md: min(vh(60), px(680)),
|
|
||||||
lg: vh(70),
|
|
||||||
},
|
|
||||||
minHeight: { xs: px(350), sm: px(300), md: px(400), lg: px(600) },
|
|
||||||
};
|
|
||||||
|
|
||||||
Header.childStyle = {
|
|
||||||
marginTop: {
|
|
||||||
xs: max(vh(20), px(200)),
|
|
||||||
sm: vh(45),
|
|
||||||
md: max(vh(30), px(150)),
|
|
||||||
lg: max(vh(35), px(200)),
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,22 +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/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { MovieDetails } from "./movie";
|
|
||||||
export { ShowDetails } from "./show";
|
|
@ -1,68 +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 { type Movie, MovieP, type QueryIdentifier, type QueryPage } from "@kyoo/models";
|
|
||||||
import { usePageStyle } from "@kyoo/primitives";
|
|
||||||
import { Platform, ScrollView } from "react-native";
|
|
||||||
import { useYoshiki } from "yoshiki/native";
|
|
||||||
import { DefaultLayout } from "../layout";
|
|
||||||
import { DetailsCollections } from "./collection";
|
|
||||||
import { Header } from "./header";
|
|
||||||
|
|
||||||
const query = (slug: string): QueryIdentifier<Movie> => ({
|
|
||||||
parser: MovieP,
|
|
||||||
path: ["movie", slug],
|
|
||||||
params: {
|
|
||||||
fields: ["studio", "watchStatus"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const MovieDetails: QueryPage<{ slug: string }> = ({ slug }) => {
|
|
||||||
const { css } = useYoshiki();
|
|
||||||
const pageStyle = usePageStyle();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
{...css([
|
|
||||||
Platform.OS === "web" && {
|
|
||||||
// @ts-ignore Web only property
|
|
||||||
overflow: "auto" as any,
|
|
||||||
// @ts-ignore Web only property
|
|
||||||
overflowX: "hidden",
|
|
||||||
// @ts-ignore Web only property
|
|
||||||
overflowY: "overlay",
|
|
||||||
},
|
|
||||||
pageStyle,
|
|
||||||
])}
|
|
||||||
>
|
|
||||||
<Header type="movie" query={query(slug)} />
|
|
||||||
<DetailsCollections type="movie" slug={slug} />
|
|
||||||
{/* <Staff slug={slug} /> */}
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
MovieDetails.getFetchUrls = ({ slug }) => [
|
|
||||||
query(slug),
|
|
||||||
DetailsCollections.query("movie", slug),
|
|
||||||
// ShowStaff.query(slug),
|
|
||||||
];
|
|
||||||
|
|
||||||
MovieDetails.getLayout = { Layout: DefaultLayout, props: { transparent: true } };
|
|
@ -42,7 +42,7 @@ import { type Atom, useAtomValue } from "jotai";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { percent, useYoshiki } from "yoshiki/native";
|
import { percent, useYoshiki } from "yoshiki/native";
|
||||||
import { EpisodeLine, displayRuntime, episodeDisplayNumber } from "../details/episode";
|
import { EpisodeLine, displayRuntime, episodeDisplayNumber } from "../../../../src/ui/details/episode";
|
||||||
import { EmptyView } from "../fetch";
|
import { EmptyView } from "../fetch";
|
||||||
import { type State, downloadAtom } from "./state";
|
import { type State, downloadAtom } from "./state";
|
||||||
|
|
||||||
|
@ -37,7 +37,7 @@ import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { percent, rem, useYoshiki } from "yoshiki/native";
|
import { percent, rem, useYoshiki } from "yoshiki/native";
|
||||||
import { Header as DetailsHeader } from "../details/header";
|
import { Header as DetailsHeader } from "../../../../src/ui/details/header";
|
||||||
import type { WithLoading } from "../fetch";
|
import type { WithLoading } from "../fetch";
|
||||||
|
|
||||||
export const Header = ({
|
export const Header = ({
|
||||||
|
@ -22,7 +22,7 @@ import { type News, NewsP, type QueryIdentifier, getDisplayDate } from "@kyoo/mo
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useYoshiki } from "yoshiki/native";
|
import { useYoshiki } from "yoshiki/native";
|
||||||
import { ItemGrid } from "../browse/grid";
|
import { ItemGrid } from "../browse/grid";
|
||||||
import { EpisodeBox, episodeDisplayNumber } from "../details/episode";
|
import { EpisodeBox, episodeDisplayNumber } from "../../../../src/ui/details/episode";
|
||||||
import { InfiniteFetch } from "../fetch-infinite";
|
import { InfiniteFetch } from "../fetch-infinite";
|
||||||
import { Header } from "./genre";
|
import { Header } from "./genre";
|
||||||
|
|
||||||
|
@ -30,7 +30,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useYoshiki } from "yoshiki/native";
|
import { useYoshiki } from "yoshiki/native";
|
||||||
import { ItemGrid } from "../browse/grid";
|
import { ItemGrid } from "../browse/grid";
|
||||||
import { EpisodeBox, episodeDisplayNumber } from "../details/episode";
|
import { EpisodeBox, episodeDisplayNumber } from "../../../../src/ui/details/episode";
|
||||||
import { InfiniteFetch } from "../fetch-infinite";
|
import { InfiniteFetch } from "../fetch-infinite";
|
||||||
import { Header } from "./genre";
|
import { Header } from "./genre";
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Platform, StyleSheet, View } from "react-native";
|
import { Platform, StyleSheet, View } from "react-native";
|
||||||
import { useRouter } from "solito/router";
|
import { useRouter } from "solito/router";
|
||||||
import { useYoshiki } from "yoshiki/native";
|
import { useYoshiki } from "yoshiki/native";
|
||||||
import { episodeDisplayNumber } from "../details/episode";
|
import { episodeDisplayNumber } from "../../../../src/ui/details/episode";
|
||||||
import { ErrorView } from "../../../../src/ui/errors";
|
import { ErrorView } from "../../../../src/ui/errors";
|
||||||
import { Back, Hover, LoadingIndicator } from "./components/hover";
|
import { Back, Hover, LoadingIndicator } from "./components/hover";
|
||||||
import { useVideoKeyboard } from "./keyboard";
|
import { useVideoKeyboard } from "./keyboard";
|
||||||
|
@ -26,12 +26,12 @@ export const watchListIcon = (status: WatchStatus | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const WatchListInfo = ({
|
export const WatchListInfo = ({
|
||||||
type,
|
kind,
|
||||||
slug,
|
slug,
|
||||||
status,
|
status,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
type: "movie" | "show" | "episode";
|
kind: "movie" | "serie" | "episode";
|
||||||
slug: string;
|
slug: string;
|
||||||
status: WatchStatus | null;
|
status: WatchStatus | null;
|
||||||
color: ComponentProps<typeof IconButton>["color"];
|
color: ComponentProps<typeof IconButton>["color"];
|
||||||
@ -40,12 +40,12 @@ export const WatchListInfo = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
path: [type, slug, "watchStatus"],
|
path: [kind, slug, "watchStatus"],
|
||||||
compute: (newStatus: WatchStatus | null) => ({
|
compute: (newStatus: WatchStatus | null) => ({
|
||||||
method: newStatus ? "POST" : "DELETE",
|
method: newStatus ? "POST" : "DELETE",
|
||||||
params: newStatus ? { status: newStatus } : undefined,
|
params: newStatus ? { status: newStatus } : undefined,
|
||||||
}),
|
}),
|
||||||
invalidate: [type, slug],
|
invalidate: [kind, slug],
|
||||||
});
|
});
|
||||||
if (mutation.isPending) status = mutation.variables;
|
if (mutation.isPending) status = mutation.variables;
|
||||||
|
|
||||||
|
34
front/src/components/rating.tsx
Normal file
34
front/src/components/rating.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import Star from "@material-symbols/svg-400/rounded/star-fill.svg";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { rem, useYoshiki } from "yoshiki/native";
|
||||||
|
import { type Breakpoint, Icon, P, Skeleton, ts } from "~/primitives";
|
||||||
|
|
||||||
|
export const Rating = ({
|
||||||
|
rating,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
rating: number | null;
|
||||||
|
color: Breakpoint<string>;
|
||||||
|
}) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...css({ flexDirection: "row", alignItems: "center" })}>
|
||||||
|
<Icon icon={Star} color={color} {...css({ marginRight: ts(0.5) })} />
|
||||||
|
<P {...css({ color, verticalAlign: "middle" })}>
|
||||||
|
{rating ? rating / 10 : "??"} / 10
|
||||||
|
</P>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Rating.Loader = ({ color }: { color: Breakpoint<string> }) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...css({ flexDirection: "row", alignItems: "center" })}>
|
||||||
|
<Icon icon={Star} color={color} {...css({ marginRight: ts(0.5) })} />
|
||||||
|
<Skeleton {...css({ width: rem(2) })} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
@ -12,3 +12,4 @@ export * from "./video";
|
|||||||
export * from "./user";
|
export * from "./user";
|
||||||
|
|
||||||
export * from "./utils/images";
|
export * from "./utils/images";
|
||||||
|
export * from "./utils/genre";
|
||||||
|
@ -31,7 +31,7 @@ export const Movie = z
|
|||||||
thumbnail: KImage.nullable(),
|
thumbnail: KImage.nullable(),
|
||||||
banner: KImage.nullable(),
|
banner: KImage.nullable(),
|
||||||
logo: KImage.nullable(),
|
logo: KImage.nullable(),
|
||||||
trailerUrl: z.string().optional().nullable(),
|
trailerUrl: z.string().nullable(),
|
||||||
|
|
||||||
isAvailable: z.boolean(),
|
isAvailable: z.boolean(),
|
||||||
|
|
||||||
@ -42,7 +42,13 @@ export const Movie = z
|
|||||||
videos: z.array(EmbeddedVideo).optional(),
|
videos: z.array(EmbeddedVideo).optional(),
|
||||||
watchStatus: z
|
watchStatus: z
|
||||||
.object({
|
.object({
|
||||||
status: z.enum(["completed", "watching", "rewatching", "dropped", "planned"]),
|
status: z.enum([
|
||||||
|
"completed",
|
||||||
|
"watching",
|
||||||
|
"rewatching",
|
||||||
|
"dropped",
|
||||||
|
"planned",
|
||||||
|
]),
|
||||||
score: z.number().int().gte(0).lte(100).nullable(),
|
score: z.number().int().gte(0).lte(100).nullable(),
|
||||||
completedAt: zdate().nullable(),
|
completedAt: zdate().nullable(),
|
||||||
percent: z.number().int().gte(0).lte(100),
|
percent: z.number().int().gte(0).lte(100),
|
||||||
@ -52,5 +58,6 @@ export const Movie = z
|
|||||||
.transform((x) => ({
|
.transform((x) => ({
|
||||||
...x,
|
...x,
|
||||||
href: `/movies/${x.slug}`,
|
href: `/movies/${x.slug}`,
|
||||||
|
playHref: `/watch/${x.slug}`,
|
||||||
}));
|
}));
|
||||||
export type Movie = z.infer<typeof Movie>;
|
export type Movie = z.infer<typeof Movie>;
|
||||||
|
@ -32,7 +32,7 @@ export const Serie = z
|
|||||||
thumbnail: KImage.nullable(),
|
thumbnail: KImage.nullable(),
|
||||||
banner: KImage.nullable(),
|
banner: KImage.nullable(),
|
||||||
logo: KImage.nullable(),
|
logo: KImage.nullable(),
|
||||||
trailerUrl: z.string().optional().nullable(),
|
trailerUrl: z.string().nullable(),
|
||||||
|
|
||||||
entriesCount: z.number().int(),
|
entriesCount: z.number().int(),
|
||||||
availableCount: z.number().int(),
|
availableCount: z.number().int(),
|
||||||
@ -45,7 +45,13 @@ export const Serie = z
|
|||||||
nextEntry: Entry.optional().nullable(),
|
nextEntry: Entry.optional().nullable(),
|
||||||
watchStatus: z
|
watchStatus: z
|
||||||
.object({
|
.object({
|
||||||
status: z.enum(["completed", "watching", "rewatching", "dropped", "planned"]),
|
status: z.enum([
|
||||||
|
"completed",
|
||||||
|
"watching",
|
||||||
|
"rewatching",
|
||||||
|
"dropped",
|
||||||
|
"planned",
|
||||||
|
]),
|
||||||
score: z.number().int().gte(0).lte(100).nullable(),
|
score: z.number().int().gte(0).lte(100).nullable(),
|
||||||
startedAt: zdate().nullable(),
|
startedAt: zdate().nullable(),
|
||||||
completedAt: zdate().nullable(),
|
completedAt: zdate().nullable(),
|
||||||
@ -53,10 +59,13 @@ export const Serie = z
|
|||||||
})
|
})
|
||||||
.nullable(),
|
.nullable(),
|
||||||
})
|
})
|
||||||
.transform((x) => ({
|
.transform((x) => {
|
||||||
...x,
|
const entry = x.nextEntry ?? x.firstEntry;
|
||||||
href: `/series/${x.slug}`,
|
return {
|
||||||
playHref: x.firstEntry ? `/watch/${x.firstEntry.slug}` : null,
|
...x,
|
||||||
}));
|
href: `/series/${x.slug}`,
|
||||||
|
playHref: entry ? `/watch/${entry.slug}` : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
export type Serie = z.infer<typeof Serie>;
|
export type Serie = z.infer<typeof Serie>;
|
||||||
|
@ -54,5 +54,5 @@ export const Account = User.and(
|
|||||||
token: z.string(),
|
token: z.string(),
|
||||||
selected: z.boolean(),
|
selected: z.boolean(),
|
||||||
}),
|
}),
|
||||||
);
|
).transform((x) => ({ ...x, isAdmin: true }));
|
||||||
export type Account = z.infer<typeof Account>;
|
export type Account = z.infer<typeof Account>;
|
||||||
|
@ -25,3 +25,4 @@ export const Genre = z.enum([
|
|||||||
"soap",
|
"soap",
|
||||||
"talk",
|
"talk",
|
||||||
]);
|
]);
|
||||||
|
export type Genre = z.infer<typeof Genre>;
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { type ComponentProps, type ComponentType, type ForwardedRef, forwardRef } from "react";
|
import {
|
||||||
|
type ComponentProps,
|
||||||
|
type ComponentType,
|
||||||
|
type ForwardedRef,
|
||||||
|
forwardRef,
|
||||||
|
} from "react";
|
||||||
import { Platform, type PressableProps } from "react-native";
|
import { Platform, type PressableProps } from "react-native";
|
||||||
import type { SvgProps } from "react-native-svg";
|
import type { SvgProps } from "react-native-svg";
|
||||||
import type { YoshikiStyle } from "yoshiki";
|
import type { YoshikiStyle } from "yoshiki";
|
||||||
import { type Stylable, type Theme, px, useYoshiki } from "yoshiki/native";
|
import { px, type Stylable, type Theme, useYoshiki } from "yoshiki/native";
|
||||||
import { PressableFeedback } from "./links";
|
import { PressableFeedback } from "./links";
|
||||||
import { P } from "./text";
|
import { P } from "./text";
|
||||||
import { type Breakpoint, focusReset, ts } from "./utils";
|
import { type Breakpoint, focusReset, ts } from "./utils";
|
||||||
@ -25,7 +30,12 @@ type IconProps = {
|
|||||||
export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
|
export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
|
||||||
const { css, theme } = useYoshiki();
|
const { css, theme } = useYoshiki();
|
||||||
const computed = css(
|
const computed = css(
|
||||||
{ width: size, height: size, fill: color ?? theme.contrast, flexShrink: 0 } as any,
|
{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fill: color ?? theme.contrast,
|
||||||
|
flexShrink: 0,
|
||||||
|
} as any,
|
||||||
props,
|
props,
|
||||||
) as any;
|
) as any;
|
||||||
|
|
||||||
@ -44,7 +54,9 @@ export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IconButton = forwardRef(function IconButton<AsProps = PressableProps>(
|
export const IconButton = forwardRef(function IconButton<
|
||||||
|
AsProps = PressableProps,
|
||||||
|
>(
|
||||||
{
|
{
|
||||||
icon,
|
icon,
|
||||||
size,
|
size,
|
||||||
@ -84,7 +96,9 @@ export const IconButton = forwardRef(function IconButton<AsProps = PressableProp
|
|||||||
<Icon
|
<Icon
|
||||||
icon={icon}
|
icon={icon}
|
||||||
size={size}
|
size={size}
|
||||||
color={"disabled" in asProps && asProps.disabled ? theme.overlay1 : color}
|
color={
|
||||||
|
"disabled" in asProps && asProps.disabled ? theme.overlay1 : color
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
@ -114,7 +128,7 @@ export const IconFab = <AsProps = PressableProps>(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DottedSeparator = (props: Stylable) => {
|
export const DottedSeparator = (props: Stylable<"text">) => {
|
||||||
const { css } = useYoshiki();
|
const { css } = useYoshiki();
|
||||||
return <P {...css({ mX: ts(1) }, props)}>{String.fromCharCode(0x2022)}</P>;
|
return <P {...css({ mX: ts(1) }, props)}>{String.fromCharCode(0x2022)}</P>;
|
||||||
};
|
};
|
||||||
|
@ -66,13 +66,17 @@ Image.Loader = ({ layout, ...props }: { layout: ImageLayout }) => {
|
|||||||
export const Poster = ({
|
export const Poster = ({
|
||||||
layout,
|
layout,
|
||||||
...props
|
...props
|
||||||
}: ComponentProps<typeof Image> & {
|
}: Omit<ComponentProps<typeof Image>, "layout"> & {
|
||||||
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
|
layout: YoshikiEnhanced<
|
||||||
|
{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }
|
||||||
|
>;
|
||||||
}) => <Image layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
|
}) => <Image layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
|
||||||
|
|
||||||
Poster.Loader = ({
|
Poster.Loader = ({
|
||||||
layout,
|
layout,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
|
layout: YoshikiEnhanced<
|
||||||
|
{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }
|
||||||
|
>;
|
||||||
}) => <Image.Loader layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
|
}) => <Image.Loader layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import EHead from "expo-router/head";
|
||||||
|
|
||||||
export const Head = ({
|
export const Head = ({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@ -7,5 +9,11 @@ export const Head = ({
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
return null;
|
return (
|
||||||
|
<EHead>
|
||||||
|
{title && <title>{`${title} - Kyoo`}</title>}
|
||||||
|
{description && <meta name="description" content={description} />}
|
||||||
|
{image && <meta property="og:image" content={image} />}
|
||||||
|
</EHead>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,19 +0,0 @@
|
|||||||
// import NextHead from "next/head";
|
|
||||||
|
|
||||||
export const Head = ({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
image,
|
|
||||||
}: {
|
|
||||||
title?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
image?: string | null;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<NextHead>
|
|
||||||
{title && <title>{`${title} - Kyoo`}</title>}
|
|
||||||
{description && <meta name="description" content={description} />}
|
|
||||||
{image && <meta property="og:image" content={image} />}
|
|
||||||
</NextHead>
|
|
||||||
);
|
|
||||||
};
|
|
@ -37,7 +37,7 @@ import {
|
|||||||
} from "@kyoo/primitives";
|
} from "@kyoo/primitives";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type Theme, useYoshiki } from "yoshiki/native";
|
import { type Theme, useYoshiki } from "yoshiki/native";
|
||||||
import { ErrorView } from "../../../../src/ui/errors";
|
import { ErrorView } from "../errors";
|
||||||
|
|
||||||
export const PartOf = ({
|
export const PartOf = ({
|
||||||
name,
|
name,
|
@ -56,12 +56,6 @@ export const episodeDisplayNumber = (episode: {
|
|||||||
return "??";
|
return "??";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const displayRuntime = (runtime: number | null) => {
|
|
||||||
if (!runtime) return null;
|
|
||||||
if (runtime < 60) return `${runtime}min`;
|
|
||||||
return `${Math.floor(runtime / 60)}h${runtime % 60}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EpisodeBox = ({
|
export const EpisodeBox = ({
|
||||||
slug,
|
slug,
|
||||||
showSlug,
|
showSlug,
|
605
front/src/ui/details/header.tsx
Normal file
605
front/src/ui/details/header.tsx
Normal file
@ -0,0 +1,605 @@
|
|||||||
|
import Refresh from "@material-symbols/svg-400/rounded/autorenew.svg";
|
||||||
|
import Download from "@material-symbols/svg-400/rounded/download.svg";
|
||||||
|
import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg";
|
||||||
|
import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg";
|
||||||
|
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
|
||||||
|
import Theaters from "@material-symbols/svg-400/rounded/theaters-fill.svg";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { type ImageStyle, Platform, View } from "react-native";
|
||||||
|
import {
|
||||||
|
em,
|
||||||
|
max,
|
||||||
|
md,
|
||||||
|
min,
|
||||||
|
percent,
|
||||||
|
px,
|
||||||
|
rem,
|
||||||
|
type Stylable,
|
||||||
|
type Theme,
|
||||||
|
useYoshiki,
|
||||||
|
vh,
|
||||||
|
} from "yoshiki/native";
|
||||||
|
import { WatchListInfo } from "~/components/items/watchlist-info";
|
||||||
|
import { Rating } from "~/components/rating";
|
||||||
|
import {
|
||||||
|
type Genre,
|
||||||
|
type KImage,
|
||||||
|
Movie,
|
||||||
|
Serie,
|
||||||
|
Show,
|
||||||
|
type Studio,
|
||||||
|
type WatchStatusV,
|
||||||
|
} from "~/models";
|
||||||
|
import {
|
||||||
|
A,
|
||||||
|
Chip,
|
||||||
|
Container,
|
||||||
|
capitalize,
|
||||||
|
DottedSeparator,
|
||||||
|
GradientImageBackground,
|
||||||
|
H1,
|
||||||
|
H2,
|
||||||
|
Head,
|
||||||
|
HR,
|
||||||
|
IconButton,
|
||||||
|
IconFab,
|
||||||
|
LI,
|
||||||
|
Link,
|
||||||
|
Menu,
|
||||||
|
P,
|
||||||
|
Poster,
|
||||||
|
Skeleton,
|
||||||
|
tooltip,
|
||||||
|
ts,
|
||||||
|
UL,
|
||||||
|
} from "~/primitives";
|
||||||
|
import { useAccount } from "~/providers/account-context";
|
||||||
|
import { Fetch, type QueryIdentifier, useMutation } from "~/query";
|
||||||
|
import { displayRuntime, getDisplayDate } from "~/utils";
|
||||||
|
|
||||||
|
const ButtonList = ({
|
||||||
|
kind,
|
||||||
|
slug,
|
||||||
|
playHref,
|
||||||
|
trailerUrl,
|
||||||
|
watchStatus,
|
||||||
|
}: {
|
||||||
|
kind: "movie" | "serie" | "collection";
|
||||||
|
slug: string;
|
||||||
|
playHref: string | null;
|
||||||
|
trailerUrl: string | null;
|
||||||
|
watchStatus: WatchStatusV | null;
|
||||||
|
}) => {
|
||||||
|
const account = useAccount();
|
||||||
|
const { css, theme } = useYoshiki();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const metadataRefreshMutation = useMutation({
|
||||||
|
method: "POST",
|
||||||
|
path: [kind, slug, "refresh"],
|
||||||
|
invalidate: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{playHref !== null && (
|
||||||
|
<IconFab
|
||||||
|
icon={PlayArrow}
|
||||||
|
as={Link}
|
||||||
|
href={playHref}
|
||||||
|
color={{ xs: theme.user.colors.black, md: theme.colors.black }}
|
||||||
|
{...css({
|
||||||
|
bg: theme.user.accent,
|
||||||
|
fover: { self: { bg: theme.user.accent } },
|
||||||
|
})}
|
||||||
|
{...tooltip(t("show.play"))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{trailerUrl && (
|
||||||
|
<IconButton
|
||||||
|
icon={Theaters}
|
||||||
|
as={Link}
|
||||||
|
href={trailerUrl}
|
||||||
|
target="_blank"
|
||||||
|
color={{ xs: theme.user.contrast, md: theme.colors.white }}
|
||||||
|
{...tooltip(t("show.trailer"))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{kind !== "collection" && (
|
||||||
|
<WatchListInfo
|
||||||
|
kind={kind}
|
||||||
|
slug={slug}
|
||||||
|
status={watchStatus}
|
||||||
|
color={{ xs: theme.user.contrast, md: theme.colors.white }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(kind === "movie" || account?.isAdmin === true) && (
|
||||||
|
<Menu
|
||||||
|
Trigger={IconButton}
|
||||||
|
icon={MoreHoriz}
|
||||||
|
{...tooltip(t("misc.more"))}
|
||||||
|
>
|
||||||
|
{kind === "movie" && (
|
||||||
|
<>
|
||||||
|
{/* <Menu.Item */}
|
||||||
|
{/* icon={Download} */}
|
||||||
|
{/* onSelect={() => downloader(kind, slug)} */}
|
||||||
|
{/* label={t("home.episodeMore.download")} */}
|
||||||
|
{/* /> */}
|
||||||
|
<Menu.Item
|
||||||
|
label={t("home.episodeMore.mediainfo")}
|
||||||
|
icon={MovieInfo}
|
||||||
|
href={`/${kind}/${slug}/info`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{account?.isAdmin === true && (
|
||||||
|
<>
|
||||||
|
{kind === "movie" && <HR />}
|
||||||
|
<Menu.Item
|
||||||
|
label={t("home.refreshMetadata")}
|
||||||
|
icon={Refresh}
|
||||||
|
onSelect={() => metadataRefreshMutation.mutate()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TitleLine = ({
|
||||||
|
kind,
|
||||||
|
slug,
|
||||||
|
playHref,
|
||||||
|
name,
|
||||||
|
tagline,
|
||||||
|
date,
|
||||||
|
rating,
|
||||||
|
runtime,
|
||||||
|
poster,
|
||||||
|
trailerUrl,
|
||||||
|
// studio,
|
||||||
|
watchStatus,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
kind: "movie" | "serie" | "collection";
|
||||||
|
slug: string;
|
||||||
|
playHref: string | null;
|
||||||
|
name: string;
|
||||||
|
tagline: string | null;
|
||||||
|
date: string | null;
|
||||||
|
rating: number | null;
|
||||||
|
runtime: number | null;
|
||||||
|
poster: KImage | null;
|
||||||
|
trailerUrl: string | null;
|
||||||
|
// studio: Studio;
|
||||||
|
watchStatus: WatchStatusV | null;
|
||||||
|
} & Stylable) => {
|
||||||
|
const { css, theme } = useYoshiki();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
{...css(
|
||||||
|
{
|
||||||
|
flexDirection: { xs: "column", md: "row" },
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
flexDirection: { xs: "column", sm: "row" },
|
||||||
|
alignItems: { xs: "center", sm: "flex-start" },
|
||||||
|
flexGrow: 1,
|
||||||
|
maxWidth: percent(100),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Poster
|
||||||
|
src={poster}
|
||||||
|
alt={name}
|
||||||
|
quality="medium"
|
||||||
|
layout={{
|
||||||
|
width: { xs: percent(50), md: percent(25) },
|
||||||
|
}}
|
||||||
|
{...(css({
|
||||||
|
maxWidth: {
|
||||||
|
xs: px(175),
|
||||||
|
sm: Platform.OS === "web" ? ("unset" as any) : 99999999,
|
||||||
|
},
|
||||||
|
flexShrink: 0,
|
||||||
|
}) as { style: ImageStyle })}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
alignSelf: { xs: "center", sm: "flex-end", md: "center" },
|
||||||
|
alignItems: { xs: "center", sm: "flex-start" },
|
||||||
|
paddingLeft: { sm: em(2.5) },
|
||||||
|
flexShrink: 1,
|
||||||
|
flexGrow: 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<P
|
||||||
|
{...css({
|
||||||
|
textAlign: { xs: "center", sm: "left" },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<H1
|
||||||
|
{...css({
|
||||||
|
color: (theme: Theme) => ({
|
||||||
|
xs: theme.user.heading,
|
||||||
|
md: theme.heading,
|
||||||
|
}),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</H1>
|
||||||
|
{date && (
|
||||||
|
<P
|
||||||
|
{...css({
|
||||||
|
fontSize: rem(2.5),
|
||||||
|
color: (theme: Theme) => ({
|
||||||
|
xs: theme.user.paragraph,
|
||||||
|
md: theme.paragraph,
|
||||||
|
}),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
({date})
|
||||||
|
</P>
|
||||||
|
)}
|
||||||
|
</P>
|
||||||
|
{tagline && (
|
||||||
|
<P
|
||||||
|
{...css({
|
||||||
|
fontWeight: "300",
|
||||||
|
fontSize: rem(1.5),
|
||||||
|
marginTop: 0,
|
||||||
|
letterSpacing: 0,
|
||||||
|
textAlign: { xs: "center", sm: "left" },
|
||||||
|
color: (theme: Theme) => ({
|
||||||
|
xs: theme.user.heading,
|
||||||
|
md: theme.heading,
|
||||||
|
}),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{tagline}
|
||||||
|
</P>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ButtonList
|
||||||
|
kind={kind}
|
||||||
|
slug={slug}
|
||||||
|
playHref={playHref}
|
||||||
|
trailerUrl={trailerUrl}
|
||||||
|
watchStatus={watchStatus}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{rating !== null && rating !== 0 && (
|
||||||
|
<>
|
||||||
|
<DottedSeparator
|
||||||
|
{...css({
|
||||||
|
color: {
|
||||||
|
xs: theme.user.contrast,
|
||||||
|
md: theme.colors.white,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Rating
|
||||||
|
rating={rating}
|
||||||
|
color={{ xs: theme.user.contrast, md: theme.colors.white }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{runtime && (
|
||||||
|
<>
|
||||||
|
<DottedSeparator
|
||||||
|
{...css({
|
||||||
|
color: {
|
||||||
|
xs: theme.user.contrast,
|
||||||
|
md: theme.colors.white,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<P
|
||||||
|
{...css({
|
||||||
|
color: {
|
||||||
|
xs: theme.user.contrast,
|
||||||
|
md: theme.colors.white,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{displayRuntime(runtime)}
|
||||||
|
</P>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{/* <View */}
|
||||||
|
{/* {...css([ */}
|
||||||
|
{/* { */}
|
||||||
|
{/* paddingTop: { xs: ts(3), sm: ts(8) }, */}
|
||||||
|
{/* alignSelf: { xs: "flex-start", md: "flex-end" }, */}
|
||||||
|
{/* justifyContent: "flex-end", */}
|
||||||
|
{/* flexDirection: "column", */}
|
||||||
|
{/* }, */}
|
||||||
|
{/* md({ */}
|
||||||
|
{/* position: "absolute", */}
|
||||||
|
{/* top: 0, */}
|
||||||
|
{/* bottom: 0, */}
|
||||||
|
{/* right: 0, */}
|
||||||
|
{/* width: percent(25), */}
|
||||||
|
{/* height: percent(100), */}
|
||||||
|
{/* paddingRight: ts(3), */}
|
||||||
|
{/* }) as any, */}
|
||||||
|
{/* ])} */}
|
||||||
|
{/* > */}
|
||||||
|
{/* {studio && ( */}
|
||||||
|
{/* <P */}
|
||||||
|
{/* {...css({ */}
|
||||||
|
{/* color: (theme: Theme) => theme.user.paragraph, */}
|
||||||
|
{/* display: "flex", */}
|
||||||
|
{/* })} */}
|
||||||
|
{/* > */}
|
||||||
|
{/* {t("show.studio")}:{" "} */}
|
||||||
|
{/* <A */}
|
||||||
|
{/* href={`/studio/${studio.slug}`} */}
|
||||||
|
{/* {...css({ color: (theme) => theme.user.link })} */}
|
||||||
|
{/* > */}
|
||||||
|
{/* {studio.name} */}
|
||||||
|
{/* </A> */}
|
||||||
|
{/* </P> */}
|
||||||
|
{/* )} */}
|
||||||
|
{/* </View> */}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Description = ({
|
||||||
|
isLoading,
|
||||||
|
overview,
|
||||||
|
tags,
|
||||||
|
genres,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
isLoading: boolean;
|
||||||
|
overview?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
genres?: Genre[];
|
||||||
|
} & Stylable) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
{...css(
|
||||||
|
{ paddingBottom: ts(1), flexDirection: { xs: "column", sm: "row" } },
|
||||||
|
props,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<P
|
||||||
|
{...css({
|
||||||
|
display: { xs: "flex", sm: "none" },
|
||||||
|
flexWrap: "wrap",
|
||||||
|
color: (theme: Theme) => theme.user.paragraph,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t("show.genre")}:{" "}
|
||||||
|
{(isLoading ? [...Array<Genre>(3)] : genres!).map((genre, i) => (
|
||||||
|
<Fragment key={genre ?? i.toString()}>
|
||||||
|
<P {...css({ m: 0 })}>{i !== 0 && ", "}</P>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton {...css({ width: rem(5) })} />
|
||||||
|
) : (
|
||||||
|
<A href={`/genres/${genre.toLowerCase()}`}>
|
||||||
|
{t(`genres.${genre}`)}
|
||||||
|
</A>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
flexDirection: "column",
|
||||||
|
flexGrow: 1,
|
||||||
|
flexBasis: { sm: 0 },
|
||||||
|
paddingTop: ts(4),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Skeleton lines={4}>
|
||||||
|
{isLoading || (
|
||||||
|
<P {...css({ textAlign: "justify" })}>
|
||||||
|
{overview ?? t("show.noOverview")}
|
||||||
|
</P>
|
||||||
|
)}
|
||||||
|
</Skeleton>
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
flexWrap: "wrap",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: ts(0.5),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<P {...css({ marginRight: ts(0.5) })}>{t("show.tags")}:</P>
|
||||||
|
{(isLoading ? [...Array<string>(3)] : tags!).map((tag, i) => (
|
||||||
|
<Chip
|
||||||
|
key={tag ?? i}
|
||||||
|
label={tag && capitalize(tag)}
|
||||||
|
href={`/search?q=${tag}`}
|
||||||
|
size="small"
|
||||||
|
{...css({ m: ts(0.5) })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<HR
|
||||||
|
orientation="vertical"
|
||||||
|
{...css({ marginX: ts(2), display: { xs: "none", sm: "flex" } })}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
flexBasis: percent(25),
|
||||||
|
display: { xs: "none", sm: "flex" },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<H2>{t("show.genre")}</H2>
|
||||||
|
{isLoading || genres?.length ? (
|
||||||
|
<UL>
|
||||||
|
{(isLoading ? [...Array<Genre>(3)] : genres!).map((genre, i) => (
|
||||||
|
<LI key={genre ?? i}>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton {...css({ marginBottom: 0 })} />
|
||||||
|
) : (
|
||||||
|
<A href={`/genres/${genre.toLowerCase()}`}>
|
||||||
|
{t(`genres.${genre}`)}
|
||||||
|
</A>
|
||||||
|
)}
|
||||||
|
</LI>
|
||||||
|
))}
|
||||||
|
</UL>
|
||||||
|
) : (
|
||||||
|
<P>{t("show.genre-none")}</P>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Header = ({
|
||||||
|
kind,
|
||||||
|
slug,
|
||||||
|
}: {
|
||||||
|
kind: "movie" | "serie";
|
||||||
|
slug: string;
|
||||||
|
}) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fetch
|
||||||
|
query={Header.query(kind, slug)}
|
||||||
|
Loader={() => <p>loading</p>}
|
||||||
|
Render={(data) => (
|
||||||
|
<>
|
||||||
|
<Head
|
||||||
|
title={data.name}
|
||||||
|
description={data.description}
|
||||||
|
image={data.thumbnail?.high}
|
||||||
|
/>
|
||||||
|
<GradientImageBackground
|
||||||
|
src={data.thumbnail}
|
||||||
|
quality="high"
|
||||||
|
alt=""
|
||||||
|
layout={{
|
||||||
|
width: percent(100),
|
||||||
|
height: {
|
||||||
|
xs: vh(40),
|
||||||
|
sm: min(vh(60), px(750)),
|
||||||
|
md: min(vh(60), px(680)),
|
||||||
|
lg: vh(70),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...(css({
|
||||||
|
minHeight: { xs: px(350), sm: px(300), md: px(400), lg: px(600) },
|
||||||
|
}) as any)}
|
||||||
|
>
|
||||||
|
<TitleLine
|
||||||
|
kind={kind}
|
||||||
|
slug={slug}
|
||||||
|
name={data.name}
|
||||||
|
tagline={data.tagline}
|
||||||
|
date={getDisplayDate(data)}
|
||||||
|
rating={data.rating}
|
||||||
|
runtime={data.kind === "movie" ? data.runtime : null}
|
||||||
|
poster={data.poster}
|
||||||
|
// studio={data.studio}
|
||||||
|
playHref={data.kind !== "collection" ? data.playHref : null}
|
||||||
|
trailerUrl={data.kind !== "collection" ? data.trailerUrl : null}
|
||||||
|
watchStatus={data.kind !== "collection" ? data.watchStatus?.status! : null}
|
||||||
|
{...css({
|
||||||
|
marginTop: {
|
||||||
|
xs: max(vh(20), px(200)),
|
||||||
|
sm: vh(45),
|
||||||
|
md: max(vh(30), px(150)),
|
||||||
|
lg: max(vh(35), px(200)),
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</GradientImageBackground>
|
||||||
|
{/* <Description */}
|
||||||
|
{/* isLoading={isLoading} */}
|
||||||
|
{/* overview={data?.overview} */}
|
||||||
|
{/* genres={data?.genres} */}
|
||||||
|
{/* tags={data?.tags} */}
|
||||||
|
{/* {...css({ paddingTop: { xs: 0, md: ts(2) } })} */}
|
||||||
|
{/* /> */}
|
||||||
|
{/* <Container */}
|
||||||
|
{/* {...css({ */}
|
||||||
|
{/* flexWrap: "wrap", */}
|
||||||
|
{/* flexDirection: "row", */}
|
||||||
|
{/* alignItems: "center", */}
|
||||||
|
{/* marginTop: ts(0.5), */}
|
||||||
|
{/* })} */}
|
||||||
|
{/* > */}
|
||||||
|
{/* <P {...css({ marginRight: ts(0.5), textAlign: "center" })}> */}
|
||||||
|
{/* {t("show.links")}: */}
|
||||||
|
{/* </P> */}
|
||||||
|
{/* {(!isLoading */}
|
||||||
|
{/* ? Object.entries(data.externalId!).filter( */}
|
||||||
|
{/* ([_, data]) => data.link, */}
|
||||||
|
{/* ) */}
|
||||||
|
{/* : [...Array(3)].map((_) => [undefined, undefined] as const) */}
|
||||||
|
{/* ).map(([name, data], i) => ( */}
|
||||||
|
{/* <Chip */}
|
||||||
|
{/* key={name ?? i} */}
|
||||||
|
{/* label={name} */}
|
||||||
|
{/* href={data?.link || undefined} */}
|
||||||
|
{/* target="_blank" */}
|
||||||
|
{/* size="small" */}
|
||||||
|
{/* outline */}
|
||||||
|
{/* {...css({ m: ts(0.5) })} */}
|
||||||
|
{/* /> */}
|
||||||
|
{/* ))} */}
|
||||||
|
{/* </Container> */}
|
||||||
|
{/* {type === "show" && ( */}
|
||||||
|
{/* <ShowWatchStatusCard {...(data?.watchStatus as any)} /> */}
|
||||||
|
{/* )} */}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Header.query = (kind: string, slug: string): QueryIdentifier<Show> => ({
|
||||||
|
parser: Show,
|
||||||
|
path: [kind, slug],
|
||||||
|
params: {
|
||||||
|
with: ["studio", "watchStatus"],
|
||||||
|
},
|
||||||
|
});
|
1
front/src/ui/details/index.tsx
Normal file
1
front/src/ui/details/index.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { MovieDetails } from "./movie";
|
30
front/src/ui/details/movie.tsx
Normal file
30
front/src/ui/details/movie.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Platform, ScrollView } from "react-native";
|
||||||
|
import { useYoshiki } from "yoshiki/native";
|
||||||
|
import { Movie } from "~/models";
|
||||||
|
import type { QueryIdentifier } from "~/query";
|
||||||
|
import { useQueryState } from "~/utils";
|
||||||
|
import { Header } from "./header";
|
||||||
|
|
||||||
|
export const MovieDetails = () => {
|
||||||
|
const [slug] = useQueryState("slug", undefined!);
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
//{...css(
|
||||||
|
// Platform.OS === "web" && {
|
||||||
|
// // @ts-ignore Web only property
|
||||||
|
// overflow: "auto" as any,
|
||||||
|
// // @ts-ignore Web only property
|
||||||
|
// overflowX: "hidden",
|
||||||
|
// // @ts-ignore Web only property
|
||||||
|
// overflowY: "overlay",
|
||||||
|
// },
|
||||||
|
//)}
|
||||||
|
>
|
||||||
|
<Header kind="movie" slug={slug} />
|
||||||
|
{/* <DetailsCollections type="movie" slug={slug} /> */}
|
||||||
|
{/* <Staff slug={slug} /> */}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
@ -31,7 +31,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import Svg, { Path, type SvgProps } from "react-native-svg";
|
import Svg, { Path, type SvgProps } from "react-native-svg";
|
||||||
import { percent, useYoshiki } from "yoshiki/native";
|
import { percent, useYoshiki } from "yoshiki/native";
|
||||||
import { DefaultLayout } from "../layout";
|
import { DefaultLayout } from "../../../packages/ui/src/layoutpackages/ui/src/layout";
|
||||||
import { DetailsCollections } from "./collection";
|
import { DetailsCollections } from "./collection";
|
||||||
import { EpisodeLine, episodeDisplayNumber } from "./episode";
|
import { EpisodeLine, episodeDisplayNumber } from "./episode";
|
||||||
import { Header } from "./header";
|
import { Header } from "./header";
|
@ -23,16 +23,25 @@ export const getDisplayDate = (data: Show | Movie) => {
|
|||||||
startAir,
|
startAir,
|
||||||
endAir,
|
endAir,
|
||||||
airDate,
|
airDate,
|
||||||
}: { startAir?: Date | null; endAir?: Date | null; airDate?: Date | null } = data;
|
}: { startAir?: Date | null; endAir?: Date | null; airDate?: Date | null } =
|
||||||
|
data;
|
||||||
|
|
||||||
if (startAir) {
|
if (startAir) {
|
||||||
if (!endAir || startAir.getFullYear() === endAir.getFullYear()) {
|
if (!endAir || startAir.getFullYear() === endAir.getFullYear()) {
|
||||||
return startAir.getFullYear().toString();
|
return startAir.getFullYear().toString();
|
||||||
}
|
}
|
||||||
return startAir.getFullYear() + (endAir ? ` - ${endAir.getFullYear()}` : "");
|
return (
|
||||||
|
startAir.getFullYear() + (endAir ? ` - ${endAir.getFullYear()}` : "")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (airDate) {
|
if (airDate) {
|
||||||
return airDate.getFullYear().toString();
|
return airDate.getFullYear().toString();
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const displayRuntime = (runtime: number | null) => {
|
||||||
|
if (!runtime) return null;
|
||||||
|
if (runtime < 60) return `${runtime}min`;
|
||||||
|
return `${Math.floor(runtime / 60)}h${runtime % 60}`;
|
||||||
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user