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
|
||||
pgformatter
|
||||
# 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 { percent, px, useYoshiki } from "yoshiki/native";
|
||||
import { ItemGrid } from "../browse/grid";
|
||||
import { Header as ShowHeader, TitleLine } from "../details/header";
|
||||
import { SvgWave } from "../details/show";
|
||||
import { Header as ShowHeader, TitleLine } from "../../../../src/ui/details/headeri/details/header";
|
||||
import { SvgWave } from "../../../../src/ui/details/show/ui/details/show";
|
||||
import { Fetch } from "../fetch";
|
||||
import { InfiniteFetch } from "../fetch-infinite";
|
||||
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 { View } from "react-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 { 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 { View } from "react-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";
|
||||
|
||||
export const Header = ({
|
||||
|
@ -22,7 +22,7 @@ import { type News, NewsP, type QueryIdentifier, getDisplayDate } from "@kyoo/mo
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
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 { Header } from "./genre";
|
||||
|
||||
|
@ -30,7 +30,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
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 { Header } from "./genre";
|
||||
|
||||
|
@ -35,7 +35,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Platform, StyleSheet, View } from "react-native";
|
||||
import { useRouter } from "solito/router";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
import { episodeDisplayNumber } from "../details/episode";
|
||||
import { episodeDisplayNumber } from "../../../../src/ui/details/episode";
|
||||
import { ErrorView } from "../../../../src/ui/errors";
|
||||
import { Back, Hover, LoadingIndicator } from "./components/hover";
|
||||
import { useVideoKeyboard } from "./keyboard";
|
||||
|
@ -26,12 +26,12 @@ export const watchListIcon = (status: WatchStatus | null) => {
|
||||
};
|
||||
|
||||
export const WatchListInfo = ({
|
||||
type,
|
||||
kind,
|
||||
slug,
|
||||
status,
|
||||
...props
|
||||
}: {
|
||||
type: "movie" | "show" | "episode";
|
||||
kind: "movie" | "serie" | "episode";
|
||||
slug: string;
|
||||
status: WatchStatus | null;
|
||||
color: ComponentProps<typeof IconButton>["color"];
|
||||
@ -40,12 +40,12 @@ export const WatchListInfo = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const mutation = useMutation({
|
||||
path: [type, slug, "watchStatus"],
|
||||
path: [kind, slug, "watchStatus"],
|
||||
compute: (newStatus: WatchStatus | null) => ({
|
||||
method: newStatus ? "POST" : "DELETE",
|
||||
params: newStatus ? { status: newStatus } : undefined,
|
||||
}),
|
||||
invalidate: [type, slug],
|
||||
invalidate: [kind, slug],
|
||||
});
|
||||
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 "./utils/images";
|
||||
export * from "./utils/genre";
|
||||
|
@ -31,7 +31,7 @@ export const Movie = z
|
||||
thumbnail: KImage.nullable(),
|
||||
banner: KImage.nullable(),
|
||||
logo: KImage.nullable(),
|
||||
trailerUrl: z.string().optional().nullable(),
|
||||
trailerUrl: z.string().nullable(),
|
||||
|
||||
isAvailable: z.boolean(),
|
||||
|
||||
@ -42,7 +42,13 @@ export const Movie = z
|
||||
videos: z.array(EmbeddedVideo).optional(),
|
||||
watchStatus: z
|
||||
.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(),
|
||||
completedAt: zdate().nullable(),
|
||||
percent: z.number().int().gte(0).lte(100),
|
||||
@ -52,5 +58,6 @@ export const Movie = z
|
||||
.transform((x) => ({
|
||||
...x,
|
||||
href: `/movies/${x.slug}`,
|
||||
playHref: `/watch/${x.slug}`,
|
||||
}));
|
||||
export type Movie = z.infer<typeof Movie>;
|
||||
|
@ -32,7 +32,7 @@ export const Serie = z
|
||||
thumbnail: KImage.nullable(),
|
||||
banner: KImage.nullable(),
|
||||
logo: KImage.nullable(),
|
||||
trailerUrl: z.string().optional().nullable(),
|
||||
trailerUrl: z.string().nullable(),
|
||||
|
||||
entriesCount: z.number().int(),
|
||||
availableCount: z.number().int(),
|
||||
@ -45,7 +45,13 @@ export const Serie = z
|
||||
nextEntry: Entry.optional().nullable(),
|
||||
watchStatus: z
|
||||
.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(),
|
||||
startedAt: zdate().nullable(),
|
||||
completedAt: zdate().nullable(),
|
||||
@ -53,10 +59,13 @@ export const Serie = z
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
.transform((x) => ({
|
||||
.transform((x) => {
|
||||
const entry = x.nextEntry ?? x.firstEntry;
|
||||
return {
|
||||
...x,
|
||||
href: `/series/${x.slug}`,
|
||||
playHref: x.firstEntry ? `/watch/${x.firstEntry.slug}` : null,
|
||||
}));
|
||||
playHref: entry ? `/watch/${entry.slug}` : null,
|
||||
};
|
||||
});
|
||||
|
||||
export type Serie = z.infer<typeof Serie>;
|
||||
|
@ -54,5 +54,5 @@ export const Account = User.and(
|
||||
token: z.string(),
|
||||
selected: z.boolean(),
|
||||
}),
|
||||
);
|
||||
).transform((x) => ({ ...x, isAdmin: true }));
|
||||
export type Account = z.infer<typeof Account>;
|
||||
|
@ -25,3 +25,4 @@ export const Genre = z.enum([
|
||||
"soap",
|
||||
"talk",
|
||||
]);
|
||||
export type Genre = z.infer<typeof Genre>;
|
||||
|
@ -1,9 +1,14 @@
|
||||
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 type { SvgProps } from "react-native-svg";
|
||||
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 { P } from "./text";
|
||||
import { type Breakpoint, focusReset, ts } from "./utils";
|
||||
@ -25,7 +30,12 @@ type IconProps = {
|
||||
export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
|
||||
const { css, theme } = useYoshiki();
|
||||
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,
|
||||
) 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,
|
||||
size,
|
||||
@ -84,7 +96,9 @@ export const IconButton = forwardRef(function IconButton<AsProps = PressableProp
|
||||
<Icon
|
||||
icon={icon}
|
||||
size={size}
|
||||
color={"disabled" in asProps && asProps.disabled ? theme.overlay1 : color}
|
||||
color={
|
||||
"disabled" in asProps && asProps.disabled ? theme.overlay1 : color
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
@ -114,7 +128,7 @@ export const IconFab = <AsProps = PressableProps>(
|
||||
);
|
||||
};
|
||||
|
||||
export const DottedSeparator = (props: Stylable) => {
|
||||
export const DottedSeparator = (props: Stylable<"text">) => {
|
||||
const { css } = useYoshiki();
|
||||
return <P {...css({ mX: ts(1) }, props)}>{String.fromCharCode(0x2022)}</P>;
|
||||
};
|
||||
|
@ -66,13 +66,17 @@ Image.Loader = ({ layout, ...props }: { layout: ImageLayout }) => {
|
||||
export const Poster = ({
|
||||
layout,
|
||||
...props
|
||||
}: ComponentProps<typeof Image> & {
|
||||
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
|
||||
}: Omit<ComponentProps<typeof Image>, "layout"> & {
|
||||
layout: YoshikiEnhanced<
|
||||
{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }
|
||||
>;
|
||||
}) => <Image layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
|
||||
|
||||
Poster.Loader = ({
|
||||
layout,
|
||||
...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} />;
|
||||
|
@ -1,3 +1,5 @@
|
||||
import EHead from "expo-router/head";
|
||||
|
||||
export const Head = ({
|
||||
title,
|
||||
description,
|
||||
@ -7,5 +9,11 @@ export const Head = ({
|
||||
description?: 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";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Theme, useYoshiki } from "yoshiki/native";
|
||||
import { ErrorView } from "../../../../src/ui/errors";
|
||||
import { ErrorView } from "../errors";
|
||||
|
||||
export const PartOf = ({
|
||||
name,
|
@ -56,12 +56,6 @@ export const episodeDisplayNumber = (episode: {
|
||||
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 = ({
|
||||
slug,
|
||||
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 Svg, { Path, type SvgProps } from "react-native-svg";
|
||||
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 { EpisodeLine, episodeDisplayNumber } from "./episode";
|
||||
import { Header } from "./header";
|
@ -23,16 +23,25 @@ export const getDisplayDate = (data: Show | Movie) => {
|
||||
startAir,
|
||||
endAir,
|
||||
airDate,
|
||||
}: { startAir?: Date | null; endAir?: Date | null; airDate?: Date | null } = data;
|
||||
}: { startAir?: Date | null; endAir?: Date | null; airDate?: Date | null } =
|
||||
data;
|
||||
|
||||
if (startAir) {
|
||||
if (!endAir || startAir.getFullYear() === endAir.getFullYear()) {
|
||||
return startAir.getFullYear().toString();
|
||||
}
|
||||
return startAir.getFullYear() + (endAir ? ` - ${endAir.getFullYear()}` : "");
|
||||
return (
|
||||
startAir.getFullYear() + (endAir ? ` - ${endAir.getFullYear()}` : "")
|
||||
);
|
||||
}
|
||||
if (airDate) {
|
||||
return airDate.getFullYear().toString();
|
||||
}
|
||||
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