Rework entry box/line

This commit is contained in:
Zoe Roux
2025-07-14 22:24:48 +02:00
parent dbf63e1b22
commit d5c7ee40bc
6 changed files with 383 additions and 418 deletions
+147
View File
@@ -0,0 +1,147 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import {
percent,
rem,
type Stylable,
type Theme,
useYoshiki,
} from "yoshiki/native";
import { EntryContext } from "~/components/items/context-menus";
import { ItemProgress } from "~/components/items/item-grid";
import type { KImage, WatchStatusV } from "~/models";
import {
focusReset,
Image,
ImageBackground,
important,
Link,
P,
Skeleton,
SubP,
ts,
} from "~/primitives";
export const EntryBox = ({
slug,
serieSlug,
name,
description,
thumbnail,
href,
watchedPercent,
watchedStatus,
...props
}: Stylable & {
slug: string;
// if serie slug is null, disable "Go to serie" in the context menu
serieSlug: string | null;
name: string | null;
description: string | null;
href: string;
thumbnail: KImage | null;
watchedPercent: number | null;
watchedStatus: WatchStatusV | null;
}) => {
const [moreOpened, setMoreOpened] = useState(false);
const { css } = useYoshiki("episodebox");
const { t } = useTranslation();
return (
<Link
href={moreOpened ? undefined : href}
onLongPress={() => setMoreOpened(true)}
{...css(
{
alignItems: "center",
child: {
poster: {
borderColor: (theme) => theme.background,
borderWidth: ts(0.5),
borderStyle: "solid",
borderRadius: 6,
},
more: {
opacity: 0,
},
},
fover: {
self: focusReset,
poster: {
borderColor: (theme: Theme) => theme.accent,
},
title: {
textDecorationLine: "underline",
},
more: {
opacity: 1,
},
},
},
props,
)}
>
<ImageBackground
src={thumbnail}
quality="low"
alt=""
layout={{ width: percent(100), aspectRatio: 16 / 9 }}
{...(css("poster") as any)}
>
{(watchedPercent || watchedStatus === "completed") && (
<ItemProgress watchPercent={watchedPercent ?? 100} />
)}
<EntryContext
slug={slug}
serieSlug={serieSlug}
status={watchedStatus}
isOpen={moreOpened}
setOpen={(v) => setMoreOpened(v)}
{...css([
{
position: "absolute",
top: 0,
right: 0,
bg: (theme) => theme.darkOverlay,
},
"more",
Platform.OS === "web" &&
moreOpened && { display: important("flex") },
])}
/>
</ImageBackground>
<P {...css([{ marginY: 0, textAlign: "center" }, "title"])}>
{name ?? t("show.episodeNoMetadata")}
</P>
<SubP
numberOfLines={3}
{...css({
marginTop: 0,
textAlign: "center",
})}
>
{description}
</SubP>
</Link>
);
};
EntryBox.Loader = (props: Stylable) => {
const { css } = useYoshiki();
return (
<View
{...css(
{
alignItems: "center",
},
props,
)}
>
<Image.Loader layout={{ width: percent(100), aspectRatio: 16 / 9 }} />
<Skeleton {...css({ width: percent(50) })} />
<Skeleton {...css({ width: percent(75), height: rem(0.8) })} />
</View>
);
};
+205
View File
@@ -0,0 +1,205 @@
import ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg";
import ExpandLess from "@material-symbols/svg-400/rounded/keyboard_arrow_up-fill.svg";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, type PressableProps, View } from "react-native";
import { percent, type Stylable, useYoshiki } from "yoshiki/native";
import { EntryContext } from "~/components/items/context-menus";
import { ItemProgress } from "~/components/items/item-grid";
import type { KImage, WatchStatusV } from "~/models";
import {
focusReset,
H6,
IconButton,
Image,
ImageBackground,
important,
Link,
P,
Skeleton,
SubP,
tooltip,
ts,
} from "~/primitives";
import type { Layout } from "~/query";
import { displayRuntime } from "~/utils";
export const EntryLine = ({
slug,
serieSlug,
name,
thumbnail,
description,
displayNumber,
airDate,
runtime,
watchedPercent,
watchedStatus,
href,
...props
}: {
slug: string;
// if show slug is null, disable "Go to show" in the context menu
serieSlug: string | null;
displayNumber: string;
name: string | null;
description: string | null;
thumbnail: KImage | null;
airDate: Date | null;
runtime: number | null;
watchedPercent: number | null;
watchedStatus: WatchStatusV | null;
href: string;
} & PressableProps) => {
const [moreOpened, setMoreOpened] = useState(false);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const { css } = useYoshiki("episode-line");
const { t } = useTranslation();
return (
<Link
href={moreOpened ? undefined : href}
onLongPress={() => setMoreOpened(true)}
{...css(
{
alignItems: "center",
flexDirection: "row",
child: {
more: {
opacity: 0,
},
},
fover: {
self: focusReset,
title: {
textDecorationLine: "underline",
},
more: {
opacity: 1,
},
},
},
props,
)}
>
<ImageBackground
src={thumbnail}
quality="low"
alt=""
layout={{
width: percent(18),
aspectRatio: 16 / 9,
}}
{...(css({ flexShrink: 0, m: ts(1), borderRadius: 6 }) as any)}
>
{(watchedPercent || watchedStatus === "completed") && (
<ItemProgress watchPercent={watchedPercent ?? 100} />
)}
</ImageBackground>
<View {...css({ flexGrow: 1, flexShrink: 1, m: ts(1) })}>
<View
{...css({
flexGrow: 1,
flexShrink: 1,
flexDirection: "row",
justifyContent: "space-between",
})}
>
{/* biome-ignore lint/a11y/useValidAriaValues: simply use H6 for the style but keep a P */}
<H6 aria-level={undefined} {...css([{ flexShrink: 1 }, "title"])}>
{[displayNumber, name ?? t("show.episodeNoMetadata")].join(" · ")}
</H6>
<View {...css({ flexDirection: "row", alignItems: "center" })}>
<SubP>
{[
airDate
? // @ts-expect-error Source https://www.i18next.com/translation-function/formatting#datetime
t("{{val, datetime}}", { val: airDate })
: null,
displayRuntime(runtime),
]
.filter((item) => item != null)
.join(" · ")}
</SubP>
<EntryContext
slug={slug}
serieSlug={serieSlug}
status={watchedStatus}
isOpen={moreOpened}
setOpen={(v) => setMoreOpened(v)}
{...css([
"more",
{ display: "flex", marginLeft: ts(3) },
Platform.OS === "web" &&
moreOpened && { display: important("flex") },
])}
/>
</View>
</View>
<View
{...css({ flexDirection: "row", justifyContent: "space-between" })}
>
<P numberOfLines={descriptionExpanded ? undefined : 3}>
{description}
</P>
<IconButton
{...css(["more", Platform.OS !== "web" && { opacity: 1 }])}
icon={descriptionExpanded ? ExpandLess : ExpandMore}
{...tooltip(
t(descriptionExpanded ? "misc.collapse" : "misc.expand"),
)}
onPress={(e) => {
e.preventDefault();
setDescriptionExpanded((isExpanded) => !isExpanded);
}}
/>
</View>
</View>
</Link>
);
};
EntryLine.Loader = (props: Stylable) => {
const { css } = useYoshiki();
return (
<View
{...css(
{
alignItems: "center",
flexDirection: "row",
},
props,
)}
>
<Image.Loader
layout={{
width: percent(18),
aspectRatio: 16 / 9,
}}
{...css({ flexShrink: 0, m: ts(1) })}
/>
<View {...css({ flexGrow: 1, flexShrink: 1, m: ts(1) })}>
<View
{...css({
flexGrow: 1,
flexShrink: 1,
flexDirection: "row",
justifyContent: "space-between",
})}
>
<Skeleton {...css({ width: percent(30) })} />
<Skeleton {...css({ width: percent(15) })} />
</View>
<Skeleton />
</View>
</View>
);
};
EntryLine.layout = {
numColumns: 1,
size: 100,
layout: "vertical",
gap: ts(1),
} satisfies Layout;
+16
View File
@@ -0,0 +1,16 @@
export * from "./entry-box";
export * from "./entry-list";
export const episodeDisplayNumber = (episode: {
seasonNumber?: number | null;
episodeNumber?: number | null;
absoluteNumber?: number | null;
}) => {
if (
typeof episode.seasonNumber === "number" &&
typeof episode.episodeNumber === "number"
)
return `S${episode.seasonNumber}:E${episode.episodeNumber}`;
if (episode.absoluteNumber) return episode.absoluteNumber.toString();
return "??";
};
+14 -16
View File
@@ -14,19 +14,17 @@ import { useMutation } from "~/query";
import { watchListIcon } from "./watchlist-info";
// import { useDownloader } from "../../packages/ui/src/downloadses/ui/src/downloads";
export const EpisodesContext = ({
kind = "episode",
export const EntryContext = ({
kind = "entry",
slug,
showSlug,
serieSlug,
status,
force,
...props
}: {
kind?: "serie" | "movie" | "episode";
showSlug?: string | null;
kind?: "serie" | "movie" | "entry";
serieSlug?: string | null;
slug: string;
status: WatchStatusV | null;
force?: boolean;
} & Partial<ComponentProps<typeof Menu<typeof IconButton>>>) => {
const account = useAccount();
// const downloader = useDownloader();
@@ -34,7 +32,10 @@ export const EpisodesContext = ({
const { t } = useTranslation();
const mutation = useMutation({
path: [kind, slug, "watchStatus"],
path:
kind === "entry"
? ["serie", serieSlug!, "entries", slug]
: [kind, slug, "watchStatus"],
compute: (newStatus: WatchStatusV | null) => ({
method: newStatus ? "POST" : "DELETE",
params: newStatus ? { status: newStatus } : undefined,
@@ -55,15 +56,15 @@ export const EpisodesContext = ({
icon={MoreVert}
{...tooltip(t("misc.more"))}
{...(css(
[Platform.OS !== "web" && !force && { display: "none" }],
[Platform.OS !== "web" && { display: "none" }],
props,
) as any)}
>
{showSlug && (
{serieSlug && (
<Menu.Item
label={t("home.episodeMore.goToShow")}
icon={Info}
href={`/serie/${showSlug}`}
href={`/serie/${serieSlug}`}
/>
)}
<Menu.Sub
@@ -121,21 +122,18 @@ export const ItemContext = ({
kind,
slug,
status,
force,
...props
}: {
kind: "movie" | "serie";
slug: string;
status: WatchStatusV | null;
force?: boolean;
} & Partial<ComponentProps<typeof Menu<typeof IconButton>>>) => {
return (
<EpisodesContext
<EntryContext
kind={kind}
slug={slug}
status={status}
showSlug={null}
force={force}
serieSlug={null}
{...props}
/>
);