Rework series header

This commit is contained in:
Zoe Roux 2026-02-01 18:52:09 +01:00
parent f7f3564816
commit e3dc2bd531
No known key found for this signature in database
16 changed files with 386 additions and 764 deletions

View File

@ -105,7 +105,7 @@ export const EntryLine = ({
>
<Icon
icon={MultipleVideos}
fillClassName="accent-accent dark:accent-slate-400"
className="fill-accent dark:fill-slate-400"
/>
<SubP className="ml-2">
{t("show.videosCount", { number: videosCount })}

View File

@ -2,12 +2,13 @@ import BookmarkAdd from "@material-symbols/svg-400/rounded/bookmark_add.svg";
import BookmarkAdded from "@material-symbols/svg-400/rounded/bookmark_added-fill.svg";
import BookmarkRemove from "@material-symbols/svg-400/rounded/bookmark_remove.svg";
import Bookmark from "@material-symbols/svg-400/rounded/bookmark-fill.svg";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import type { Serie } from "~/models";
import { IconButton, Menu, tooltip } from "~/primitives";
import { IconButton, Menu, PressableFeedback, tooltip } from "~/primitives";
import { useAccount } from "~/providers/account-context";
import { useMutation } from "~/query";
import { ComponentProps } from "react";
import { PressableProps } from "react-native";
type WatchStatus = NonNullable<Serie["watchStatus"]>["status"];
const WatchStatus = [
@ -40,7 +41,7 @@ export const WatchListInfo = ({
kind: "movie" | "serie" | "episode";
slug: string;
status: WatchStatus | null;
}) => {
} & Partial<ComponentProps<typeof IconButton<PressableProps>>>) => {
const account = useAccount();
const { t } = useTranslation();
@ -94,7 +95,7 @@ export const WatchListInfo = ({
Trigger={IconButton}
icon={watchListIcon(status)}
{...tooltip(t("show.watchlistEdit"))}
{...props}
{...(props as any)}
>
{Object.values(WatchStatus).map((x) => (
<Menu.Item

View File

@ -6,24 +6,39 @@ import { cn } from "~/utils";
export const Rating = ({
rating,
className,
textClassName,
iconClassName,
...props
}: {
rating: number | null;
className?: string;
textClassName?: string;
iconClassName?: string;
}) => {
return (
<View className={cn("flex-row items-center", className)} {...props}>
<Icon icon={Star} className="mr-1" />
<P className="align-middle">{rating ? rating / 10 : "??"} / 10</P>
<Icon icon={Star} className={cn("mr-1", iconClassName)} />
<P className={cn("align-middle", textClassName)}>
{rating ? rating / 10 : "??"} / 10
</P>
</View>
);
};
Rating.Loader = ({ className, ...props }: { className?: string }) => {
Rating.Loader = ({
className,
textClassName,
iconClassName,
...props
}: {
className?: string;
textClassName?: string;
iconClassName?: string;
}) => {
return (
<View className={cn("flex-row items-center", className)} {...props}>
<Icon icon={Star} className="mr-1" />
<Skeleton className="w-8" />
<Icon icon={Star} className={cn("mr-1", iconClassName)} />
<Skeleton className={cn("w-8", textClassName)} />
</View>
);
};

View File

@ -1,82 +1,58 @@
import { type TextProps, View } from "react-native";
import { px, rem, type Theme, useYoshiki } from "yoshiki/native";
import { View } from "react-native";
import { cn } from "~/utils";
import { Link } from "./links";
import { Skeleton } from "./skeleton";
import { P } from "./text";
import { capitalize, ts } from "./utils";
import { capitalize } from "./utils";
export const Chip = ({
color,
size = "medium",
outline = false,
label,
href,
replace,
target,
textProps,
className,
...props
}: {
color?: string;
size?: "small" | "medium" | "large";
outline?: boolean;
label: string;
href: string | null;
replace?: boolean;
target?: string;
textProps?: TextProps;
className?: string;
}) => {
const { css } = useYoshiki("chip");
textProps ??= {};
const sizeMult = size === "medium" ? 1 : size === "small" ? 0.5 : 1.5;
return (
<Link
href={href}
replace={replace}
target={target}
{...css(
[
{
pY: ts(1 * sizeMult),
pX: ts(2.5 * sizeMult),
borderRadius: ts(3),
overflow: "hidden",
justifyContent: "center",
},
outline && {
borderColor: color ?? ((theme: Theme) => theme.accent),
borderStyle: "solid",
borderWidth: px(1),
fover: {
self: {
bg: (theme: Theme) => theme.accent,
},
text: {
color: (theme: Theme) => theme.alternate.contrast,
},
},
},
!outline && {
bg: color ?? ((theme: Theme) => theme.accent),
},
],
props,
className={cn(
"group justify-center overflow-hidden rounded-4xl border border-accent outline-0",
size === "small" && "px-2.5 py-1",
size === "medium" && "px-5 py-2",
size === "large" && "px-10 py-4",
outline && "hover:bg-accent focus:bg-accent",
!outline && "bg-accent hover:bg-background focus:bg-background",
className,
)}
{...props}
>
<P
{...css(
[
"text",
{
marginVertical: 0,
fontSize: rem(0.8),
color: (theme: Theme) =>
outline ? theme.contrast : theme.alternate.contrast,
},
],
textProps,
className={cn(
outline &&
cn(
"dark:text-slate-300",
"group-hover:text-slate-200 group-focus:text-slate-200",
),
!outline &&
cn(
"text-slate-200 dark:text-slate-300",
"group-hover:text-slate-600 group-focus:text-slate-600",
"dark:group-focus:text-slate-300 dark:group-hover:text-slate-300",
),
size === "small" && "text-sm",
)}
>
{capitalize(label)}
@ -86,42 +62,28 @@ export const Chip = ({
};
Chip.Loader = ({
color,
size = "medium",
outline = false,
className,
...props
}: {
color?: string;
size?: "small" | "medium" | "large";
outline?: boolean;
className?: string;
}) => {
const { css } = useYoshiki();
const sizeMult = size === "medium" ? 1 : size === "small" ? 0.5 : 1.5;
return (
<View
{...css(
[
{
pY: ts(1 * sizeMult),
pX: ts(2.5 * sizeMult),
borderRadius: ts(3),
overflow: "hidden",
justifyContent: "center",
},
outline && {
borderColor: color ?? ((theme: Theme) => theme.accent),
borderStyle: "solid",
borderWidth: px(1),
},
!outline && {
bg: color ?? ((theme: Theme) => theme.accent),
},
],
props,
className={cn(
"group justify-center overflow-hidden rounded-4xl border border-accent outline-0",
size === "small" && "px-2.5 py-1",
size === "medium" && "px-5 py-2",
size === "large" && "px-10 py-4",
!outline && "bg-accent",
className,
)}
{...props}
>
<Skeleton {...css({ width: rem(3) })} />
<Skeleton className="w-10" />
</View>
);
};

View File

@ -12,7 +12,7 @@ export const HR = ({
return (
<EHR
className={cn(
"border-0 bg-gray-400 opacity-70",
"shrink-0 border-0 bg-gray-400 opacity-70",
orientation === "vertical" && "mx-4 my-2 h-auto w-px",
orientation === "horizontal" && "mx-2 my-4 h-px w-auto",
className,

View File

@ -1,4 +1,4 @@
import type { ComponentProps, ComponentType } from "react";
import { type ComponentProps, type ComponentType, useState } from "react";
import { Animated, type PressableProps } from "react-native";
import type { SvgProps } from "react-native-svg";
import { withUniwind } from "uniwind";
@ -14,38 +14,33 @@ const IconWrapper = ({ icon: Icon, ...props }: { icon: Icon } & SvgProps) => {
const BaseIcon = withUniwind(IconWrapper, {
stroke: {
fromClassName: "strokeClassName",
fromClassName: "className",
styleProperty: "accentColor",
},
fill: {
fromClassName: "fillClassName",
styleProperty: "accentColor",
fromClassName: "className",
styleProperty: "fill",
},
width: {
fromClassName: "widthClassName",
fromClassName: "className",
styleProperty: "width",
},
height: {
fromClassName: "heightClassName",
fromClassName: "className",
styleProperty: "height",
},
});
export const Icon = ({
className,
fillClassName,
widthClassName,
heightClassName,
...props
}: ComponentProps<typeof BaseIcon>) => {
return (
<BaseIcon
fillClassName={
fillClassName ? fillClassName : "accent-slate-600 dark:accent-slate-400"
}
widthClassName={cn("w-6", widthClassName)}
heightClassName={cn("h-6", heightClassName)}
className={cn("shrink-0", className)}
className={cn(
"h-6 w-6 shrink-0 fill-slate-600 dark:fill-slate-400",
className,
)}
{...props}
/>
);
@ -60,7 +55,7 @@ export const IconButton = <AsProps = PressableProps>({
}: {
as?: ComponentType<AsProps>;
icon: Icon;
iconProps?: Exclude<ComponentProps<typeof Icon>, "icon">;
iconProps?: Omit<ComponentProps<typeof Icon>, "icon">;
className?: string;
} & AsProps) => {
const Container = as ?? PressableFeedback;
@ -69,18 +64,18 @@ export const IconButton = <AsProps = PressableProps>({
<Container
focusRipple
className={cn(
"m-2 h-6 w-6 self-center overflow-hidden rounded-full",
"hover:bg-gray-300 focus-visible:bg-gray-300 focus-visible:dark:bg-gray-700 hover:dark:bg-gray-700",
"h-10 w-10 self-center overflow-hidden rounded-full p-2",
"outline-0 hover:bg-gray-400/50 focus-visible:bg-gray-400/50",
className,
)}
{...(asProps as AsProps)}
>
<Icon icon={icon} />
<Icon icon={icon} {...iconProps} />
</Container>
);
};
const AIconButton = Animated.createAnimatedComponent(IconButton);
const Pressable = Animated.createAnimatedComponent(PressableFeedback);
export const IconFab = <AsProps = PressableProps>({
icon,
@ -88,21 +83,35 @@ export const IconFab = <AsProps = PressableProps>({
iconProps,
...props
}: ComponentProps<typeof IconButton<AsProps>>) => {
const [hover, setHover] = useState(false);
const [focus, setFocus] = useState(false);
return (
<AIconButton
icon={icon}
className={cn("bg-accent", className)}
iconProps={{
...iconProps,
className: cn("text-slate-900", iconProps?.className),
}}
<Pressable
className={cn(
"group h-10 w-10 overflow-hidden rounded-full bg-accent p-2 outline-0",
className,
)}
onHoverIn={() => setHover(true)}
onHoverOut={() => setHover(false)}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
style={{
transform: [{ scale: 1.3 }],
transform: hover || focus ? [{ scale: 1.3 }] : [],
transitionProperty: "transform",
transitionDuration: 3000,
transitionDuration: "150ms",
}}
{...(props as AsProps)}
/>
>
<Icon
icon={icon}
{...iconProps}
className={cn(
"fill-slate-300",
(hover || focus) && "fill-slate-200",
iconProps?.className,
)}
/>
</Pressable>
);
};

View File

@ -8,7 +8,6 @@ import { useYoshiki } from "yoshiki/native";
import type { KImage } from "~/models";
import { useToken } from "~/providers/account-context";
import { cn } from "~/utils";
import type { ImageLayout, YoshikiEnhanced } from "./image";
const ImgBg = withUniwind(EImageBackground);
@ -19,7 +18,6 @@ export const ImageBackground = ({
src,
quality,
alt,
layout,
className,
...props
}: {
@ -27,7 +25,6 @@ export const ImageBackground = ({
quality: "low" | "medium" | "high";
alt?: string;
style?: ImageStyle;
layout?: ImageLayout;
children: ReactNode;
className?: string;
}) => {
@ -57,21 +54,14 @@ export const ImageBackground = ({
};
export const PosterBackground = ({
alt,
layout,
className,
...props
}: Omit<ComponentProps<typeof ImageBackground>, "layout"> & {
style?: ImageStyle;
layout: YoshikiEnhanced<
{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }
>;
}) => {
const { css } = useYoshiki();
}: ComponentProps<typeof ImageBackground>) => {
return (
<ImageBackground
alt={alt!}
layout={{ aspectRatio: 2 / 3, ...layout }}
{...css({ borderRadius: 10, overflow: "hidden" }, props)}
className={cn("aspect-2/3 overflow-hidden rounded", className)}
{...props}
/>
);
};

View File

@ -1,10 +1,11 @@
import { Image as EImage } from "expo-image";
import type { ComponentProps } from "react";
import { type ImageStyle, Platform, type ViewStyle } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { type ImageStyle, Platform } from "react-native";
import { withUniwind } from "uniwind";
import type { YoshikiStyle } from "yoshiki/src/type";
import type { KImage } from "~/models";
import { useToken } from "~/providers/account-context";
import { cn } from "~/utils";
import { Skeleton } from "./skeleton";
export type YoshikiEnhanced<Style> = Style extends any
@ -13,11 +14,7 @@ export type YoshikiEnhanced<Style> = Style extends any
}
: never;
export type ImageLayout = YoshikiEnhanced<
| { width: ImageStyle["width"]; height: ImageStyle["height"] }
| { width: ImageStyle["width"]; aspectRatio: ImageStyle["aspectRatio"] }
| { height: ImageStyle["height"]; aspectRatio: ImageStyle["aspectRatio"] }
>;
const Img = withUniwind(EImage);
// This should stay in think with `ImageBackground`.
// (copy-pasted but change `EImageBackground` with `EImage`)
@ -25,24 +22,23 @@ export const Image = ({
src,
quality,
alt,
layout,
className,
...props
}: {
src: KImage | null;
quality: "low" | "medium" | "high";
alt?: string;
style?: ImageStyle;
layout: ImageLayout;
className?: string;
}) => {
const { css, theme } = useYoshiki();
const { apiUrl, authToken } = useToken();
const uri = src ? `${apiUrl}${src[quality ?? "high"]}` : null;
return (
<EImage
<Img
recyclingKey={uri}
source={{
uri,
uri: uri!,
// use cookies on web to allow `img` to make the call instead of js
headers:
authToken && Platform.OS !== "web"
@ -53,41 +49,24 @@ export const Image = ({
}}
placeholder={{ blurhash: src?.blurhash }}
accessibilityLabel={alt}
{...(css(
[layout, { borderRadius: 6, backgroundColor: theme.overlay0 }],
props,
) as any)}
className={cn("overflow-hidden rounded bg-gray-300", className)}
//imageStyle={{ width: "100%", height: "100%", margin: 0, padding: 0 }}
{...props}
/>
);
};
Image.Loader = ({
layout,
...props
}: {
className?: string;
layout?: ImageLayout;
}) => {
const { css } = useYoshiki();
const border = { borderRadius: 6 } satisfies ViewStyle;
return <Skeleton variant="custom" {...css([layout, border], props)} />;
Image.Loader = (props: { className?: string }) => {
return <Skeleton variant="custom" {...props} />;
};
export const Poster = ({
layout,
className,
...props
}: Omit<ComponentProps<typeof Image>, "layout"> & {
layout: YoshikiEnhanced<
{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }
>;
}) => <Image layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
}: ComponentProps<typeof Image>) => (
<Image className={cn("aspect-2/3", className)} {...props} />
);
Poster.Loader = ({
layout,
...props
}: {
layout: YoshikiEnhanced<
{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }
>;
}) => <Image.Loader layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
Poster.Loader = ({ className, ...props }: { className?: string }) => (
<Image.Loader className={cn("aspect-2/3", className)} {...props} />
);

View File

@ -60,7 +60,10 @@ export const A = ({
return (
<Text
{...linkProps}
className={cn("select-text text-accent", className)}
className={cn(
"select-text text-accent hover:underline focus:underline",
className,
)}
{...props}
>
{children}

View File

@ -121,8 +121,10 @@ const MenuItem = ({
const icn = (icon || selected) && (
<Icon
icon={icon ?? Check}
fillClassName={cn(disabled && "accent-slate-600")}
className="mx-2"
className={cn(
"mx-2",
disabled && "fill-slate-600 dark:fill-slate-600",
)}
/>
);

View File

@ -103,9 +103,9 @@ const MenuItem = forwardRef<
const icn = (icon || selected) && (
<Icon
icon={icon ?? Dot}
fillClassName={cn(disabled && "accent-slate-600")}
className={cn(
"mx-2 group-data-highlighted:fill-slate-200",
disabled && "fill-slate-600 dark:fill-slate-600",
!icon && "h-2 w-2",
)}
/>

View File

@ -16,7 +16,8 @@ export const Skeleton = ({
className={cn(
"relative",
lines === 1 && "overflow-hidden rounded",
variant === "text" && "m-1 h-5 w-4/5",
variant === "text" && lines === 1 && "h-5",
variant === "text" && "my-1 w-4/5",
variant === "round" && "rounded-full",
className,
)}
@ -35,6 +36,7 @@ export const Skeleton = ({
<Animated.View
className="absolute inset-0 bg-linear-to-r from-transparent via-gray-500 to-transparent"
style={{
transform: [{ translateX: "-100%" }],
animationName: {
from: {
transform: [{ translateX: "-100%" }],

View File

@ -31,14 +31,13 @@ const styleText = (
return (
<Component
className={cn(
"m-0 shrink font-sans text-base text-slate-600 dark:text-slate-400",
type === "header" && "font-headers text-slate-900 dark:text-slate-200",
"shrink font-sans text-base text-slate-600 dark:text-slate-400",
type === "header" &&
"font-headers text-slate-900 dark:text-slate-200",
type === "sub" && "font-light text-sm opacity-80",
custom,
className,
)}
// reset expo/html-elements style
style={[{ marginVertical: 0 }, style]}
{...props}
/>
);
@ -52,14 +51,22 @@ export const H3 = styleText(EH3, "header");
export const H4 = styleText(EH4, "header");
export const H5 = styleText(EH5, "header");
export const H6 = styleText(EH6, "header");
export const Heading = styleText(EP, "header");
export const P = styleText(EP, undefined);
export const SubP = styleText(EP, "sub");
export const Heading = styleText(Text as any, "header");
export const P = styleText(Text as any, undefined);
export const SubP = styleText(Text as any, "sub");
export const LI = ({ children, ...props }: ComponentProps<typeof P>) => {
export const LI = ({
children,
className,
...props
}: ComponentProps<typeof P>) => {
return (
<P role="listitem" {...props}>
<Text className="mb-2 h-full pr-1">{String.fromCharCode(0x2022)}</Text>
<P
role="listitem"
className={cn("flex items-center", className)}
{...props}
>
<Text className="h-full px-1">{String.fromCharCode(0x2022)}</Text>
{children}
</P>
);

View File

@ -3,49 +3,40 @@ 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 { LinearGradient } from "expo-linear-gradient";
import { Stack } from "expo-router";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { type ImageStyle, Platform, View } from "react-native";
import { 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 {
Collection,
type Genre,
type KImage,
Movie,
Serie,
Show,
type Studio,
type WatchStatusV,
} from "~/models";
import type { Metadata } from "~/models/utils/metadata";
import {
A,
Chip,
Container,
ContrastArea,
capitalize,
DottedSeparator,
GradientImageBackground,
H1,
H2,
Head,
HR,
IconButton,
IconFab,
ImageBackground,
LI,
Link,
Menu,
@ -58,7 +49,7 @@ import {
} from "~/primitives";
import { useAccount } from "~/providers/account-context";
import { Fetch, type QueryIdentifier } from "~/query";
import { displayRuntime, getDisplayDate } from "~/utils";
import { cn, displayRuntime, getDisplayDate } from "~/utils";
const ButtonList = ({
kind,
@ -66,15 +57,16 @@ const ButtonList = ({
playHref,
trailerUrl,
watchStatus,
iconsClassName,
}: {
kind: "movie" | "serie" | "collection";
slug: string;
playHref: string | null;
trailerUrl: string | null;
watchStatus: WatchStatusV | null;
iconsClassName?: string;
}) => {
const account = useAccount();
const { css, theme } = useYoshiki();
const { t } = useTranslation();
// const metadataRefreshMutation = useMutation({
@ -84,22 +76,13 @@ const ButtonList = ({
// });
return (
<View
{...css({
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
})}
>
<View className="flex-row items-center justify-center">
{playHref !== null && (
<IconFab
icon={PlayArrow}
as={Link}
href={playHref}
{...css({
bg: theme.user.accent,
fover: { self: { bg: theme.user.accent } },
})}
iconProps={{ className: "dark:fill-slate-200" }}
{...tooltip(t("show.play"))}
/>
)}
@ -109,6 +92,7 @@ const ButtonList = ({
as={Link}
href={trailerUrl}
target="_blank"
iconProps={{ className: iconsClassName }}
{...tooltip(t("show.trailer"))}
/>
)}
@ -117,12 +101,14 @@ const ButtonList = ({
kind={kind}
slug={slug}
status={watchStatus}
iconProps={{ className: iconsClassName }}
/>
)}
{(kind === "movie" || account?.isAdmin === true) && (
<Menu
Trigger={IconButton}
icon={MoreHoriz}
iconProps={{ className: iconsClassName }}
{...tooltip(t("misc.more"))}
>
{kind === "movie" && (
@ -166,8 +152,8 @@ export const TitleLine = ({
runtime,
poster,
trailerUrl,
studios,
watchStatus,
className,
...props
}: {
kind: "movie" | "serie" | "collection";
@ -180,199 +166,58 @@ export const TitleLine = ({
runtime: number | null;
poster: KImage | null;
trailerUrl: string | null;
studios: Studio[] | null;
watchStatus: WatchStatusV | null;
} & Stylable) => {
const { css, theme } = useYoshiki();
const { t } = useTranslation();
className?: string;
}) => {
return (
<Container
{...css(
{
flexDirection: { xs: "column", md: "row" },
},
props,
)}
className={cn("flex-1 max-sm:items-center sm:flex-row", className)}
{...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,
])}
>
{studios !== null && (
<P
{...css({
flexWrap: "wrap",
color: (theme: Theme) => theme.user.paragraph,
})}
>
{t("show.studios")}:{" "}
{studios.map((studio, i) => (
<Fragment key={studio.id}>
<P {...(css({ m: 0 }) as any)}>{i !== 0 && ", "}</P>
<A href={`/studios/${studio.slug}`}>{studio.name}</A>
</Fragment>
))}
<Poster
src={poster}
alt={name}
quality="medium"
className="w-1/2 shrink-0 max-sm:max-w-44 md:w-1/4"
/>
<View className="flex-1 self-center max-sm:mt-8 max-sm:items-center sm:pl-10 sm:max-md:self-end md:max-lg:mt-5">
<P className="max-sm:text-center">
<H1 className="sm:text-slate-200">{name}</H1>
{date && <P className="text-3xl sm:text-slate-300"> ({date})</P>}
</P>
{tagline && (
<P className="font-light text-2xl max-sm:text-center sm:text-slate-200">
{tagline}
</P>
)}
<View className="flex-warp flex-row items-center max-sm:justify-center sm:mt-8">
<ButtonList
kind={kind}
slug={slug}
playHref={playHref}
trailerUrl={trailerUrl}
watchStatus={watchStatus}
iconsClassName="lg:fill-slate-200 dark:fill-slate-200"
/>
{rating !== null && rating !== 0 && (
<>
<DottedSeparator className="lg:text-slate-200 dark:text-slate-200" />
<Rating
rating={rating}
textClassName="lg:text-slate-200 dark:text-slate-200"
iconClassName="lg:fill-slate-200 dark:fill-slate-200"
/>
</>
)}
{runtime && (
<>
<DottedSeparator className="lg:text-slate-200 dark:text-slate-200" />
<P className="lg:text-slate-200 dark:text-slate-200">
{displayRuntime(runtime)}
</P>
</>
)}
</View>
</View>
</Container>
);
@ -380,109 +225,44 @@ export const TitleLine = ({
TitleLine.Loader = ({
kind,
className,
...props
}: {
kind: "serie" | "movie" | "collection";
className?: string;
}) => {
const { css, theme } = useYoshiki();
return (
<Container
{...css(
{
flexDirection: { xs: "column", md: "row" },
},
props,
)}
className={cn("flex-1 max-sm:items-center sm:flex-row", className)}
{...props}
>
<View
{...css({
flexDirection: { xs: "column", sm: "row" },
alignItems: { xs: "center", sm: "flex-start" },
flexGrow: 1,
maxWidth: percent(100),
})}
>
<Poster.Loader
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,
})}
>
<Skeleton
variant="header"
{...css({ width: rem(15), height: rem(2.5), marginBottom: rem(1) })}
<Poster.Loader className="w-1/2 shrink-0 max-sm:max-w-44 md:w-1/4" />
<View className="flex-1 self-center max-sm:mt-8 max-sm:items-center sm:pl-10 sm:max-md:self-end md:max-lg:mt-5">
<Skeleton variant="custom" className="h-10 w-2/5 max-sm:text-center" />
<Skeleton className="h-6 w-4/5 max-sm:text-center" />
<View className="flex-warp flex-row items-center max-sm:justify-center sm:mt-8">
<IconFab
icon={PlayArrow}
iconProps={{ className: "lg:fill-slate-200" }}
/>
<Skeleton
{...css({
width: rem(5),
height: rem(1.5),
marginBottom: rem(0.5),
})}
<IconButton
icon={Theaters}
iconProps={{ className: "lg:fill-slate-200" }}
/>
<View
{...css({
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
justifyContent: "center",
})}
>
<IconFab
icon={PlayArrow}
color={{ xs: theme.user.colors.black, md: theme.colors.black }}
{...css({
bg: theme.user.accent,
fover: { self: { bg: theme.user.accent } },
})}
/>
{kind !== "collection" && (
<IconButton
icon={Theaters}
color={{ xs: theme.user.contrast, md: theme.colors.white }}
icon={BookmarkAdd}
iconProps={{ className: "lg:fill-slate-200" }}
/>
{kind !== "collection" && (
<IconButton
icon={BookmarkAdd}
color={{ xs: theme.user.contrast, md: theme.colors.white }}
/>
)}
{kind === "movie" && <IconButton icon={MoreHoriz} />}
<DottedSeparator
{...css({
color: {
xs: theme.user.contrast,
md: theme.colors.white,
},
})}
/>
<Rating.Loader
color={{ xs: theme.user.contrast, md: theme.colors.white }}
/>
<DottedSeparator
{...css({
color: {
xs: theme.user.contrast,
md: theme.colors.white,
},
})}
/>
<Skeleton {...css({ width: rem(3) })} />
</View>
)}
{kind === "movie" && <IconButton icon={MoreHoriz} />}
<DottedSeparator className="lg:text-slate-200" />
<Rating.Loader
textClassName="lg:text-slate-200"
iconClassName="lg:fill-slate-200"
/>
<DottedSeparator className="lg:text-slate-200" />
<Skeleton className="w-1/5" />
</View>
</View>
</Container>
@ -493,95 +273,82 @@ const Description = ({
description,
tags,
genres,
studios,
externalIds,
...props
}: {
description: string | null;
tags: string[];
genres: Genre[];
studios: Studio[];
externalIds: Metadata;
} & 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")}:{" "}
{genres.map((genre, i) => (
<Fragment key={genre}>
<P {...(css({ m: 0 }) as any)}>{i !== 0 && ", "}</P>
<A href={`/genres/${genre.toLowerCase()}`}>
{t(`genres.${genre}`)}
</A>
</Fragment>
))}
</P>
<View
{...css({
flexDirection: "column",
flexGrow: 1,
flexBasis: { sm: 0 },
paddingTop: ts(4),
})}
>
<P {...css({ textAlign: "justify" })}>
<Container className="py-10" {...props}>
<View className="flex-1 flex-col-reverse sm:flex-row">
<P className="py-5 text-justify">
{description ?? t("show.noOverview")}
</P>
<View
{...css({
flexWrap: "wrap",
flexDirection: "row",
alignItems: "center",
marginTop: ts(0.5),
})}
>
<P {...(css({ marginRight: ts(0.5) }) as any)}>{t("show.tags")}:</P>
{tags.map((tag) => (
<Chip
key={tag}
label={tag && capitalize(tag)}
href={`/search?q=${tag}`}
size="small"
{...css({ m: ts(0.5) })}
/>
))}
<View className="basis-1/5 flex-row xl:mt-[-100px]">
<HR orientation="vertical" className="max-sm:hidden" />
<View className="flex-1 items-center max-sm:flex-row">
<H2>{t("show.genre")}</H2>
{genres.length ? (
<UL className="flex-1 flex-wrap max-sm:flex-row max-sm:items-center max-sm:text-center">
{genres.map((genre) => (
<LI key={genre}>
<A href={`/genres/${genre.toLowerCase()}`}>
{t(`genres.${genre}`)}
</A>
</LI>
))}
</UL>
) : (
<P>{t("show.genre-none")}</P>
)}
</View>
</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>
{genres.length ? (
<UL>
{genres.map((genre) => (
<LI key={genre}>
<A href={`/genres/${genre.toLowerCase()}`}>
{t(`genres.${genre}`)}
</A>
</LI>
))}
</UL>
) : (
<P>{t("show.genre-none")}</P>
)}
<View className="mt-5 flex-row flex-wrap items-center">
<P className="mr-1">{t("show.tags")}:</P>
{tags.map((tag) => (
<Chip
key={tag}
label={tag && capitalize(tag)}
href={`/search?q=${tag}`}
size="small"
className="m-1"
/>
))}
</View>
<P className="my-5 flex-row flex-wrap items-center">
<P className="mr-1">{t("show.studios")}:</P>
{studios.map((x, i) => (
<>
{i !== 0 && ","}
<A key={x.id} href={x.slug} className="ml-2">
{x.name}
</A>
</>
))}
</P>
<View className="flex-row flex-wrap items-center">
<P className="mr-1 text-center">{t("show.links")}:</P>
{Object.entries(externalIds)
.filter(([_, data]) => data.link)
.map(([name, data]) => (
<Chip
key={name}
label={name}
href={data.link}
target="_blank"
size="small"
outline
className="m-1"
/>
))}
</View>
</Container>
);
@ -589,72 +356,40 @@ const Description = ({
Description.Loader = ({ ...props }: object) => {
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")}:{" "}
{[...Array<Genre>(3)].map((_, i) => (
<Fragment key={i.toString()}>
<P {...(css({ m: 0 }) as any)}>{i !== 0 && ", "}</P>
<Skeleton {...css({ width: rem(5) })} />
</Fragment>
))}
</P>
<View
{...css({
flexDirection: "column",
flexGrow: 1,
flexBasis: { sm: 0 },
paddingTop: ts(4),
})}
>
<Container className="py-10" {...props}>
<View className="flex-1 flex-col-reverse sm:flex-row">
<Skeleton lines={4} />
<View
{...css({
flexWrap: "wrap",
flexDirection: "row",
alignItems: "center",
marginTop: ts(0.5),
})}
>
<P {...(css({ marginRight: ts(0.5) }) as any)}>{t("show.tags")}:</P>
{[...Array<string>(3)].map((_, i) => (
<Chip.Loader key={i} size="small" {...css({ m: ts(0.5) })} />
))}
<View className="basis-1/5 flex-row xl:mt-[-100px]">
<HR orientation="vertical" className="max-sm:hidden" />
<View className="flex-1 items-center max-sm:flex-row">
<H2>{t("show.genre")}</H2>
<UL className="flex-1 flex-wrap max-sm:flex-row max-sm:items-center max-sm:text-center">
{[...Array(3)].map((_, i) => (
<LI key={i}>
<Skeleton className="w-25" />
</LI>
))}
</UL>
</View>
</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>
<UL>
{[...Array<Genre>(3)].map((_, i) => (
<LI key={i}>
<Skeleton {...css({ marginBottom: 0 })} />
</LI>
))}
</UL>
<View className="mt-5 flex-row flex-wrap items-center">
<P className="mr-1">{t("show.tags")}:</P>
{[...Array(3)].map((_, i) => (
<Chip.Loader key={i} size="small" className="m-1" />
))}
</View>
<P className="my-5 flex flex-row flex-wrap items-center">
<P className="mr-1">{t("show.studios")}:</P>
<Skeleton className="w-2/5" />
</P>
<View className="flex-row flex-wrap items-center">
<P className="mr-1 text-center">{t("show.links")}:</P>
{[...Array(2)].map((_, i) => (
<Chip.Loader key={i} size="small" outline className="m-1" />
))}
</View>
</Container>
);
@ -667,9 +402,6 @@ export const Header = ({
kind: "movie" | "serie";
slug: string;
}) => {
const { css, theme } = useYoshiki();
const { t } = useTranslation();
return (
<>
<Stack.Screen
@ -681,141 +413,60 @@ export const Header = ({
<Fetch
query={Header.query(kind, slug)}
Render={(data) => (
<View {...css({ flex: 1 })}>
<View className="flex-1">
<Head
title={data.name}
description={data.description}
image={data.thumbnail?.high}
/>
<GradientImageBackground
<ImageBackground
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(65),
},
}}
{...(css({
position: "absolute",
top: 0,
left: 0,
right: 0,
minHeight: {
xs: px(350),
sm: px(300),
md: px(400),
lg: px(600),
},
}) as any)}
/>
<ContrastArea>
<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}
studios={data.kind !== "collection" ? data.studios! : null}
playHref={data.kind !== "collection" ? data.playHref : null}
trailerUrl={data.kind !== "collection" ? data.trailerUrl : null}
watchStatus={
data.kind !== "collection"
? (data.watchStatus?.status ?? null)
: null
}
{...css({
marginTop: {
xs: max(vh(20), px(200)),
sm: vh(45),
md: max(vh(30), px(150)),
lg: max(vh(35), px(200)),
},
})}
/>
</ContrastArea>
<Description
description={data?.description}
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),
})}
className="absolute top-0 right-0 left-0 h-[40vh] w-full sm:h-[60vh] sm:min-h-[750px] md:min-h-[680px] lg:h-[65vh]"
>
<P {...css({ marginRight: ts(0.5), textAlign: "center" })}>
{t("show.links")}:
</P>
{Object.entries(data.externalId!)
.filter(([_, data]) => data.link)
.map(([name, data]) => (
<Chip
key={name}
label={name}
href={data.link}
target="_blank"
size="small"
outline
{...css({ m: ts(0.5) })}
/>
))}
</Container>
<View className="absolute inset-0 bg-linear-to-b from-transparent to-slate-950/70" />
</ImageBackground>
<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}
playHref={data.kind !== "collection" ? data.playHref : null}
trailerUrl={data.kind !== "collection" ? data.trailerUrl : null}
watchStatus={
data.kind !== "collection"
? (data.watchStatus?.status ?? null)
: null
}
className="mt-[max(20vh,200px)] sm:mt-[35vh] md:mt-[max(45vh,150px)] lg:mt-[max(35vh,200px)]"
/>
<Description
description={data.description}
tags={data.tags}
genres={data.genres}
studios={data.kind !== "collection" ? data.studios! : []}
externalIds={data.externalId}
/>
{/* {type === "show" && ( */}
{/* <ShowWatchStatusCard {...(data?.watchStatus as any)} /> */}
{/* )} */}
</View>
)}
Loader={() => (
<>
<LinearGradient
start={{ x: 0, y: 0.25 }}
end={{ x: 0, y: 1 }}
colors={["transparent", theme.darkOverlay]}
{...(css({
width: percent(100),
height: {
xs: vh(40),
sm: min(vh(60), px(750)),
md: min(vh(60), px(680)),
lg: vh(65),
},
minHeight: {
xs: px(350),
sm: px(300),
md: px(400),
lg: px(600),
},
position: "absolute",
top: 0,
left: 0,
right: 0,
}) as any)}
/>
<View className="flex-1">
<View className="absolute top-0 right-0 left-0 h-[40vh] w-full bg-linear-to-b from-transparent to-slate-950/70 sm:h-[60vh] sm:min-h-[750px] md:min-h-[680px] lg:h-[65vh]" />
<TitleLine.Loader
kind={kind}
{...css({
marginTop: {
xs: max(vh(20), px(200)),
sm: vh(45),
md: max(vh(30), px(150)),
lg: max(vh(35), px(200)),
},
})}
className="mt-[max(20vh,200px)] sm:mt-[35vh] md:mt-[max(45vh,150px)] lg:mt-[max(35vh,200px)]"
/>
<Description.Loader />
</>
</View>
)}
/>
</>
@ -825,8 +476,8 @@ export const Header = ({
Header.query = (
kind: "serie" | "movie" | "collection",
slug: string,
): QueryIdentifier<Serie | Movie | Collection> => ({
parser: kind === "serie" ? Serie : kind === "movie" ? Movie : Collection,
): QueryIdentifier<Show> => ({
parser: Show,
path: ["api", `${kind}s`, slug],
params: {
with: ["studios", ...(kind === "serie" ? ["firstEntry", "nextEntry"] : [])],

View File

@ -8,18 +8,18 @@ import { EntryLine, entryDisplayNumber } from "~/components/entries";
import type { Entry, Serie } from "~/models";
import { Container, H2 } from "~/primitives";
import { Fetch } from "~/query";
import { cn, useQueryState } from "~/utils";
import { useQueryState } from "~/utils";
import { Header } from "./header";
import { EntryList } from "./season";
export const Svg = withUniwind(RSvg, {
stroke: {
fromClassName: "strokeClassName",
fromClassName: "className",
styleProperty: "accentColor",
},
fill: {
fromClassName: "fillClassName",
styleProperty: "accentColor",
fromClassName: "className",
styleProperty: "fill",
},
});
@ -79,7 +79,7 @@ const SerieHeader = () => {
/>
{/* <DetailsCollections type="serie" slug={slug} /> */}
{/* <Staff slug={slug} /> */}
<SvgWave fillClassName={cn("accent-card")} className="flex-1 shrink-0" />
<SvgWave className="flex-1 shrink-0 fill-card" />
</View>
);
};

View File

@ -27,14 +27,15 @@ export const useQueryState = <S>(key: string, initial: S) => {
return [state, update] as const;
};
export const getDisplayDate = (data: Show | Movie) => {
const {
startAir,
endAir,
airDate,
}: { startAir?: Date | null; endAir?: Date | null; airDate?: Date | null } =
data;
export const getDisplayDate = ({
startAir,
endAir,
airDate,
}: {
startAir?: Date | null;
endAir?: Date | null;
airDate?: Date | null;
}) => {
if (startAir) {
if (!endAir || startAir.getFullYear() === endAir.getFullYear()) {
return startAir.getFullYear().toString();