Create part-of collection card

This commit is contained in:
Zoe Roux 2026-02-19 13:41:37 +01:00
parent 01f5b44b60
commit 31af752e2e
No known key found for this signature in database
18 changed files with 264 additions and 197 deletions

View File

@ -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<Collection>({
...getColumns(collections),
...getColumns(transQ),
...(preferOriginal && {
poster: sql<Image>`coalesce(nullif(${collections.original}->'poster', 'null'::jsonb), ${transQ.poster})`,
thumbnail: sql<Image>`coalesce(nullif(${collections.original}->'thumbnail', 'null'::jsonb), ${transQ.thumbnail})`,
banner: sql<Image>`coalesce(nullif(${collections.original}->'banner', 'null'::jsonb), ${transQ.banner})`,
logo: sql<Image>`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))

View File

@ -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({

View File

@ -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.",

View File

@ -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)),
}),
]);

View File

@ -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)),

View File

@ -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",

View File

@ -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:

View File

@ -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}
>
<ImageBackground
src={thumbnail}
src={banner}
quality="medium"
className="h-full w-full flex-row items-center justify-evenly"
>

View File

@ -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,

View File

@ -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;

View File

@ -1,24 +1,15 @@
import type { ComponentType } from "react";
import { View, type ViewProps } from "react-native";
import { cn } from "~/utils";
export const Container = <AsProps = ViewProps>({
className,
as,
...props
}: {
className?: string;
as?: ComponentType<AsProps>;
} & AsProps) => {
const As = as ?? View;
export const Container = ({ className, ...props }: ViewProps) => {
return (
<As
<View
className={cn(
"flex w-full self-center px-4",
"sm:w-xl md:w-3xl lg:w-5xl xl:w-7xl",
className,
)}
{...(props as AsProps)}
{...props}
/>
);
};

View File

@ -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 <View className={cn("overflow-hidden bg-gray-300", className)} />;
return (
<View className={cn("overflow-hidden bg-gray-300", className)}>
{children}
</View>
);
}
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}
</ImgBg>
);
};

View File

@ -27,6 +27,7 @@ export const InfiniteFetch = <Data, Type extends string = string>({
Header,
fetchMore = true,
contentContainerStyle,
margin = true,
...props
}: {
query: QueryIdentifier<Data>;
@ -47,6 +48,7 @@ export const InfiniteFetch = <Data, Type extends string = string>({
contentContainerStyle?: ViewStyle;
onScroll?: LegendListProps["onScroll"];
scrollEventThrottle?: LegendListProps["scrollEventThrottle"];
margin?: boolean;
}): JSX.Element | null => {
const { numColumns, size, gap } = useBreakpointMap(layout);
const oldItems = useRef<Data[] | undefined>(undefined);
@ -101,12 +103,15 @@ export const InfiniteFetch = <Data, Type extends string = string>({
}
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}
/>
);

View File

@ -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 (
<View className="bg-background">
<Header kind="collection" slug={slug} onImageLayout={onImageLayout} />
<SvgWave className="flex-1 shrink-0 fill-card" />
</View>
);
};
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 (
<>
<View className="flex-1 bg-card">
<HeaderBackground {...headerProps} />
<Animated.ScrollView
<InfiniteFetch
query={CollectionDetails.query(slug)}
layout={ItemDetails.layout}
Render={({ item }) => (
<ItemDetails
{...itemMap(item)}
tagline={item.tagline}
description={item.description}
genres={item.genres}
playHref={item.kind !== "collection" ? item.playHref : null}
/>
)}
Loader={() => (
<Container>
<ItemDetails.Loader />
</Container>
)}
Header={() => (
<CollectionHeader
slug={slug}
onImageLayout={(e) => setHeight(e.nativeEvent.layout.height)}
/>
)}
onScroll={scrollHandler}
scrollEventThrottle={16}
contentContainerStyle={{ paddingBottom: insets.bottom }}
>
<Header
kind="collection"
slug={slug}
onImageLayout={(e) => setHeight(e.nativeEvent.layout.height)}
/>
</Animated.ScrollView>
</>
contentContainerStyle={{
paddingBottom: insets.bottom,
}}
margin={false}
/>
</View>
);
};
CollectionDetails.query = (slug: string): QueryIdentifier<Show> => ({
parser: Show,
path: ["api", "collections", slug, "shows"],
infinite: true,
});

View File

@ -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 (
<Container className="py-10" {...props}>
<View className="flex-1 flex-col-reverse sm:flex-row">
<P className="py-5 text-justify">
<P className="flex-1 py-5 text-justify">
{description ?? t("show.noOverview")}
</P>
<View className="basis-1/5 flex-row xl:mt-[-100px]">
@ -293,27 +294,33 @@ const Description = ({
</View>
<View className="mt-5 flex-row flex-wrap items-center">
<P className="mr-1">{t("show.tags")}:</P>
{tags.map((tag) => (
<Chip
key={tag}
label={tag && capitalize(tag)}
href={`/search?q=${tag}`}
size="small"
className="m-1"
/>
))}
{tags.length ? (
tags.map((tag) => (
<Chip
key={tag}
label={tag && capitalize(tag)}
href={`/search?q=${tag}`}
size="small"
className="m-1"
/>
))
) : (
<P>{t("show.tags-none")}</P>
)}
</View>
<P className="my-5 flex-row flex-wrap items-center">
<P className="mr-1">{t("show.studios")}:</P>
{studios.map((x, i) => (
<Fragment key={x.id}>
{i !== 0 && ","}
<A href={x.slug} className="ml-2">
{x.name}
</A>
</Fragment>
))}
</P>
{studios !== null && (
<P className="my-5 flex-row flex-wrap items-center">
<P className="mr-1">{t("show.studios")}:</P>
{studios.map((x, i) => (
<Fragment key={x.id}>
{i !== 0 && ","}
<A href={x.slug} className="ml-2">
{x.name}
</A>
</Fragment>
))}
</P>
)}
<View className="flex-row flex-wrap items-center">
<P className="mr-1 text-center">{t("show.links")}:</P>
{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" && ( */}
{/* <ShowWatchStatusCard {...(data?.watchStatus as any)} /> */}
{/* )} */}
{data.kind !== "collection" && data.collection && (
<Container className="mb-4">
<PartOf
name={data.collection.name}
description={data.collection.description}
banner={data.collection.banner ?? data.collection.thumbnail}
href={data.collection.href}
/>
</Container>
)}
</View>
)}
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"] : []),
],
},

View File

@ -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 (
<Link
href={href}
{...css({
borderRadius: 16,
overflow: "hidden",
borderWidth: ts(0.5),
borderStyle: "solid",
borderColor: (theme) => 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,
)}
>
<GradientImageBackground
src={thumbnail}
alt=""
quality="medium"
gradient={{
colors: [theme.darkOverlay, "transparent"],
start: { x: 0, y: 0 },
end: { x: 1, y: 0 },
}}
{...css({
padding: ts(3),
})}
>
<H2 {...css("title")}>
<ImageBackground src={banner} quality="high" alt="" className="p-6">
<View className="absolute inset-0 bg-linear-to-b from-transparent to-slate-950/70" />
<H2
className={cn(
"py-2",
"text-slate-200 dark:text-slate-200",
"group-focus-within:underline group-hover:underline",
)}
>
{t("show.partOf")} {name}
</H2>
<P {...css({ textAlign: "justify" })}>{overview}</P>
</GradientImageBackground>
<P className="text-justify text-slate-400 dark:text-slate-400">
{description}
</P>
</ImageBackground>
</Link>
);
};
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 (
<Container {...css({ marginY: ts(2) })}>
{items.map((x) => (
<PartOf
key={x.id}
name={x.name}
overview={x.overview}
thumbnail={x.thumbnail}
href={x.href}
/>
))}
</Container>
);
};
DetailsCollections.query = (
type: "movie" | "show",
slug: string,
): QueryIdentifier<Collection> => ({
parser: CollectionP,
path: [type, slug, "collections"],
params: {
limit: 0,
},
infinite: true,
});

View File

@ -123,34 +123,33 @@ export const EntryList = ({
.filter((x) => x !== null)
}
placeholderCount={5}
Render={({ item }) =>
item.kind === "season" ? (
<Container
as={SeasonHeader}
serieSlug={slug}
name={item.name}
seasonNumber={item.seasonNumber}
seasons={seasons ?? []}
/>
) : (
<Container
as={EntryLine}
{...item}
// Don't display "Go to serie"
videosCount={item.videos.length}
serieSlug={null}
displayNumber={entryDisplayNumber(item)}
watchedPercent={item.progress.percent}
/>
)
}
Loader={({ index }) =>
index === 0 ? (
<Container as={SeasonHeader.Loader} />
) : (
<Container as={EntryLine.Loader} />
)
}
Render={({ item }) => (
<Container>
{item.kind === "season" ? (
<SeasonHeader
serieSlug={slug}
name={item.name}
seasonNumber={item.seasonNumber}
seasons={seasons ?? []}
/>
) : (
<EntryLine
{...item}
// Don't display "Go to serie"
videosCount={item.videos.length}
serieSlug={null}
displayNumber={entryDisplayNumber(item)}
watchedPercent={item.progress.percent}
/>
)}
</Container>
)}
Loader={({ index }) => (
<Container>
{index === 0 ? <SeasonHeader.Loader /> : <EntryLine.Loader />}
</Container>
)}
margin={false}
{...props}
/>
);

View File

@ -74,7 +74,6 @@ const SerieHeader = ({
}}
Loader={NextUp.Loader}
/>
{/* <DetailsCollections type="serie" slug={slug} /> */}
{/* <Staff slug={slug} /> */}
<SvgWave className="flex-1 shrink-0 fill-card" />
</View>