diff --git a/front/src/components/entries/entry-box.tsx b/front/src/components/entries/entry-box.tsx
new file mode 100644
index 00000000..7095ef55
--- /dev/null
+++ b/front/src/components/entries/entry-box.tsx
@@ -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 (
+ 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,
+ )}
+ >
+
+ {(watchedPercent || watchedStatus === "completed") && (
+
+ )}
+ setMoreOpened(v)}
+ {...css([
+ {
+ position: "absolute",
+ top: 0,
+ right: 0,
+ bg: (theme) => theme.darkOverlay,
+ },
+ "more",
+ Platform.OS === "web" &&
+ moreOpened && { display: important("flex") },
+ ])}
+ />
+
+
+ {name ?? t("show.episodeNoMetadata")}
+
+
+ {description}
+
+
+ );
+};
+
+EntryBox.Loader = (props: Stylable) => {
+ const { css } = useYoshiki();
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/front/src/components/entries/entry-list.tsx b/front/src/components/entries/entry-list.tsx
new file mode 100644
index 00000000..e0d350cc
--- /dev/null
+++ b/front/src/components/entries/entry-list.tsx
@@ -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 (
+ setMoreOpened(true)}
+ {...css(
+ {
+ alignItems: "center",
+ flexDirection: "row",
+ child: {
+ more: {
+ opacity: 0,
+ },
+ },
+ fover: {
+ self: focusReset,
+ title: {
+ textDecorationLine: "underline",
+ },
+ more: {
+ opacity: 1,
+ },
+ },
+ },
+ props,
+ )}
+ >
+
+ {(watchedPercent || watchedStatus === "completed") && (
+
+ )}
+
+
+
+ {/* biome-ignore lint/a11y/useValidAriaValues: simply use H6 for the style but keep a P */}
+
+ {[displayNumber, name ?? t("show.episodeNoMetadata")].join(" · ")}
+
+
+
+ {[
+ 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(" · ")}
+
+ setMoreOpened(v)}
+ {...css([
+ "more",
+ { display: "flex", marginLeft: ts(3) },
+ Platform.OS === "web" &&
+ moreOpened && { display: important("flex") },
+ ])}
+ />
+
+
+
+
+ {description}
+
+ {
+ e.preventDefault();
+ setDescriptionExpanded((isExpanded) => !isExpanded);
+ }}
+ />
+
+
+
+ );
+};
+
+EntryLine.Loader = (props: Stylable) => {
+ const { css } = useYoshiki();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+EntryLine.layout = {
+ numColumns: 1,
+ size: 100,
+ layout: "vertical",
+ gap: ts(1),
+} satisfies Layout;
diff --git a/front/src/components/entries/index.ts b/front/src/components/entries/index.ts
new file mode 100644
index 00000000..02ceb3cb
--- /dev/null
+++ b/front/src/components/entries/index.ts
@@ -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 "??";
+};
diff --git a/front/src/components/items/context-menus.tsx b/front/src/components/items/context-menus.tsx
index 69ac3da6..0fc68802 100644
--- a/front/src/components/items/context-menus.tsx
+++ b/front/src/components/items/context-menus.tsx
@@ -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>>) => {
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 && (
)}
>>) => {
return (
-
);
diff --git a/front/src/models/index.ts b/front/src/models/index.ts
index 0422787f..329bba61 100644
--- a/front/src/models/index.ts
+++ b/front/src/models/index.ts
@@ -3,6 +3,7 @@ export * from "./entry";
export * from "./extra";
export * from "./kyoo-error";
export * from "./movie";
+export * from "./season";
export * from "./serie";
export * from "./show";
export * from "./studio";
diff --git a/front/src/ui/details/episode.tsx b/front/src/ui/details/episode.tsx
deleted file mode 100644
index 597af9d1..00000000
--- a/front/src/ui/details/episode.tsx
+++ /dev/null
@@ -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 .
- */
-
-/** 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 (
- 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,
- )}
- >
-
- {(watchedPercent || watchedStatus === WatchStatusV.Completed) && (
-
- )}
- setMoreOpened(v)}
- {...css([
- {
- position: "absolute",
- top: 0,
- right: 0,
- bg: (theme) => theme.darkOverlay,
- },
- "more",
- Platform.OS === "web" &&
- moreOpened && { display: important("flex") },
- ])}
- />
-
-
- {name ?? t("show.episodeNoMetadata")}
-
-
- {overview}
-
-
- );
-};
-
-EpisodeBox.Loader = (props: Stylable) => {
- const { css } = useYoshiki();
-
- return (
-
-
-
-
-
- );
-};
-
-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 (
- setMoreOpened(true)}
- {...css(
- {
- alignItems: "center",
- flexDirection: "row",
- child: {
- more: {
- opacity: 0,
- },
- },
- fover: {
- self: focusReset,
- title: {
- textDecorationLine: "underline",
- },
- more: {
- opacity: 1,
- },
- },
- },
- props,
- )}
- >
-
- {(watchedPercent || watchedStatus === WatchStatusV.Completed) && (
- <>
- theme.overlay0,
- width: percent(100),
- height: ts(0.5),
- position: "absolute",
- bottom: 0,
- })}
- />
- theme.accent,
- width: percent(watchedPercent ?? 100),
- height: ts(0.5),
- position: "absolute",
- bottom: 0,
- })}
- />
- >
- )}
-
-
-
- {/* biome-ignore lint/a11y/useValidAriaValues: simply use H6 for the style but keep a P */}
-
- {[displayNumber, name ?? t("show.episodeNoMetadata")].join(" · ")}
-
-
-
- {[
- // @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(" · ")}
-
- setMoreOpened(v)}
- {...css([
- "more",
- { display: "flex", marginLeft: ts(3) },
- Platform.OS === "web" &&
- moreOpened && { display: important("flex") },
- ])}
- />
-
-
-
- {overview}
- {
- e.preventDefault();
- setDescriptionExpanded((isExpanded) => !isExpanded);
- }}
- />
-
-
-
- );
-};
-
-EpisodeLine.Loader = (props: Stylable) => {
- const { css } = useYoshiki();
-
- return (
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-EpisodeLine.layout = {
- numColumns: 1,
- size: 100,
- layout: "vertical",
- gap: ts(1),
-} satisfies Layout;