diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index c05a96e8..1d13dc70 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -41,6 +41,8 @@ import { getEntryTransQ, mapProgress, } from "../entries"; +import { Collection } from "~/models/collections"; +import { alias } from "drizzle-orm/pg-core"; export const watchStatusQ = db .select({ @@ -124,6 +126,42 @@ export const showRelations = { .where(eq(showTranslations.pk, shows.pk)) .as("translations"); }, + collection: ({ + languages, + preferOriginal, + }: { + languages: string[]; + preferOriginal?: boolean; + }) => { + const collections = alias(shows, "collections"); + const colTrans = alias(showTranslations, "col_trans"); + const transQ = db + .selectDistinctOn([colTrans.pk]) + .from(colTrans) + .orderBy( + colTrans.pk, + sql`array_position(${sqlarr(languages)}, ${colTrans.language})`, + ) + .as("t"); + + return db + .select({ + json: jsonbBuildObject({ + ...getColumns(collections), + ...getColumns(transQ), + ...(preferOriginal && { + poster: sql`coalesce(nullif(${collections.original}->'poster', 'null'::jsonb), ${transQ.poster})`, + thumbnail: sql`coalesce(nullif(${collections.original}->'thumbnail', 'null'::jsonb), ${transQ.thumbnail})`, + banner: sql`coalesce(nullif(${collections.original}->'banner', 'null'::jsonb), ${transQ.banner})`, + logo: sql`coalesce(nullif(${collections.original}->'logo', 'null'::jsonb), ${transQ.logo})`, + }), + }), + }) + .from(collections) + .innerJoin(transQ, eq(collections.pk, transQ.pk)) + .where(eq(collections.pk, shows.collectionPk)) + .as("collection"); + }, studios: ({ languages }: { languages: string[] }) => { const studioTransQ = db .selectDistinctOn([studioTranslations.pk]) @@ -304,7 +342,10 @@ export async function getShows({ watchStatus: getColumns(watchStatusQ), - ...buildRelations(relations, showRelations, { languages }), + ...buildRelations(relations, showRelations, { + languages, + preferOriginal, + }), }) .from(shows) .leftJoin(watchStatusQ, eq(shows.pk, watchStatusQ.showPk)) diff --git a/api/src/controllers/shows/movies.ts b/api/src/controllers/shows/movies.ts index 79bf33bb..7c23a926 100644 --- a/api/src/controllers/shows/movies.ts +++ b/api/src/controllers/shows/movies.ts @@ -77,10 +77,13 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) preferOriginal: t.Optional( t.Boolean({ description: desc.preferOriginal }), ), - with: t.Array(t.UnionEnum(["translations", "studios", "videos"]), { - default: [], - description: "Include related resources in the response.", - }), + with: t.Array( + t.UnionEnum(["translations", "collection", "studios", "videos"]), + { + default: [], + description: "Include related resources in the response.", + }, + ), }), headers: t.Object({ "accept-language": AcceptLanguage(), @@ -119,10 +122,13 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) preferOriginal: t.Optional( t.Boolean({ description: desc.preferOriginal }), ), - with: t.Array(t.UnionEnum(["translations", "studios", "videos"]), { - default: [], - description: "Include related resources in the response.", - }), + with: t.Array( + t.UnionEnum(["translations", "collection", "studios", "videos"]), + { + default: [], + description: "Include related resources in the response.", + }, + ), }), response: { 302: t.Void({ diff --git a/api/src/controllers/shows/series.ts b/api/src/controllers/shows/series.ts index 73769481..3672d35e 100644 --- a/api/src/controllers/shows/series.ts +++ b/api/src/controllers/shows/series.ts @@ -78,7 +78,13 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] }) t.Boolean({ description: desc.preferOriginal }), ), with: t.Array( - t.UnionEnum(["translations", "studios", "firstEntry", "nextEntry"]), + t.UnionEnum([ + "translations", + "collection", + "studios", + "firstEntry", + "nextEntry", + ]), { default: [], description: "Include related resources in the response.", @@ -123,7 +129,13 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] }) t.Boolean({ description: desc.preferOriginal }), ), with: t.Array( - t.UnionEnum(["translations", "studios", "firstEntry", "nextEntry"]), + t.UnionEnum([ + "translations", + "collection", + "studios", + "firstEntry", + "nextEntry", + ]), { default: [], description: "Include related resources in the response.", diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index e6fb514c..ace47e2a 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -1,6 +1,6 @@ import { t } from "elysia"; import type { Prettify } from "~/utils"; -import { SeedCollection } from "./collections"; +import { Collection, SeedCollection } from "./collections"; import { bubble, bubbleImages, registerExamples } from "./examples"; import { SeedStaff } from "./staff"; import { SeedStudio, Studio } from "./studio"; @@ -70,6 +70,7 @@ export const FullMovie = t.Intersect([ t.Object({ translations: t.Optional(TranslationRecord(MovieTranslation)), videos: t.Optional(t.Array(EmbeddedVideo)), + collection: t.Optional(t.Nullable(Collection)), studios: t.Optional(t.Array(Studio)), }), ]); diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index 640d7575..9feb8e41 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -1,6 +1,6 @@ import { t } from "elysia"; import type { Prettify } from "~/utils"; -import { SeedCollection } from "./collections"; +import { Collection, SeedCollection } from "./collections"; import { Entry, SeedEntry, SeedExtra } from "./entry"; import { bubbleImages, madeInAbyss, registerExamples } from "./examples"; import { SeedSeason } from "./season"; @@ -84,6 +84,7 @@ export const FullSerie = t.Intersect([ Serie, t.Object({ translations: t.Optional(TranslationRecord(SerieTranslation)), + collection: t.Optional(t.Nullable(Collection)), studios: t.Optional(t.Array(Studio)), firstEntry: t.Optional(t.Nullable(Entry)), nextEntry: t.Optional(t.Nullable(Entry)), diff --git a/front/public/translations/en.json b/front/public/translations/en.json index 84fc33c2..7aaea08b 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -25,6 +25,7 @@ "episode-none": "There is no episodes in this season", "episodeNoMetadata": "No metadata available", "tags": "Tags", + "tags-none": "No tags available", "links": "Links", "jumpToSeason": "Jump to season", "partOf": "Part of the", diff --git a/front/src/components/items/index.ts b/front/src/components/items/index.ts index 30d220e6..69abea5a 100644 --- a/front/src/components/items/index.ts +++ b/front/src/components/items/index.ts @@ -13,7 +13,7 @@ export const itemMap = ( subtitle: item.kind !== "collection" ? getDisplayDate(item) : null, href: item.href, poster: item.poster, - thumbnail: item.thumbnail, + banner: item.banner ?? item.thumbnail, watchStatus: item.kind !== "collection" ? (item.watchStatus?.status ?? null) : null, watchPercent: diff --git a/front/src/components/items/item-list.tsx b/front/src/components/items/item-list.tsx index fb8e6533..ce50162f 100644 --- a/front/src/components/items/item-list.tsx +++ b/front/src/components/items/item-list.tsx @@ -22,7 +22,7 @@ export const ItemList = ({ kind, name, subtitle, - thumbnail, + banner, poster, watchStatus, availableCount, @@ -36,7 +36,7 @@ export const ItemList = ({ name: string; subtitle: string | null; poster: KImage | null; - thumbnail: KImage | null; + banner: KImage | null; watchStatus: WatchStatusV | null; availableCount?: number | null; seenCount?: number | null; @@ -56,7 +56,7 @@ export const ItemList = ({ {...props} > diff --git a/front/src/models/movie.ts b/front/src/models/movie.ts index 8b00b1cc..a81ede6a 100644 --- a/front/src/models/movie.ts +++ b/front/src/models/movie.ts @@ -4,6 +4,7 @@ import { Genre } from "./utils/genre"; import { KImage } from "./utils/images"; import { Metadata } from "./utils/metadata"; import { zdate } from "./utils/utils"; +import { Collection } from "./collection"; export const Movie = z .object({ @@ -64,6 +65,8 @@ export const Movie = z percent: z.number().int().gte(0).lte(100), }) .nullable(), + + collection: Collection.optional().nullable(), }) .transform((x) => ({ ...x, diff --git a/front/src/models/serie.ts b/front/src/models/serie.ts index e8671382..abf030de 100644 --- a/front/src/models/serie.ts +++ b/front/src/models/serie.ts @@ -5,6 +5,7 @@ import { Genre } from "./utils/genre"; import { KImage } from "./utils/images"; import { Metadata } from "./utils/metadata"; import { zdate } from "./utils/utils"; +import { Collection } from "./collection"; export const Serie = z .object({ @@ -62,6 +63,8 @@ export const Serie = z seenCount: z.number().int().gte(0), }) .nullable(), + + collection: Collection.optional().nullable(), }) .transform((x) => { const entry = x.nextEntry ?? x.firstEntry; diff --git a/front/src/primitives/container.tsx b/front/src/primitives/container.tsx index e74e0f7d..b69cd521 100644 --- a/front/src/primitives/container.tsx +++ b/front/src/primitives/container.tsx @@ -1,24 +1,15 @@ -import type { ComponentType } from "react"; import { View, type ViewProps } from "react-native"; import { cn } from "~/utils"; -export const Container = ({ - className, - as, - ...props -}: { - className?: string; - as?: ComponentType; -} & AsProps) => { - const As = as ?? View; +export const Container = ({ className, ...props }: ViewProps) => { return ( - ); }; diff --git a/front/src/primitives/image-background.tsx b/front/src/primitives/image-background.tsx index ee32340c..4577cea4 100644 --- a/front/src/primitives/image-background.tsx +++ b/front/src/primitives/image-background.tsx @@ -21,6 +21,7 @@ export const ImageBackground = ({ quality, alt, className, + children, ...props }: { src: KImage | null; @@ -32,7 +33,11 @@ export const ImageBackground = ({ const { apiUrl, authToken } = useToken(); if (!src) { - return ; + return ( + + {children} + + ); } const uri = `${apiUrl}${src[quality ?? "high"]}`; @@ -54,7 +59,9 @@ export const ImageBackground = ({ className={cn("overflow-hidden bg-gray-300", className)} imageStyle={{ width: "100%", height: "100%", margin: 0, padding: 0 }} {...props} - /> + > + {children} + ); }; diff --git a/front/src/query/fetch-infinite.tsx b/front/src/query/fetch-infinite.tsx index cd4d42e9..e7f69032 100644 --- a/front/src/query/fetch-infinite.tsx +++ b/front/src/query/fetch-infinite.tsx @@ -27,6 +27,7 @@ export const InfiniteFetch = ({ Header, fetchMore = true, contentContainerStyle, + margin = true, ...props }: { query: QueryIdentifier; @@ -47,6 +48,7 @@ export const InfiniteFetch = ({ contentContainerStyle?: ViewStyle; onScroll?: LegendListProps["onScroll"]; scrollEventThrottle?: LegendListProps["scrollEventThrottle"]; + margin?: boolean; }): JSX.Element | null => { const { numColumns, size, gap } = useBreakpointMap(layout); const oldItems = useRef(undefined); @@ -101,12 +103,15 @@ export const InfiniteFetch = ({ } showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} - contentContainerStyle={{ - ...contentContainerStyle, - gap, - marginLeft: numColumns > 1 ? gap : 0, - marginRight: numColumns > 1 ? gap : 0, - }} + contentContainerStyle={[ + { + ...contentContainerStyle, + gap, + }, + margin + ? { marginLeft: gap, marginRight: gap } + : { marginLeft: 0, marginRight: 0 }, + ]} {...props} /> ); diff --git a/front/src/ui/details/collection.tsx b/front/src/ui/details/collection.tsx index 2edb2647..c7c12218 100644 --- a/front/src/ui/details/collection.tsx +++ b/front/src/ui/details/collection.tsx @@ -1,30 +1,77 @@ import { useState } from "react"; -import Animated from "react-native-reanimated"; +import { View, type ViewProps } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { itemMap } from "~/components/items"; +import { ItemDetails } from "~/components/items/item-details"; +import { Show } from "~/models"; +import { Container } from "~/primitives"; +import { InfiniteFetch, type QueryIdentifier } from "~/query"; import { useQueryState } from "~/utils"; import { HeaderBackground, useScrollNavbar } from "../navbar"; import { Header } from "./header"; +import { SvgWave } from "./serie"; + +const CollectionHeader = ({ + slug, + onImageLayout, +}: { + slug: string; + onImageLayout?: ViewProps["onLayout"]; +}) => { + return ( + +
+ + + ); +}; export const CollectionDetails = () => { const [slug] = useQueryState("slug", undefined!); const insets = useSafeAreaInsets(); const [imageHeight, setHeight] = useState(300); - const { scrollHandler, headerProps } = useScrollNavbar({ imageHeight }); + const { scrollHandler, headerProps } = useScrollNavbar({ + imageHeight, + }); return ( - <> + - ( + + )} + Loader={() => ( + + + + )} + Header={() => ( + setHeight(e.nativeEvent.layout.height)} + /> + )} onScroll={scrollHandler} - scrollEventThrottle={16} - contentContainerStyle={{ paddingBottom: insets.bottom }} - > -
setHeight(e.nativeEvent.layout.height)} - /> - - + contentContainerStyle={{ + paddingBottom: insets.bottom, + }} + margin={false} + /> + ); }; + +CollectionDetails.query = (slug: string): QueryIdentifier => ({ + parser: Show, + path: ["api", "collections", slug, "shows"], + infinite: true, +}); diff --git a/front/src/ui/details/header.tsx b/front/src/ui/details/header.tsx index 7042fdf3..eb0be504 100644 --- a/front/src/ui/details/header.tsx +++ b/front/src/ui/details/header.tsx @@ -41,6 +41,7 @@ import { import { useAccount } from "~/providers/account-context"; import { Fetch, type QueryIdentifier } from "~/query"; import { cn, displayRuntime, getDisplayDate } from "~/utils"; +import { PartOf } from "./part-of"; const ButtonList = ({ kind, @@ -260,7 +261,7 @@ const Description = ({ description: string | null; tags: string[]; genres: Genre[]; - studios: Studio[]; + studios: Studio[] | null; externalIds: Metadata; }) => { const { t } = useTranslation(); @@ -268,7 +269,7 @@ const Description = ({ return ( -

+

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

@@ -293,27 +294,33 @@ const Description = ({

{t("show.tags")}:

- {tags.map((tag) => ( - - ))} + {tags.length ? ( + tags.map((tag) => ( + + )) + ) : ( +

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

+ )}
-

-

{t("show.studios")}:

- {studios.map((x, i) => ( - - {i !== 0 && ","} - - {x.name} - - - ))} -

+ {studios !== null && ( +

+

{t("show.studios")}:

+ {studios.map((x, i) => ( + + {i !== 0 && ","} + + {x.name} + + + ))} +

+ )}

{t("show.links")}:

{Object.entries(externalIds) @@ -425,13 +432,20 @@ export const Header = ({ description={data.description} tags={data.tags} genres={data.genres} - studios={data.kind !== "collection" ? data.studios! : []} + studios={data.kind !== "collection" ? data.studios! : null} externalIds={data.externalId} /> - {/* {type === "show" && ( */} - {/* */} - {/* )} */} + {data.kind !== "collection" && data.collection && ( + + + + )}
)} Loader={() => ( @@ -459,7 +473,7 @@ Header.query = ( path: ["api", `${kind}s`, slug], params: { with: [ - ...(kind !== "collection" ? ["studios"] : []), + ...(kind !== "collection" ? ["collection", "studios"] : []), ...(kind === "serie" ? ["firstEntry", "nextEntry"] : []), ], }, diff --git a/front/src/ui/details/part-of.tsx b/front/src/ui/details/part-of.tsx index 864ab92e..3b1e45aa 100644 --- a/front/src/ui/details/part-of.tsx +++ b/front/src/ui/details/part-of.tsx @@ -1,110 +1,47 @@ -import { - type Collection, - CollectionP, - type KyooImage, - type QueryIdentifier, - useInfiniteFetch, -} from "@kyoo/models"; -import { - Container, - focusReset, - GradientImageBackground, - H2, - ImageBackground, - Link, - P, - ts, -} from "@kyoo/primitives"; import { useTranslation } from "react-i18next"; -import { type Theme, useYoshiki } from "yoshiki/native"; +import { View } from "react-native"; +import type { KImage } from "~/models"; +import { H2, ImageBackground, Link, P } from "~/primitives"; +import { cn } from "~/utils"; export const PartOf = ({ name, - overview, - thumbnail, + description, + banner, href, + className, }: { name: string; - overview: string | null; - thumbnail: KyooImage | null; + description: string | null; + banner: KImage | null; href: string; + className?: string; }) => { - const { css, theme } = useYoshiki("part-of-collection"); const { t } = useTranslation(); return ( theme.background, - fover: { - self: { ...focusReset, borderColor: (theme: Theme) => theme.accent }, - title: { textDecorationLine: "underline" }, - }, - })} + className={cn( + "group flex-1 overflow-hidden rounded-xl ring-accent hover:ring-3 focus-visible:ring-3", + className, + )} > - -

+ + +

{t("show.partOf")} {name}

-

{overview}

- +

+ {description} +

+
); }; - -export const DetailsCollections = ({ - type, - slug, -}: { - type: "movie" | "show"; - slug: string; -}) => { - const { items } = useInfiniteFetch(DetailsCollections.query(type, slug)); - const { css } = useYoshiki(); - - // Since most items dont have collections, not having a skeleton reduces layout shifts. - if (!items) return null; - - return ( - - {items.map((x) => ( - - ))} - - ); -}; - -DetailsCollections.query = ( - type: "movie" | "show", - slug: string, -): QueryIdentifier => ({ - parser: CollectionP, - path: [type, slug, "collections"], - params: { - limit: 0, - }, - infinite: true, -}); diff --git a/front/src/ui/details/season.tsx b/front/src/ui/details/season.tsx index 904e9828..87aa301b 100644 --- a/front/src/ui/details/season.tsx +++ b/front/src/ui/details/season.tsx @@ -123,34 +123,33 @@ export const EntryList = ({ .filter((x) => x !== null) } placeholderCount={5} - Render={({ item }) => - item.kind === "season" ? ( - - ) : ( - - ) - } - Loader={({ index }) => - index === 0 ? ( - - ) : ( - - ) - } + Render={({ item }) => ( + + {item.kind === "season" ? ( + + ) : ( + + )} + + )} + Loader={({ index }) => ( + + {index === 0 ? : } + + )} + margin={false} {...props} /> ); diff --git a/front/src/ui/details/serie.tsx b/front/src/ui/details/serie.tsx index a1ae2adb..c8d77196 100644 --- a/front/src/ui/details/serie.tsx +++ b/front/src/ui/details/serie.tsx @@ -74,7 +74,6 @@ const SerieHeader = ({ }} Loader={NextUp.Loader} /> - {/* */} {/* */}