Rework show details card

This commit is contained in:
Zoe Roux 2026-02-04 11:38:37 +01:00
parent 3bfadc673e
commit d587d119fd
No known key found for this signature in database
4 changed files with 189 additions and 288 deletions

View File

@ -0,0 +1,176 @@
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { ItemContext } from "~/components/items/context-menus";
import { ItemWatchStatus } from "~/components/items/item-helpers";
import type { Genre, KImage, WatchStatusV } from "~/models";
import {
Chip,
IconFab,
Link,
P,
PosterBackground,
Skeleton,
SubP,
tooltip,
ts,
} from "~/primitives";
import type { Layout } from "~/query";
import { cn } from "~/utils";
export const ItemDetails = ({
slug,
kind,
name,
tagline,
subtitle,
description,
poster,
genres,
href,
playHref,
watchStatus,
availableCount,
seenCount,
className,
...props
}: {
slug: string;
kind: "movie" | "serie" | "collection";
name: string;
tagline: string | null;
subtitle: string | null;
poster: KImage | null;
genres: Genre[] | null;
description: string | null;
href: string;
playHref: string | null;
watchStatus: WatchStatusV | null;
availableCount?: number | null;
seenCount?: number | null;
className?: string;
}) => {
const [moreOpened, setMoreOpened] = useState(false);
const { t } = useTranslation();
return (
<View className={cn("h-72", className)} {...props}>
<Link
href={moreOpened ? undefined : href}
onLongPress={() => setMoreOpened(true)}
className={cn(
"h-full flex-row overflow-hidden rounded-xl bg-card",
"group outline-0 ring-accent focus-within:ring-3 hover:ring-3",
)}
>
<PosterBackground
src={poster}
alt=""
quality="low"
className="h-full rounded-none"
>
<View className="absolute bottom-0 w-full bg-slate-900/50 p-2 px-3">
<P className="text-slate-200 group-focus-within:underline group-hover:underline">
{name}
</P>
{subtitle && <SubP className="text-slate-400">{subtitle}</SubP>}
</View>
<ItemWatchStatus
watchStatus={watchStatus}
availableCount={availableCount}
seenCount={seenCount}
/>
</PosterBackground>
<View className="mb-14 flex-1 justify-end p-2">
<View className="my-2 flex-row-reverse justify-between">
{kind !== "collection" && (
<ItemContext
kind={kind}
slug={slug}
status={watchStatus}
isOpen={moreOpened}
setOpen={(v) => setMoreOpened(v)}
/>
)}
{tagline && <P className="p-1">{tagline}</P>}
</View>
<ScrollView className="px-1">
<SubP className="text-justify">
{description ?? t("show.noOverview")}
</SubP>
</ScrollView>
</View>
</Link>
{/* This view needs to be out of the Link because nested <a> are not allowed on the web */}
<View
className={cn(
"absolute right-0 bottom-0 left-0 ml-[192px] h-14",
"flex-row items-center justify-end overflow-hidden bg-popover",
"overflow-hidden rounded-br-xl",
)}
>
{genres && (
<ScrollView
horizontal
className="h-full"
contentContainerClassName="items-center"
>
{genres.map((x, i) => (
<Chip
key={x ?? i}
label={t(`genres.${x}`)}
href={"#"}
size="small"
className="mx-1"
/>
))}
</ScrollView>
)}
{playHref !== null && (
<IconFab
icon={PlayArrow}
as={Link}
href={playHref}
className="mx-2"
{...tooltip(t("show.play"))}
/>
)}
</View>
</View>
);
};
ItemDetails.Loader = (props: object) => {
return (
<View
className={"h-72 flex-row overflow-hidden rounded-xl bg-card"}
{...props}
>
<View className="aspect-2/3 h-full bg-gray-400">
<View className="absolute bottom-0 w-full bg-slate-900/50 p-2 px-3">
<Skeleton className="h-5 w-4/5" />
<Skeleton className="h-3.5 w-2/5" />
</View>
</View>
<View className="flex-1">
<View className="flex-1 p-2">
<Skeleton className="m-1 my-2" />
<Skeleton lines={5} className="mx-1 w-full" />
</View>
<View className="h-14 flex-row items-center bg-popover">
<Chip.Loader size="small" className="mx-2" />
<Chip.Loader size="small" className="mx-2" />
</View>
</View>
</View>
);
};
ItemDetails.layout = {
size: 288,
numColumns: { xs: 1, md: 2, xl: 3 },
layout: "grid",
gap: { xs: ts(1), md: ts(8) },
} satisfies Layout;

View File

@ -21,9 +21,9 @@ export const ItemWatchStatus = ({
{...props}
>
{watchStatus === "completed" ? (
<Icon icon={Done} />
<Icon icon={Done} className="fill-slate-400" />
) : (
<P className="text-center">
<P className="text-center text-slate-400">
{seenCount ?? 0}/{availableCount}
</P>
)}

View File

@ -34,7 +34,7 @@ export const Chip = ({
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",
!outline && "bg-accent hover:bg-transparent focus:bg-transparent",
className,
)}
{...props}

View File

@ -1,287 +1,13 @@
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { calc, percent, px, rem, type Theme, useYoshiki } from "yoshiki/native";
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { ItemGrid } from "~/components/items";
import { ItemContext } from "~/components/items/context-menus";
import { ItemWatchStatus } from "~/components/items/item-helpers";
import { type Genre, type KImage, Show, type WatchStatusV } from "~/models";
import {
Chip,
focusReset,
H3,
IconFab,
Link,
P,
PosterBackground,
Skeleton,
SubP,
tooltip,
ts,
} from "~/primitives";
import { InfiniteFetch, type Layout, type QueryIdentifier } from "~/query";
import { ItemDetails } from "~/components/items/item-details";
import { Show } from "~/models";
import { H3 } from "~/primitives";
import { InfiniteFetch, type QueryIdentifier } from "~/query";
import { getDisplayDate } from "~/utils";
export const ItemDetails = ({
slug,
kind,
name,
tagline,
subtitle,
description,
poster,
genres,
href,
playHref,
watchStatus,
availableCount,
seenCount,
...props
}: {
slug: string;
kind: "movie" | "serie" | "collection";
name: string;
tagline: string | null;
subtitle: string | null;
poster: KImage | null;
genres: Genre[] | null;
description: string | null;
href: string;
playHref: string | null;
watchStatus: WatchStatusV | null;
availableCount?: number | null;
seenCount?: number | null;
}) => {
const [moreOpened, setMoreOpened] = useState(false);
const { css } = useYoshiki("recommended-card");
const { t } = useTranslation();
return (
<View
{...css(
{
height: ItemDetails.layout.size,
},
props,
)}
>
<Link
href={moreOpened ? undefined : href}
onLongPress={() => setMoreOpened(true)}
{...css({
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
flexDirection: "row",
bg: (theme) => theme.variant.background,
borderRadius: px(12),
overflow: "hidden",
borderColor: (theme) => theme.background,
borderWidth: ts(0.25),
borderStyle: "solid",
fover: {
self: {
...focusReset,
borderColor: (theme: Theme) => theme.accent,
},
title: {
textDecorationLine: "underline",
},
},
})}
>
<PosterBackground
src={poster}
alt=""
quality="low"
layout={{ height: percent(100) }}
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
>
<View
{...css({
bg: (theme) => theme.darkOverlay,
position: "absolute",
left: 0,
right: 0,
bottom: 0,
p: ts(1),
})}
>
<P
{...css([
{ m: 0, color: (theme: Theme) => theme.colors.white },
"title",
])}
>
{name}
</P>
{subtitle && <SubP {...(css({ m: 0 }) as any)}>{subtitle}</SubP>}
</View>
<ItemWatchStatus
watchStatus={watchStatus}
availableCount={availableCount}
seenCount={seenCount}
/>
</PosterBackground>
<View
{...css({
flexShrink: 1,
flexGrow: 1,
justifyContent: "flex-end",
marginBottom: px(50),
})}
>
<View
{...css({
flexDirection: "row-reverse",
justifyContent: "space-between",
alignContent: "flex-start",
})}
>
{kind !== "collection" && (
<ItemContext
kind={kind}
slug={slug}
status={watchStatus}
isOpen={moreOpened}
setOpen={(v) => setMoreOpened(v)}
/>
)}
{tagline && <P {...css({ p: ts(1) })}>{tagline}</P>}
</View>
<ScrollView {...css({ pX: ts(1) })}>
<SubP {...css({ textAlign: "justify" })}>
{description ?? t("show.noOverview")}
</SubP>
</ScrollView>
</View>
</Link>
{/* This view needs to be out of the Link because nested <a> are not allowed on the web */}
<View
{...css({
position: "absolute",
// Take the border into account
bottom: ts(0.25),
right: ts(0.25),
borderWidth: ts(0.25),
borderColor: "transparent",
borderBottomEndRadius: px(6),
overflow: "hidden",
// Calculate the size of the poster
left: calc(px(ItemDetails.layout.size), "*", 2 / 3),
bg: (theme) => theme.themeOverlay,
flexDirection: "row",
pX: 4,
justifyContent: "flex-end",
height: px(50),
})}
>
{genres && (
<ScrollView
horizontal
contentContainerStyle={{ alignItems: "center" }}
>
{genres.map((x, i) => (
<Chip
key={x ?? i}
label={t(`genres.${x}`)}
href={"#"}
size="small"
{...css({ mX: ts(0.5) })}
/>
))}
</ScrollView>
)}
{playHref !== null && (
<IconFab
icon={PlayArrow}
size={20}
as={Link}
href={playHref}
{...tooltip(t("show.play"))}
{...css({
fover: { self: { transform: "scale(1.2)" as any, mX: ts(0.5) } },
})}
/>
)}
</View>
</View>
);
};
ItemDetails.Loader = (props: object) => {
const { css } = useYoshiki();
return (
<View
{...css(
{
height: ItemDetails.layout.size,
flexDirection: "row",
bg: (theme) => theme.variant.background,
borderRadius: px(12),
overflow: "hidden",
borderColor: (theme) => theme.background,
borderWidth: ts(0.25),
borderStyle: "solid",
},
props,
)}
>
<PosterBackground
src={null}
alt=""
quality="low"
layout={{ height: percent(100) }}
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
>
<View
{...css({
bg: (theme) => theme.darkOverlay,
position: "absolute",
left: 0,
right: 0,
bottom: 0,
p: ts(1),
})}
>
<Skeleton {...css({ width: percent(100) })} />
<Skeleton {...css({ height: rem(0.8) })} />
</View>
</PosterBackground>
<View {...css({ flexShrink: 1, flexGrow: 1 })}>
<View {...css({ flexGrow: 1, flexShrink: 1, pX: ts(1) })}>
<Skeleton {...css({ marginVertical: ts(2) })} />
<Skeleton lines={5} {...css({ height: rem(0.8) })} />
</View>
<View
{...css({
bg: (theme) => theme.themeOverlay,
pX: 4,
height: px(50),
flexDirection: "row",
alignItems: "center",
})}
>
<Chip.Loader size="small" {...css({ mX: ts(0.5) })} />
<Chip.Loader size="small" {...css({ mX: ts(0.5) })} />
</View>
</View>
</View>
);
};
ItemDetails.layout = {
size: ts(36),
numColumns: { xs: 1, md: 2, xl: 3 },
layout: "grid",
gap: { xs: ts(1), md: ts(8) },
} satisfies Layout;
export const Recommended = () => {
const { t } = useTranslation();
const { css } = useYoshiki();
@ -290,7 +16,7 @@ export const Recommended = () => {
<View
{...css({ marginX: ItemGrid.layout.gap, marginTop: ItemGrid.layout.gap })}
>
<H3 {...css({ pX: ts(0.5) })}>{t("home.recommended")}</H3>
<H3 className="px-1">{t("home.recommended")}</H3>
<InfiniteFetch
query={Recommended.query()}
layout={ItemDetails.layout}
@ -320,10 +46,9 @@ export const Recommended = () => {
watchStatus={
(item.kind !== "collection" && item.watchStatus?.status) || null
}
unseenEpisodesCount={
item.kind === "serie"
? item.availableCount - (item.watchStatus?.seenCount ?? 0)
: null
availableCount={item.kind === "serie" ? item.availableCount : null}
seenCount={
item.kind === "serie" ? item.watchStatus?.seenCount : null
}
/>
)}