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;