diff --git a/auth/shell.nix b/auth/shell.nix index 0cf2b1f4..cea77c33 100644 --- a/auth/shell.nix +++ b/auth/shell.nix @@ -10,6 +10,6 @@ pkgs.mkShell { postgresql_15 pgformatter # to run tests - hurl + # hurl ]; } diff --git a/front/app/(app)/movies/[slug].tsx b/front/app/(app)/movies/[slug].tsx new file mode 100644 index 00000000..19fa16e5 --- /dev/null +++ b/front/app/(app)/movies/[slug].tsx @@ -0,0 +1,3 @@ +import { MovieDetails } from "~/ui/details"; + +export default MovieDetails; diff --git a/front/packages/ui/src/collection/index.tsx b/front/packages/ui/src/collection/index.tsx index b90c133d..51cab2d6 100644 --- a/front/packages/ui/src/collection/index.tsx +++ b/front/packages/ui/src/collection/index.tsx @@ -41,8 +41,8 @@ import { useTranslation } from "react-i18next"; import { Platform, View, type ViewProps } from "react-native"; import { percent, px, useYoshiki } from "yoshiki/native"; import { ItemGrid } from "../browse/grid"; -import { Header as ShowHeader, TitleLine } from "../details/header"; -import { SvgWave } from "../details/show"; +import { Header as ShowHeader, TitleLine } from "../../../../src/ui/details/headeri/details/header"; +import { SvgWave } from "../../../../src/ui/details/show/ui/details/show"; import { Fetch } from "../fetch"; import { InfiniteFetch } from "../fetch-infinite"; import { ItemDetails } from "../home/recommended"; diff --git a/front/packages/ui/src/components/rating.tsx b/front/packages/ui/src/components/rating.tsx deleted file mode 100644 index 56b5576c..00000000 --- a/front/packages/ui/src/components/rating.tsx +++ /dev/null @@ -1,39 +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 . - */ - -import { type Breakpoint, Icon, P, Skeleton, ts } from "@kyoo/primitives"; -import Star from "@material-symbols/svg-400/rounded/star-fill.svg"; -import { View } from "react-native"; -import { rem, useYoshiki } from "yoshiki/native"; - -export const Rating = ({ rating, color }: { rating?: number; color: Breakpoint }) => { - const { css } = useYoshiki(); - - return ( - - - - {rating !== undefined && ( -

{rating ? rating / 10 : "??"} / 10

- )} -
-
- ); -}; diff --git a/front/packages/ui/src/details/header.tsx b/front/packages/ui/src/details/header.tsx deleted file mode 100644 index 0c258b98..00000000 --- a/front/packages/ui/src/details/header.tsx +++ /dev/null @@ -1,590 +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 . - */ - -import { - type Genre, - type KyooImage, - type Movie, - type QueryIdentifier, - type Show, - type Studio, - getDisplayDate, - queryFn, - useAccount, -} from "@kyoo/models"; -import type { WatchStatusV } from "@kyoo/models/src/resources/watch-status"; -import { - A, - Chip, - Container, - DottedSeparator, - GradientImageBackground, - H1, - H2, - HR, - Head, - IconButton, - IconFab, - LI, - Link, - Menu, - P, - Poster, - Skeleton, - UL, - capitalize, - tooltip, - ts, - usePopup, -} from "@kyoo/primitives"; -import Refresh from "@material-symbols/svg-400/rounded/autorenew.svg"; -import Download from "@material-symbols/svg-400/rounded/download.svg"; -import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg"; -import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg"; -import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; -import Theaters from "@material-symbols/svg-400/rounded/theaters-fill.svg"; -import { useMutation } from "@tanstack/react-query"; -import { Fragment } from "react"; -import { useTranslation } from "react-i18next"; -import { type ImageStyle, Platform, View } from "react-native"; -import { - type Stylable, - type Theme, - em, - max, - md, - min, - percent, - px, - rem, - useYoshiki, - vh, -} from "yoshiki/native"; -import { MediaInfoPopup } from "../components/media-info"; -import { Rating } from "../components/rating"; -import { WatchListInfo } from "../components/watchlist-info"; -import { useDownloader } from "../downloads"; -import { Fetch } from "../fetch"; -import { displayRuntime } from "./episode"; -import { ShowWatchStatusCard } from "./show"; - -const ButtonList = ({ - playHref, - trailerUrl, - watchStatus, - type, - slug, -}: { - type: "movie" | "show" | "collection"; - slug?: string; - playHref?: string | null; - trailerUrl?: string | null; - watchStatus?: WatchStatusV | null; -}) => { - const account = useAccount(); - const { css, theme } = useYoshiki(); - const { t } = useTranslation(); - const downloader = useDownloader(); - const [setPopup, close] = usePopup(); - - const metadataRefreshMutation = useMutation({ - mutationFn: () => - queryFn({ - path: [type, slug, "refresh"], - method: "POST", - }), - }); - - return ( - - {playHref !== null && ( - - )} - {trailerUrl && ( - - )} - {watchStatus !== undefined && type !== "collection" && slug && ( - - )} - {((type === "movie" && slug) || account?.isAdmin === true) && ( - - {type === "movie" && slug && ( - <> - downloader(type, slug)} - label={t("home.episodeMore.download")} - /> - - setPopup() - } - /> - - )} - {account?.isAdmin === true && ( - <> - {type === "movie" &&
} - metadataRefreshMutation.mutate()} - /> - - )} -
- )} -
- ); -}; - -export const TitleLine = ({ - isLoading, - playHref, - name, - tagline, - date, - rating, - runtime, - poster, - studio, - trailerUrl, - type, - watchStatus, - slug, - ...props -}: { - isLoading: boolean; - playHref?: string | null; - name?: string; - tagline?: string | null; - date?: string | null; - rating?: number | null; - runtime?: number | null; - poster?: KyooImage | null; - studio?: Studio | null; - trailerUrl?: string | null; - watchStatus?: WatchStatusV | null; - type: "movie" | "show" | "collection"; - slug?: string; -} & Stylable) => { - const { css, theme } = useYoshiki(); - const { t } = useTranslation(); - - return ( - - - - -

- - {isLoading || ( - <> -

({ xs: theme.user.heading, md: theme.heading }), - })} - > - {name} -

- {date && ( -

({ - xs: theme.user.paragraph, - md: theme.paragraph, - }), - })} - > - {" "} - ({date}) -

- )} - - )} - -

- {(isLoading || tagline) && ( - - {isLoading || ( -

({ xs: theme.user.heading, md: theme.heading }), - })} - > - {tagline} -

- )} -
- )} - - - - {rating !== null && rating !== 0 && ( - <> - - - - )} - {runtime && ( - <> - -

- {displayRuntime(runtime)} -

- - )} -
-
-
-
- - {isLoading || - (studio && ( -

theme.user.paragraph, - display: "flex", - })} - > - {t("show.studio")}:{" "} - {isLoading ? ( - - ) : ( - theme.user.link })}> - {studio.name} - - )} -

- ))} -
-
- ); -}; - -const Description = ({ - isLoading, - overview, - tags, - genres, - ...props -}: { - isLoading: boolean; - overview?: string | null; - tags?: string[]; - genres?: Genre[]; -} & Stylable) => { - const { t } = useTranslation(); - const { css } = useYoshiki(); - - return ( - -

theme.user.paragraph, - })} - > - {t("show.genre")}:{" "} - {(isLoading ? [...Array(3)] : genres!).map((genre, i) => ( - -

{i !== 0 && ", "}

- {isLoading ? ( - - ) : ( - {t(`genres.${genre}`)} - )} - - ))} -

- - - - {isLoading || ( -

{overview ?? t("show.noOverview")}

- )} -
- -

{t("show.tags")}:

- {(isLoading ? [...Array(3)] : tags!).map((tag, i) => ( - - ))} -
-
-
- -

{t("show.genre")}

- {isLoading || genres?.length ? ( -
    - {(isLoading ? [...Array(3)] : genres!).map((genre, i) => ( -
  • - {isLoading ? ( - - ) : ( - {t(`genres.${genre}`)} - )} -
  • - ))} -
- ) : ( -

{t("show.genre-none")}

- )} -
-
- ); -}; - -export const Header = ({ - query, - type, -}: { - query: QueryIdentifier; - type: "movie" | "show"; -}) => { - const { css } = useYoshiki(); - const { t } = useTranslation(); - - return ( - - {({ isLoading, ...data }) => ( - <> - - - - - - -

{t("show.links")}:

- {(!isLoading - ? Object.entries(data.externalId!).filter(([_, data]) => data.link) - : [...Array(3)].map((_) => [undefined, undefined] as const) - ).map(([name, data], i) => ( - - ))} -
- {type === "show" && } - - )} -
- ); -}; - -Header.containerStyle = { - height: { - xs: vh(40), - sm: min(vh(60), px(750)), - md: min(vh(60), px(680)), - lg: vh(70), - }, - minHeight: { xs: px(350), sm: px(300), md: px(400), lg: px(600) }, -}; - -Header.childStyle = { - marginTop: { - xs: max(vh(20), px(200)), - sm: vh(45), - md: max(vh(30), px(150)), - lg: max(vh(35), px(200)), - }, -}; diff --git a/front/packages/ui/src/details/index.tsx b/front/packages/ui/src/details/index.tsx deleted file mode 100644 index febaaa6d..00000000 --- a/front/packages/ui/src/details/index.tsx +++ /dev/null @@ -1,22 +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 . - */ - -export { MovieDetails } from "./movie"; -export { ShowDetails } from "./show"; diff --git a/front/packages/ui/src/details/movie.tsx b/front/packages/ui/src/details/movie.tsx deleted file mode 100644 index e3f54843..00000000 --- a/front/packages/ui/src/details/movie.tsx +++ /dev/null @@ -1,68 +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 . - */ - -import { type Movie, MovieP, type QueryIdentifier, type QueryPage } from "@kyoo/models"; -import { usePageStyle } from "@kyoo/primitives"; -import { Platform, ScrollView } from "react-native"; -import { useYoshiki } from "yoshiki/native"; -import { DefaultLayout } from "../layout"; -import { DetailsCollections } from "./collection"; -import { Header } from "./header"; - -const query = (slug: string): QueryIdentifier => ({ - parser: MovieP, - path: ["movie", slug], - params: { - fields: ["studio", "watchStatus"], - }, -}); - -export const MovieDetails: QueryPage<{ slug: string }> = ({ slug }) => { - const { css } = useYoshiki(); - const pageStyle = usePageStyle(); - - return ( - -
- - {/* */} - - ); -}; - -MovieDetails.getFetchUrls = ({ slug }) => [ - query(slug), - DetailsCollections.query("movie", slug), - // ShowStaff.query(slug), -]; - -MovieDetails.getLayout = { Layout: DefaultLayout, props: { transparent: true } }; diff --git a/front/packages/ui/src/downloads/page.tsx b/front/packages/ui/src/downloads/page.tsx index 4aaa46e0..f4d4edcc 100644 --- a/front/packages/ui/src/downloads/page.tsx +++ b/front/packages/ui/src/downloads/page.tsx @@ -42,7 +42,7 @@ import { type Atom, useAtomValue } from "jotai"; import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { percent, useYoshiki } from "yoshiki/native"; -import { EpisodeLine, displayRuntime, episodeDisplayNumber } from "../details/episode"; +import { EpisodeLine, displayRuntime, episodeDisplayNumber } from "../../../../src/ui/details/episode"; import { EmptyView } from "../fetch"; import { type State, downloadAtom } from "./state"; diff --git a/front/packages/ui/src/home/header.tsx b/front/packages/ui/src/home/header.tsx index ad0f24b9..42790e40 100644 --- a/front/packages/ui/src/home/header.tsx +++ b/front/packages/ui/src/home/header.tsx @@ -37,7 +37,7 @@ import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { percent, rem, useYoshiki } from "yoshiki/native"; -import { Header as DetailsHeader } from "../details/header"; +import { Header as DetailsHeader } from "../../../../src/ui/details/header"; import type { WithLoading } from "../fetch"; export const Header = ({ diff --git a/front/packages/ui/src/home/news.tsx b/front/packages/ui/src/home/news.tsx index 609d603e..ecaf4804 100644 --- a/front/packages/ui/src/home/news.tsx +++ b/front/packages/ui/src/home/news.tsx @@ -22,7 +22,7 @@ import { type News, NewsP, type QueryIdentifier, getDisplayDate } from "@kyoo/mo import { useTranslation } from "react-i18next"; import { useYoshiki } from "yoshiki/native"; import { ItemGrid } from "../browse/grid"; -import { EpisodeBox, episodeDisplayNumber } from "../details/episode"; +import { EpisodeBox, episodeDisplayNumber } from "../../../../src/ui/details/episode"; import { InfiniteFetch } from "../fetch-infinite"; import { Header } from "./genre"; diff --git a/front/packages/ui/src/home/watchlist.tsx b/front/packages/ui/src/home/watchlist.tsx index f49f3d36..58532dc4 100644 --- a/front/packages/ui/src/home/watchlist.tsx +++ b/front/packages/ui/src/home/watchlist.tsx @@ -30,7 +30,7 @@ import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { useYoshiki } from "yoshiki/native"; import { ItemGrid } from "../browse/grid"; -import { EpisodeBox, episodeDisplayNumber } from "../details/episode"; +import { EpisodeBox, episodeDisplayNumber } from "../../../../src/ui/details/episode"; import { InfiniteFetch } from "../fetch-infinite"; import { Header } from "./genre"; diff --git a/front/packages/ui/src/player/index.tsx b/front/packages/ui/src/player/index.tsx index 534a8ebe..405b1624 100644 --- a/front/packages/ui/src/player/index.tsx +++ b/front/packages/ui/src/player/index.tsx @@ -35,7 +35,7 @@ import { useTranslation } from "react-i18next"; import { Platform, StyleSheet, View } from "react-native"; import { useRouter } from "solito/router"; import { useYoshiki } from "yoshiki/native"; -import { episodeDisplayNumber } from "../details/episode"; +import { episodeDisplayNumber } from "../../../../src/ui/details/episode"; import { ErrorView } from "../../../../src/ui/errors"; import { Back, Hover, LoadingIndicator } from "./components/hover"; import { useVideoKeyboard } from "./keyboard"; diff --git a/front/src/components/items/watchlist-info.tsx b/front/src/components/items/watchlist-info.tsx index e884e4a3..6b3a8781 100644 --- a/front/src/components/items/watchlist-info.tsx +++ b/front/src/components/items/watchlist-info.tsx @@ -26,12 +26,12 @@ export const watchListIcon = (status: WatchStatus | null) => { }; export const WatchListInfo = ({ - type, + kind, slug, status, ...props }: { - type: "movie" | "show" | "episode"; + kind: "movie" | "serie" | "episode"; slug: string; status: WatchStatus | null; color: ComponentProps["color"]; @@ -40,12 +40,12 @@ export const WatchListInfo = ({ const { t } = useTranslation(); const mutation = useMutation({ - path: [type, slug, "watchStatus"], + path: [kind, slug, "watchStatus"], compute: (newStatus: WatchStatus | null) => ({ method: newStatus ? "POST" : "DELETE", params: newStatus ? { status: newStatus } : undefined, }), - invalidate: [type, slug], + invalidate: [kind, slug], }); if (mutation.isPending) status = mutation.variables; diff --git a/front/src/components/rating.tsx b/front/src/components/rating.tsx new file mode 100644 index 00000000..e201a2cd --- /dev/null +++ b/front/src/components/rating.tsx @@ -0,0 +1,34 @@ +import Star from "@material-symbols/svg-400/rounded/star-fill.svg"; +import { View } from "react-native"; +import { rem, useYoshiki } from "yoshiki/native"; +import { type Breakpoint, Icon, P, Skeleton, ts } from "~/primitives"; + +export const Rating = ({ + rating, + color, +}: { + rating: number | null; + color: Breakpoint; +}) => { + const { css } = useYoshiki(); + + return ( + + +

+ {rating ? rating / 10 : "??"} / 10 +

+
+ ); +}; + +Rating.Loader = ({ color }: { color: Breakpoint }) => { + const { css } = useYoshiki(); + + return ( + + + + + ); +}; diff --git a/front/src/models/index.ts b/front/src/models/index.ts index cbeff92b..0847032b 100644 --- a/front/src/models/index.ts +++ b/front/src/models/index.ts @@ -12,3 +12,4 @@ export * from "./video"; export * from "./user"; export * from "./utils/images"; +export * from "./utils/genre"; diff --git a/front/src/models/movie.ts b/front/src/models/movie.ts index 9c4fc36a..70bb4b46 100644 --- a/front/src/models/movie.ts +++ b/front/src/models/movie.ts @@ -31,7 +31,7 @@ export const Movie = z thumbnail: KImage.nullable(), banner: KImage.nullable(), logo: KImage.nullable(), - trailerUrl: z.string().optional().nullable(), + trailerUrl: z.string().nullable(), isAvailable: z.boolean(), @@ -42,7 +42,13 @@ export const Movie = z videos: z.array(EmbeddedVideo).optional(), watchStatus: z .object({ - status: z.enum(["completed", "watching", "rewatching", "dropped", "planned"]), + status: z.enum([ + "completed", + "watching", + "rewatching", + "dropped", + "planned", + ]), score: z.number().int().gte(0).lte(100).nullable(), completedAt: zdate().nullable(), percent: z.number().int().gte(0).lte(100), @@ -52,5 +58,6 @@ export const Movie = z .transform((x) => ({ ...x, href: `/movies/${x.slug}`, + playHref: `/watch/${x.slug}`, })); export type Movie = z.infer; diff --git a/front/src/models/serie.ts b/front/src/models/serie.ts index 85a4347e..bc32f8b8 100644 --- a/front/src/models/serie.ts +++ b/front/src/models/serie.ts @@ -32,7 +32,7 @@ export const Serie = z thumbnail: KImage.nullable(), banner: KImage.nullable(), logo: KImage.nullable(), - trailerUrl: z.string().optional().nullable(), + trailerUrl: z.string().nullable(), entriesCount: z.number().int(), availableCount: z.number().int(), @@ -45,7 +45,13 @@ export const Serie = z nextEntry: Entry.optional().nullable(), watchStatus: z .object({ - status: z.enum(["completed", "watching", "rewatching", "dropped", "planned"]), + status: z.enum([ + "completed", + "watching", + "rewatching", + "dropped", + "planned", + ]), score: z.number().int().gte(0).lte(100).nullable(), startedAt: zdate().nullable(), completedAt: zdate().nullable(), @@ -53,10 +59,13 @@ export const Serie = z }) .nullable(), }) - .transform((x) => ({ - ...x, - href: `/series/${x.slug}`, - playHref: x.firstEntry ? `/watch/${x.firstEntry.slug}` : null, - })); + .transform((x) => { + const entry = x.nextEntry ?? x.firstEntry; + return { + ...x, + href: `/series/${x.slug}`, + playHref: entry ? `/watch/${entry.slug}` : null, + }; + }); export type Serie = z.infer; diff --git a/front/src/models/user.ts b/front/src/models/user.ts index c3e0f225..097bddee 100644 --- a/front/src/models/user.ts +++ b/front/src/models/user.ts @@ -54,5 +54,5 @@ export const Account = User.and( token: z.string(), selected: z.boolean(), }), -); +).transform((x) => ({ ...x, isAdmin: true })); export type Account = z.infer; diff --git a/front/src/models/utils/genre.ts b/front/src/models/utils/genre.ts index 54ff79ae..813837d8 100644 --- a/front/src/models/utils/genre.ts +++ b/front/src/models/utils/genre.ts @@ -25,3 +25,4 @@ export const Genre = z.enum([ "soap", "talk", ]); +export type Genre = z.infer; diff --git a/front/src/primitives/icons.tsx b/front/src/primitives/icons.tsx index 195c64e0..e178d110 100644 --- a/front/src/primitives/icons.tsx +++ b/front/src/primitives/icons.tsx @@ -1,9 +1,14 @@ import type React from "react"; -import { type ComponentProps, type ComponentType, type ForwardedRef, forwardRef } from "react"; +import { + type ComponentProps, + type ComponentType, + type ForwardedRef, + forwardRef, +} from "react"; import { Platform, type PressableProps } from "react-native"; import type { SvgProps } from "react-native-svg"; import type { YoshikiStyle } from "yoshiki"; -import { type Stylable, type Theme, px, useYoshiki } from "yoshiki/native"; +import { px, type Stylable, type Theme, useYoshiki } from "yoshiki/native"; import { PressableFeedback } from "./links"; import { P } from "./text"; import { type Breakpoint, focusReset, ts } from "./utils"; @@ -25,7 +30,12 @@ type IconProps = { export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => { const { css, theme } = useYoshiki(); const computed = css( - { width: size, height: size, fill: color ?? theme.contrast, flexShrink: 0 } as any, + { + width: size, + height: size, + fill: color ?? theme.contrast, + flexShrink: 0, + } as any, props, ) as any; @@ -44,7 +54,9 @@ export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => { ); }; -export const IconButton = forwardRef(function IconButton( +export const IconButton = forwardRef(function IconButton< + AsProps = PressableProps, +>( { icon, size, @@ -84,7 +96,9 @@ export const IconButton = forwardRef(function IconButton ); @@ -114,7 +128,7 @@ export const IconFab = ( ); }; -export const DottedSeparator = (props: Stylable) => { +export const DottedSeparator = (props: Stylable<"text">) => { const { css } = useYoshiki(); return

{String.fromCharCode(0x2022)}

; }; diff --git a/front/src/primitives/image.tsx b/front/src/primitives/image.tsx index 6650f9dc..09954bd9 100644 --- a/front/src/primitives/image.tsx +++ b/front/src/primitives/image.tsx @@ -66,13 +66,17 @@ Image.Loader = ({ layout, ...props }: { layout: ImageLayout }) => { export const Poster = ({ layout, ...props -}: ComponentProps & { - layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>; +}: Omit, "layout"> & { + layout: YoshikiEnhanced< + { width: ImageStyle["width"] } | { height: ImageStyle["height"] } + >; }) => ; Poster.Loader = ({ layout, ...props }: { - layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>; + layout: YoshikiEnhanced< + { width: ImageStyle["width"] } | { height: ImageStyle["height"] } + >; }) => ; diff --git a/front/src/primitives/utils/head.tsx b/front/src/primitives/utils/head.tsx index b999f858..3848b35f 100644 --- a/front/src/primitives/utils/head.tsx +++ b/front/src/primitives/utils/head.tsx @@ -1,3 +1,5 @@ +import EHead from "expo-router/head"; + export const Head = ({ title, description, @@ -7,5 +9,11 @@ export const Head = ({ description?: string | null; image?: string | null; }) => { - return null; + return ( + + {title && {`${title} - Kyoo`}} + {description && } + {image && } + + ); }; diff --git a/front/src/primitives/utils/head.web.tsx b/front/src/primitives/utils/head.web.tsx deleted file mode 100644 index 162e132b..00000000 --- a/front/src/primitives/utils/head.web.tsx +++ /dev/null @@ -1,19 +0,0 @@ -// import NextHead from "next/head"; - -export const Head = ({ - title, - description, - image, -}: { - title?: string | null; - description?: string | null; - image?: string | null; -}) => { - return ( - - {title && {`${title} - Kyoo`}} - {description && } - {image && } - - ); -}; diff --git a/front/packages/ui/src/details/collection.tsx b/front/src/ui/details/collection.tsx similarity index 98% rename from front/packages/ui/src/details/collection.tsx rename to front/src/ui/details/collection.tsx index 83880623..d7685922 100644 --- a/front/packages/ui/src/details/collection.tsx +++ b/front/src/ui/details/collection.tsx @@ -37,7 +37,7 @@ import { } from "@kyoo/primitives"; import { useTranslation } from "react-i18next"; import { type Theme, useYoshiki } from "yoshiki/native"; -import { ErrorView } from "../../../../src/ui/errors"; +import { ErrorView } from "../errors"; export const PartOf = ({ name, diff --git a/front/packages/ui/src/details/episode.tsx b/front/src/ui/details/episode.tsx similarity index 97% rename from front/packages/ui/src/details/episode.tsx rename to front/src/ui/details/episode.tsx index 0b887b74..b798c85f 100644 --- a/front/packages/ui/src/details/episode.tsx +++ b/front/src/ui/details/episode.tsx @@ -56,12 +56,6 @@ export const episodeDisplayNumber = (episode: { return "??"; }; -export const displayRuntime = (runtime: number | null) => { - if (!runtime) return null; - if (runtime < 60) return `${runtime}min`; - return `${Math.floor(runtime / 60)}h${runtime % 60}`; -}; - export const EpisodeBox = ({ slug, showSlug, diff --git a/front/src/ui/details/header.tsx b/front/src/ui/details/header.tsx new file mode 100644 index 00000000..a50fd259 --- /dev/null +++ b/front/src/ui/details/header.tsx @@ -0,0 +1,605 @@ +import Refresh from "@material-symbols/svg-400/rounded/autorenew.svg"; +import Download from "@material-symbols/svg-400/rounded/download.svg"; +import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg"; +import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg"; +import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; +import Theaters from "@material-symbols/svg-400/rounded/theaters-fill.svg"; +import { Fragment } from "react"; +import { useTranslation } from "react-i18next"; +import { type ImageStyle, Platform, View } from "react-native"; +import { + em, + max, + md, + min, + percent, + px, + rem, + type Stylable, + type Theme, + useYoshiki, + vh, +} from "yoshiki/native"; +import { WatchListInfo } from "~/components/items/watchlist-info"; +import { Rating } from "~/components/rating"; +import { + type Genre, + type KImage, + Movie, + Serie, + Show, + type Studio, + type WatchStatusV, +} from "~/models"; +import { + A, + Chip, + Container, + capitalize, + DottedSeparator, + GradientImageBackground, + H1, + H2, + Head, + HR, + IconButton, + IconFab, + LI, + Link, + Menu, + P, + Poster, + Skeleton, + tooltip, + ts, + UL, +} from "~/primitives"; +import { useAccount } from "~/providers/account-context"; +import { Fetch, type QueryIdentifier, useMutation } from "~/query"; +import { displayRuntime, getDisplayDate } from "~/utils"; + +const ButtonList = ({ + kind, + slug, + playHref, + trailerUrl, + watchStatus, +}: { + kind: "movie" | "serie" | "collection"; + slug: string; + playHref: string | null; + trailerUrl: string | null; + watchStatus: WatchStatusV | null; +}) => { + const account = useAccount(); + const { css, theme } = useYoshiki(); + const { t } = useTranslation(); + + const metadataRefreshMutation = useMutation({ + method: "POST", + path: [kind, slug, "refresh"], + invalidate: null, + }); + + return ( + + {playHref !== null && ( + + )} + {trailerUrl && ( + + )} + {kind !== "collection" && ( + + )} + {(kind === "movie" || account?.isAdmin === true) && ( + + {kind === "movie" && ( + <> + {/* downloader(kind, slug)} */} + {/* label={t("home.episodeMore.download")} */} + {/* /> */} + + + )} + {account?.isAdmin === true && ( + <> + {kind === "movie" &&
} + metadataRefreshMutation.mutate()} + /> + + )} +
+ )} +
+ ); +}; + +export const TitleLine = ({ + kind, + slug, + playHref, + name, + tagline, + date, + rating, + runtime, + poster, + trailerUrl, + // studio, + watchStatus, + ...props +}: { + kind: "movie" | "serie" | "collection"; + slug: string; + playHref: string | null; + name: string; + tagline: string | null; + date: string | null; + rating: number | null; + runtime: number | null; + poster: KImage | null; + trailerUrl: string | null; + // studio: Studio; + watchStatus: WatchStatusV | null; +} & Stylable) => { + const { css, theme } = useYoshiki(); + const { t } = useTranslation(); + + return ( + + + + +

+

({ + xs: theme.user.heading, + md: theme.heading, + }), + })} + > + {name} +

+ {date && ( +

({ + xs: theme.user.paragraph, + md: theme.paragraph, + }), + })} + > + {" "} + ({date}) +

+ )} +

+ {tagline && ( +

({ + xs: theme.user.heading, + md: theme.heading, + }), + })} + > + {tagline} +

+ )} + + + + {rating !== null && rating !== 0 && ( + <> + + + + )} + {runtime && ( + <> + +

+ {displayRuntime(runtime)} +

+ + )} +
+
+
+
+ {/* */} + {/* {studio && ( */} + {/*

theme.user.paragraph, */} + {/* display: "flex", */} + {/* })} */} + {/* > */} + {/* {t("show.studio")}:{" "} */} + {/* theme.user.link })} */} + {/* > */} + {/* {studio.name} */} + {/* */} + {/*

*/} + {/* )} */} + {/*
*/} +
+ ); +}; + +const Description = ({ + isLoading, + overview, + tags, + genres, + ...props +}: { + isLoading: boolean; + overview?: string | null; + tags?: string[]; + genres?: Genre[]; +} & Stylable) => { + const { t } = useTranslation(); + const { css } = useYoshiki(); + + return ( + +

theme.user.paragraph, + })} + > + {t("show.genre")}:{" "} + {(isLoading ? [...Array(3)] : genres!).map((genre, i) => ( + +

{i !== 0 && ", "}

+ {isLoading ? ( + + ) : ( + + {t(`genres.${genre}`)} + + )} + + ))} +

+ + + + {isLoading || ( +

+ {overview ?? t("show.noOverview")} +

+ )} +
+ +

{t("show.tags")}:

+ {(isLoading ? [...Array(3)] : tags!).map((tag, i) => ( + + ))} +
+
+
+ +

{t("show.genre")}

+ {isLoading || genres?.length ? ( + + ) : ( +

{t("show.genre-none")}

+ )} +
+
+ ); +}; + +export const Header = ({ + kind, + slug, +}: { + kind: "movie" | "serie"; + slug: string; +}) => { + const { css } = useYoshiki(); + const { t } = useTranslation(); + + return ( +

loading

} + Render={(data) => ( + <> + + + + + {/* */} + {/* */} + {/*

*/} + {/* {t("show.links")}: */} + {/*

*/} + {/* {(!isLoading */} + {/* ? Object.entries(data.externalId!).filter( */} + {/* ([_, data]) => data.link, */} + {/* ) */} + {/* : [...Array(3)].map((_) => [undefined, undefined] as const) */} + {/* ).map(([name, data], i) => ( */} + {/* */} + {/* ))} */} + {/*
*/} + {/* {type === "show" && ( */} + {/* */} + {/* )} */} + + )} + /> + ); +}; + +Header.query = (kind: string, slug: string): QueryIdentifier => ({ + parser: Show, + path: [kind, slug], + params: { + with: ["studio", "watchStatus"], + }, +}); diff --git a/front/src/ui/details/index.tsx b/front/src/ui/details/index.tsx new file mode 100644 index 00000000..b986d106 --- /dev/null +++ b/front/src/ui/details/index.tsx @@ -0,0 +1 @@ +export { MovieDetails } from "./movie"; diff --git a/front/src/ui/details/movie.tsx b/front/src/ui/details/movie.tsx new file mode 100644 index 00000000..d8e7d13b --- /dev/null +++ b/front/src/ui/details/movie.tsx @@ -0,0 +1,30 @@ +import { Platform, ScrollView } from "react-native"; +import { useYoshiki } from "yoshiki/native"; +import { Movie } from "~/models"; +import type { QueryIdentifier } from "~/query"; +import { useQueryState } from "~/utils"; +import { Header } from "./header"; + +export const MovieDetails = () => { + const [slug] = useQueryState("slug", undefined!); + const { css } = useYoshiki(); + + return ( + +
+ {/* */} + {/* */} + + ); +}; diff --git a/front/packages/ui/src/details/person.tsx b/front/src/ui/details/person.tsx similarity index 100% rename from front/packages/ui/src/details/person.tsx rename to front/src/ui/details/person.tsx diff --git a/front/packages/ui/src/details/season.tsx b/front/src/ui/details/season.tsx similarity index 100% rename from front/packages/ui/src/details/season.tsx rename to front/src/ui/details/season.tsx diff --git a/front/packages/ui/src/details/show.tsx b/front/src/ui/details/show.tsx similarity index 98% rename from front/packages/ui/src/details/show.tsx rename to front/src/ui/details/show.tsx index f981efec..4e18e733 100644 --- a/front/packages/ui/src/details/show.tsx +++ b/front/src/ui/details/show.tsx @@ -31,7 +31,7 @@ import { useTranslation } from "react-i18next"; import { Platform, View, type ViewProps } from "react-native"; import Svg, { Path, type SvgProps } from "react-native-svg"; import { percent, useYoshiki } from "yoshiki/native"; -import { DefaultLayout } from "../layout"; +import { DefaultLayout } from "../../../packages/ui/src/layoutpackages/ui/src/layout"; import { DetailsCollections } from "./collection"; import { EpisodeLine, episodeDisplayNumber } from "./episode"; import { Header } from "./header"; diff --git a/front/packages/ui/src/details/staff.tsx b/front/src/ui/details/staff.tsx similarity index 100% rename from front/packages/ui/src/details/staff.tsx rename to front/src/ui/details/staff.tsx diff --git a/front/src/utils.ts b/front/src/utils.ts index 7d65e942..cc7a9f5c 100644 --- a/front/src/utils.ts +++ b/front/src/utils.ts @@ -23,16 +23,25 @@ export const getDisplayDate = (data: Show | Movie) => { startAir, endAir, airDate, - }: { startAir?: Date | null; endAir?: Date | null; airDate?: Date | null } = data; + }: { startAir?: Date | null; endAir?: Date | null; airDate?: Date | null } = + data; if (startAir) { if (!endAir || startAir.getFullYear() === endAir.getFullYear()) { return startAir.getFullYear().toString(); } - return startAir.getFullYear() + (endAir ? ` - ${endAir.getFullYear()}` : ""); + return ( + startAir.getFullYear() + (endAir ? ` - ${endAir.getFullYear()}` : "") + ); } if (airDate) { return airDate.getFullYear().toString(); } return null; }; + +export const displayRuntime = (runtime: number | null) => { + if (!runtime) return null; + if (runtime < 60) return `${runtime}min`; + return `${Math.floor(runtime / 60)}h${runtime % 60}`; +};