mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-03-16 14:39:15 -04:00
Rework show details card
This commit is contained in:
parent
3bfadc673e
commit
d587d119fd
176
front/src/components/items/item-details.tsx
Normal file
176
front/src/components/items/item-details.tsx
Normal 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;
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user