From c7103410dc3e2e9efcb248835cb197613fc18571 Mon Sep 17 00:00:00 2001
From: Zoe Roux
Date: Mon, 23 Jun 2025 11:52:02 +0200
Subject: [PATCH] Remake details page for movies
---
auth/shell.nix | 2 +-
front/app/(app)/movies/[slug].tsx | 3 +
front/packages/ui/src/collection/index.tsx | 4 +-
front/packages/ui/src/components/rating.tsx | 39 --
front/packages/ui/src/details/header.tsx | 590 -----------------
front/packages/ui/src/details/index.tsx | 22 -
front/packages/ui/src/details/movie.tsx | 68 --
front/packages/ui/src/downloads/page.tsx | 2 +-
front/packages/ui/src/home/header.tsx | 2 +-
front/packages/ui/src/home/news.tsx | 2 +-
front/packages/ui/src/home/watchlist.tsx | 2 +-
front/packages/ui/src/player/index.tsx | 2 +-
front/src/components/items/watchlist-info.tsx | 8 +-
front/src/components/rating.tsx | 34 +
front/src/models/index.ts | 1 +
front/src/models/movie.ts | 11 +-
front/src/models/serie.ts | 23 +-
front/src/models/user.ts | 2 +-
front/src/models/utils/genre.ts | 1 +
front/src/primitives/icons.tsx | 26 +-
front/src/primitives/image.tsx | 10 +-
front/src/primitives/utils/head.tsx | 10 +-
front/src/primitives/utils/head.web.tsx | 19 -
.../ui/src => src/ui}/details/collection.tsx | 2 +-
.../ui/src => src/ui}/details/episode.tsx | 6 -
front/src/ui/details/header.tsx | 605 ++++++++++++++++++
front/src/ui/details/index.tsx | 1 +
front/src/ui/details/movie.tsx | 30 +
.../ui/src => src/ui}/details/person.tsx | 0
.../ui/src => src/ui}/details/season.tsx | 0
.../ui/src => src/ui}/details/show.tsx | 2 +-
.../ui/src => src/ui}/details/staff.tsx | 0
front/src/utils.ts | 13 +-
33 files changed, 762 insertions(+), 780 deletions(-)
create mode 100644 front/app/(app)/movies/[slug].tsx
delete mode 100644 front/packages/ui/src/components/rating.tsx
delete mode 100644 front/packages/ui/src/details/header.tsx
delete mode 100644 front/packages/ui/src/details/index.tsx
delete mode 100644 front/packages/ui/src/details/movie.tsx
create mode 100644 front/src/components/rating.tsx
delete mode 100644 front/src/primitives/utils/head.web.tsx
rename front/{packages/ui/src => src/ui}/details/collection.tsx (98%)
rename front/{packages/ui/src => src/ui}/details/episode.tsx (97%)
create mode 100644 front/src/ui/details/header.tsx
create mode 100644 front/src/ui/details/index.tsx
create mode 100644 front/src/ui/details/movie.tsx
rename front/{packages/ui/src => src/ui}/details/person.tsx (100%)
rename front/{packages/ui/src => src/ui}/details/season.tsx (100%)
rename front/{packages/ui/src => src/ui}/details/show.tsx (98%)
rename front/{packages/ui/src => src/ui}/details/staff.tsx (100%)
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) && (
-
- )}
-
- );
-};
-
-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) && (
+
+ )}
+
+ );
+};
+
+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 ? (
+
+ {(isLoading ? [...Array(3)] : genres!).map((genre, i) => (
+ -
+ {isLoading ? (
+
+ ) : (
+
+ {t(`genres.${genre}`)}
+
+ )}
+
+ ))}
+
+ ) : (
+ {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}`;
+};