mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-31 14:33:50 -04:00
Rework entry box/line
This commit is contained in:
parent
dbf63e1b22
commit
d5c7ee40bc
147
front/src/components/entries/entry-box.tsx
Normal file
147
front/src/components/entries/entry-box.tsx
Normal 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
front/src/components/entries/entry-list.tsx
Normal file
205
front/src/components/entries/entry-list.tsx
Normal 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
front/src/components/entries/index.ts
Normal file
16
front/src/components/entries/index.ts
Normal 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,19 +14,17 @@ import { useMutation } from "~/query";
|
|||||||
import { watchListIcon } from "./watchlist-info";
|
import { watchListIcon } from "./watchlist-info";
|
||||||
// import { useDownloader } from "../../packages/ui/src/downloadses/ui/src/downloads";
|
// import { useDownloader } from "../../packages/ui/src/downloadses/ui/src/downloads";
|
||||||
|
|
||||||
export const EpisodesContext = ({
|
export const EntryContext = ({
|
||||||
kind = "episode",
|
kind = "entry",
|
||||||
slug,
|
slug,
|
||||||
showSlug,
|
serieSlug,
|
||||||
status,
|
status,
|
||||||
force,
|
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
kind?: "serie" | "movie" | "episode";
|
kind?: "serie" | "movie" | "entry";
|
||||||
showSlug?: string | null;
|
serieSlug?: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
status: WatchStatusV | null;
|
status: WatchStatusV | null;
|
||||||
force?: boolean;
|
|
||||||
} & Partial<ComponentProps<typeof Menu<typeof IconButton>>>) => {
|
} & Partial<ComponentProps<typeof Menu<typeof IconButton>>>) => {
|
||||||
const account = useAccount();
|
const account = useAccount();
|
||||||
// const downloader = useDownloader();
|
// const downloader = useDownloader();
|
||||||
@ -34,7 +32,10 @@ export const EpisodesContext = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
path: [kind, slug, "watchStatus"],
|
path:
|
||||||
|
kind === "entry"
|
||||||
|
? ["serie", serieSlug!, "entries", slug]
|
||||||
|
: [kind, slug, "watchStatus"],
|
||||||
compute: (newStatus: WatchStatusV | null) => ({
|
compute: (newStatus: WatchStatusV | null) => ({
|
||||||
method: newStatus ? "POST" : "DELETE",
|
method: newStatus ? "POST" : "DELETE",
|
||||||
params: newStatus ? { status: newStatus } : undefined,
|
params: newStatus ? { status: newStatus } : undefined,
|
||||||
@ -55,15 +56,15 @@ export const EpisodesContext = ({
|
|||||||
icon={MoreVert}
|
icon={MoreVert}
|
||||||
{...tooltip(t("misc.more"))}
|
{...tooltip(t("misc.more"))}
|
||||||
{...(css(
|
{...(css(
|
||||||
[Platform.OS !== "web" && !force && { display: "none" }],
|
[Platform.OS !== "web" && { display: "none" }],
|
||||||
props,
|
props,
|
||||||
) as any)}
|
) as any)}
|
||||||
>
|
>
|
||||||
{showSlug && (
|
{serieSlug && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
label={t("home.episodeMore.goToShow")}
|
label={t("home.episodeMore.goToShow")}
|
||||||
icon={Info}
|
icon={Info}
|
||||||
href={`/serie/${showSlug}`}
|
href={`/serie/${serieSlug}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Menu.Sub
|
<Menu.Sub
|
||||||
@ -121,21 +122,18 @@ export const ItemContext = ({
|
|||||||
kind,
|
kind,
|
||||||
slug,
|
slug,
|
||||||
status,
|
status,
|
||||||
force,
|
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
kind: "movie" | "serie";
|
kind: "movie" | "serie";
|
||||||
slug: string;
|
slug: string;
|
||||||
status: WatchStatusV | null;
|
status: WatchStatusV | null;
|
||||||
force?: boolean;
|
|
||||||
} & Partial<ComponentProps<typeof Menu<typeof IconButton>>>) => {
|
} & Partial<ComponentProps<typeof Menu<typeof IconButton>>>) => {
|
||||||
return (
|
return (
|
||||||
<EpisodesContext
|
<EntryContext
|
||||||
kind={kind}
|
kind={kind}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
status={status}
|
status={status}
|
||||||
showSlug={null}
|
serieSlug={null}
|
||||||
force={force}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -3,6 +3,7 @@ export * from "./entry";
|
|||||||
export * from "./extra";
|
export * from "./extra";
|
||||||
export * from "./kyoo-error";
|
export * from "./kyoo-error";
|
||||||
export * from "./movie";
|
export * from "./movie";
|
||||||
|
export * from "./season";
|
||||||
export * from "./serie";
|
export * from "./serie";
|
||||||
export * from "./show";
|
export * from "./show";
|
||||||
export * from "./studio";
|
export * from "./studio";
|
||||||
|
@ -1,402 +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/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** biome-ignore-all lint/correctness/noUnusedImports: TODO */
|
|
||||||
|
|
||||||
import { type KyooImage, WatchStatusV } from "@kyoo/models";
|
|
||||||
import {
|
|
||||||
focusReset,
|
|
||||||
H6,
|
|
||||||
IconButton,
|
|
||||||
Image,
|
|
||||||
ImageBackground,
|
|
||||||
type ImageProps,
|
|
||||||
imageBorderRadius,
|
|
||||||
important,
|
|
||||||
Link,
|
|
||||||
P,
|
|
||||||
Skeleton,
|
|
||||||
SubP,
|
|
||||||
tooltip,
|
|
||||||
ts,
|
|
||||||
} from "@kyoo/primitives";
|
|
||||||
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 {
|
|
||||||
type ImageStyle,
|
|
||||||
Platform,
|
|
||||||
type PressableProps,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import {
|
|
||||||
percent,
|
|
||||||
rem,
|
|
||||||
type Stylable,
|
|
||||||
type Theme,
|
|
||||||
useYoshiki,
|
|
||||||
} from "yoshiki/native";
|
|
||||||
import { ItemProgress } from "../browse/grid";
|
|
||||||
import { EpisodesContext } from "../components/context-menus";
|
|
||||||
import type { Layout } from "../fetch";
|
|
||||||
|
|
||||||
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 "??";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EpisodeBox = ({
|
|
||||||
slug,
|
|
||||||
showSlug,
|
|
||||||
name,
|
|
||||||
overview,
|
|
||||||
thumbnail,
|
|
||||||
href,
|
|
||||||
watchedPercent,
|
|
||||||
watchedStatus,
|
|
||||||
...props
|
|
||||||
}: Stylable & {
|
|
||||||
slug: string;
|
|
||||||
// if show slug is null, disable "Go to show" in the context menu
|
|
||||||
showSlug: string | null;
|
|
||||||
name: string | null;
|
|
||||||
overview: string | null;
|
|
||||||
href: string;
|
|
||||||
thumbnail?: ImageProps["src"] | 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: imageBorderRadius,
|
|
||||||
},
|
|
||||||
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")}
|
|
||||||
>
|
|
||||||
{(watchedPercent || watchedStatus === WatchStatusV.Completed) && (
|
|
||||||
<ItemProgress watchPercent={watchedPercent ?? 100} />
|
|
||||||
)}
|
|
||||||
<EpisodesContext
|
|
||||||
slug={slug}
|
|
||||||
showSlug={showSlug}
|
|
||||||
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",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{overview}
|
|
||||||
</SubP>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
EpisodeBox.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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EpisodeLine = ({
|
|
||||||
slug,
|
|
||||||
showSlug,
|
|
||||||
displayNumber,
|
|
||||||
name,
|
|
||||||
thumbnail,
|
|
||||||
overview,
|
|
||||||
id,
|
|
||||||
absoluteNumber,
|
|
||||||
episodeNumber,
|
|
||||||
seasonNumber,
|
|
||||||
releaseDate,
|
|
||||||
runtime,
|
|
||||||
watchedPercent,
|
|
||||||
watchedStatus,
|
|
||||||
href,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
// if show slug is null, disable "Go to show" in the context menu
|
|
||||||
showSlug: string | null;
|
|
||||||
displayNumber: string;
|
|
||||||
name: string | null;
|
|
||||||
overview: string | null;
|
|
||||||
thumbnail?: KyooImage | null;
|
|
||||||
absoluteNumber: number | null;
|
|
||||||
episodeNumber: number | null;
|
|
||||||
seasonNumber: number | null;
|
|
||||||
releaseDate: Date | null;
|
|
||||||
runtime: number | null;
|
|
||||||
watchedPercent: number | null;
|
|
||||||
watchedStatus: WatchStatusV | null;
|
|
||||||
href: string;
|
|
||||||
} & PressableProps &
|
|
||||||
Stylable) => {
|
|
||||||
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: imageBorderRadius })}
|
|
||||||
>
|
|
||||||
{(watchedPercent || watchedStatus === WatchStatusV.Completed) && (
|
|
||||||
<>
|
|
||||||
<View
|
|
||||||
{...css({
|
|
||||||
backgroundColor: (theme) => theme.overlay0,
|
|
||||||
width: percent(100),
|
|
||||||
height: ts(0.5),
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
{...css({
|
|
||||||
backgroundColor: (theme) => theme.accent,
|
|
||||||
width: percent(watchedPercent ?? 100),
|
|
||||||
height: ts(0.5),
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
{[
|
|
||||||
// @ts-ignore Source https://www.i18next.com/translation-function/formatting#datetime
|
|
||||||
releaseDate
|
|
||||||
? t("{{val, datetime}}", { val: releaseDate })
|
|
||||||
: null,
|
|
||||||
displayRuntime(runtime),
|
|
||||||
]
|
|
||||||
.filter((item) => item != null)
|
|
||||||
.join(" · ")}
|
|
||||||
</SubP>
|
|
||||||
<EpisodesContext
|
|
||||||
slug={slug}
|
|
||||||
showSlug={showSlug}
|
|
||||||
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}>{overview}</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
EpisodeLine.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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
EpisodeLine.layout = {
|
|
||||||
numColumns: 1,
|
|
||||||
size: 100,
|
|
||||||
layout: "vertical",
|
|
||||||
gap: ts(1),
|
|
||||||
} satisfies Layout;
|
|
Loading…
x
Reference in New Issue
Block a user