Implement staff display

This commit is contained in:
Zoe Roux 2026-03-28 11:18:56 +01:00
parent 85813129ce
commit 6c3616ed2f
No known key found for this signature in database
12 changed files with 326 additions and 19 deletions

View File

@ -43,14 +43,16 @@ export const insertStaff = record(
staffPk: ret.find((y) => y.slug === x.staff.slug)!.pk,
kind: x.kind,
order: i,
character: {
...x.character,
image: enqueueOptImage(imgQueue, {
url: x.character.image,
table: roles,
column: sql`${roles.character}['image']`,
}),
},
character: x.character
? {
...x.character,
image: enqueueOptImage(imgQueue, {
url: x.character.image,
table: roles,
column: sql`${roles.character}['image']`,
}),
}
: null,
}));
await flushImageQueue(tx, imgQueue, -200);

View File

@ -35,12 +35,14 @@ export type Staff = typeof Staff.static;
export const SeedStaff = t.Composite([
t.Omit(Role, ["character"]),
t.Object({
character: t.Composite([
t.Omit(Character, ["image"]),
t.Object({
image: t.Nullable(SeedImage),
}),
]),
character: t.Nullable(
t.Composite([
t.Omit(Character, ["image"]),
t.Object({
image: t.Nullable(SeedImage),
}),
]),
),
staff: t.Composite([
t.Object({
slug: t.String({ format: "slug" }),

View File

@ -47,7 +47,8 @@
"videosCount": "{{number}} videos",
"version": "Version {{number}}",
"part": "Part {{number}}",
"videos-map": "Edit video mappings"
"videos-map": "Edit video mappings",
"staff-as":"as {{character}}"
},
"videos-map": {
"none": "NONE",

View File

@ -7,6 +7,7 @@ export * from "./search";
export * from "./season";
export * from "./serie";
export * from "./show";
export * from "./staff";
export * from "./studio";
export * from "./user";
export * from "./utils/genre";

38
front/src/models/staff.ts Normal file
View File

@ -0,0 +1,38 @@
import { z } from "zod/v4";
import { KImage } from "./utils/images";
import { Metadata } from "./utils/metadata";
import { zdate } from "./utils/utils";
export const Character = z.object({
name: z.string(),
latinName: z.string().nullable(),
image: KImage.nullable(),
});
export type Character = z.infer<typeof Character>;
export const Staff = z.object({
id: z.string(),
slug: z.string(),
name: z.string(),
latinName: z.string().nullable(),
image: KImage.nullable(),
externalId: Metadata,
createdAt: zdate(),
updatedAt: zdate(),
});
export type Staff = z.infer<typeof Staff>;
export const Role = z.object({
kind: z.enum([
"actor",
"director",
"writter",
"producer",
"music",
"crew",
"other",
]),
character: Character.nullable(),
staff: Staff,
});
export type Role = z.infer<typeof Role>;

View File

@ -71,12 +71,14 @@ export const IconButton = <AsProps = PressableProps>({
as,
className,
iconClassName,
disabled,
...asProps
}: {
as?: ComponentType<AsProps>;
icon: Icon;
iconClassName?: string;
className?: string;
disabled?: boolean;
} & AsProps) => {
const Container = as ?? PressableFeedback;
@ -90,7 +92,13 @@ export const IconButton = <AsProps = PressableProps>({
)}
{...(asProps as AsProps)}
>
<Icon icon={icon} className={iconClassName} />
<Icon
icon={icon}
className={cn(
disabled && "fill-slate-400 dark:fill-slate-600",
iconClassName,
)}
/>
</Container>
);
};

View File

@ -0,0 +1,163 @@
import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
import ArrowForward from "@material-symbols/svg-400/rounded/arrow_forward-fill.svg";
import {
type ComponentType,
type ReactElement,
useEffect,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import {
type Breakpoint,
IconButton,
tooltip,
useBreakpointMap,
} from "~/primitives";
import { type QueryIdentifier, useInfiniteFetch } from "./query";
export type GridLayout = {
numColumns: Breakpoint<number>;
numLines: Breakpoint<number>;
gap: Breakpoint<number>;
};
export const InfiniteGrid = <Data,>({
query,
layout,
Render,
Loader,
Empty,
Header,
Footer,
getItemKey,
}: {
query: QueryIdentifier<Data>;
layout: GridLayout;
Render: (props: { item: Data; index: number }) => ReactElement | null;
Loader: (props: { index: number }) => ReactElement | null;
Empty?: ReactElement;
Header?: ComponentType<{ controls: ReactElement }> | ReactElement;
Footer?: ComponentType | ReactElement;
getItemKey?: (item: Data, index: number) => string | number;
}): ReactElement | null => {
const { t } = useTranslation();
const [pageIndex, setPageIndex] = useState(0);
const { numColumns, numLines, gap } = useBreakpointMap(layout);
query = {
...query,
params: { ...query.params, limit: numColumns * numLines },
};
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } =
useInfiniteFetch(query);
if (!query.infinite)
console.warn("A non infinite query was passed to an InfiniteGrid.");
const queryIdentity = JSON.stringify(query);
useEffect(() => {
queryIdentity;
setPageIndex(0);
}, [queryIdentity]);
const pages = data?.pages ?? [];
const items = pages[pageIndex]?.items ?? [];
const controls = (
<View className="flex-row items-center">
<IconButton
icon={ArrowBack}
onPress={() => setPageIndex((x) => x - 1)}
disabled={pageIndex <= 0}
{...tooltip(t("misc.prev-page"))}
/>
<IconButton
icon={ArrowForward}
onPress={async () => {
if (pageIndex < pages.length - 1) {
setPageIndex((x) => x + 1);
return;
}
if (!hasNextPage || isFetchingNextPage) return;
const res = await fetchNextPage();
if (!res.isError) setPageIndex((x) => x + 1);
}}
disabled={
pageIndex === pages.length - 1 && (isFetchingNextPage || !hasNextPage)
}
{...tooltip(t("misc.next-page"))}
/>
</View>
);
const header =
typeof Header === "function" ? (
<Header controls={controls} />
) : (
(Header ?? controls)
);
const footer = typeof Footer === "function" ? <Footer /> : (Footer ?? null);
if (isFetching && pages.length === 0) {
return (
<>
{header}
<View className="flex-row" style={{ gap }}>
{[...Array(numColumns)].map((_, columnIndex) => (
<View key={columnIndex} className="flex-1" style={{ gap }}>
{[...Array(numColumns)].map((__, idx) => (
<Loader key={idx} index={idx} />
))}
</View>
))}
</View>
{footer}
</>
);
}
if (items.length === 0) {
return (
<>
{header}
{Empty}
{footer}
</>
);
}
const columns = items.reduce(
(acc, item, index) => {
acc[index % numColumns].push(item);
return acc;
},
[...Array(numColumns)].map(() => [] as Data[]),
);
return (
<>
{header}
{
<View className="flex-row" style={{ gap }}>
{columns.map((column, columnIndex) => (
<View key={columnIndex} className="flex-1" style={{ gap }}>
{column.map((item, index) => {
const itemIndex = index * numColumns + columnIndex;
return (
<Render
key={getItemKey?.(item, itemIndex) ?? itemIndex}
item={item}
index={itemIndex}
/>
);
})}
</View>
))}
</View>
}
{footer}
</>
);
};

View File

@ -1,3 +1,4 @@
export * from "./fetch";
export * from "./fetch-grid";
export * from "./fetch-infinite";
export * from "./query";

View File

@ -4,6 +4,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useQueryState } from "~/utils";
import { HeaderBackground, useScrollNavbar } from "../navbar";
import { Header } from "./header";
import { Staff } from "./staff";
export const MovieDetails = () => {
const [slug] = useQueryState("slug", undefined!);
@ -24,6 +25,7 @@ export const MovieDetails = () => {
slug={slug}
onImageLayout={(e) => setHeight(e.nativeEvent.layout.height)}
/>
<Staff kind="movie" slug={slug} />
</Animated.ScrollView>
</>
);

View File

@ -12,6 +12,7 @@ import { useQueryState } from "~/utils";
import { HeaderBackground, useScrollNavbar } from "../navbar";
import { Header } from "./header";
import { EntryList } from "./season";
import { Staff } from "./staff";
export const SvgWave = (props: ComponentProps<typeof Svg>) => {
// aspect-[width/height]: width/height of the svg
@ -101,7 +102,7 @@ const SerieHeader = ({
}}
Loader={NextUp.Loader}
/>
{/* <Staff slug={slug} /> */}
<Staff kind="serie" slug={slug} />
<SvgWave className="flex-1 shrink-0 fill-card" />
</View>
);

View File

@ -0,0 +1,86 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Role } from "~/models";
import { Container, H2, Image, P, Poster, Skeleton, SubP } from "~/primitives";
import { InfiniteGrid, type QueryIdentifier } from "~/query";
import { EmptyView } from "../empty-view";
const CharacterCard = ({ item }: { item: Role }) => {
const { t } = useTranslation();
return (
<View className="flex-row items-center overflow-hidden rounded-xl bg-card">
<Poster src={item.staff.image} quality="low" className="w-28" />
<View className="flex-1 items-center justify-center py-5">
<P className="text-center font-semibold" numberOfLines={2}>
{item.staff.name}
</P>
<SubP className="mt-1 text-center" numberOfLines={2}>
{item.character
? t("show.staff-as", {
character: item.character.name,
})
: item.kind}
</SubP>
</View>
{item.character && (
<Poster src={item.character.image} quality="low" className="w-28" />
)}
</View>
);
};
CharacterCard.Loader = () => (
<View className="h-32 flex-row overflow-hidden rounded-xl bg-card">
<Skeleton variant="custom" className="h-full w-1/3 rounded-none" />
<View className="flex-1 items-center justify-center px-3">
<Skeleton className="h-5 w-4/5" />
<Skeleton className="mt-2 h-4 w-3/5" />
<Skeleton className="mt-2 h-3 w-2/5" />
</View>
<Skeleton variant="custom" className="h-full w-1/3 rounded-none" />
</View>
);
export const Staff = ({
kind,
slug,
}: {
kind: "serie" | "movie";
slug: string;
}) => {
const { t } = useTranslation();
return (
<Container className="mb-4">
<InfiniteGrid
query={Staff.query(kind, slug)}
layout={{
numColumns: { xs: 1, md: 2, xl: 3 },
numLines: 3,
gap: { xs: 8, lg: 12 },
}}
Header={({ controls }) => (
<View className="mb-3 flex-row items-center justify-between">
<H2>{t("show.staff")}</H2>
{controls}
</View>
)}
Empty={<EmptyView message={t("show.staff-none")} />}
Render={({ item }) => <CharacterCard item={item} />}
Loader={() => <CharacterCard.Loader />}
getItemKey={(item) =>
`${item.staff.id}-${item.kind}-${item.character?.name ?? "none"}`
}
/>
</Container>
);
};
Staff.query = (
kind: "serie" | "movie",
slug: string,
): QueryIdentifier<Role> => ({
path: ["api", `${kind}s`, slug, "staff"],
parser: Role,
infinite: true,
});

View File

@ -825,7 +825,9 @@ class TVDB(Provider):
name=x["name"],
latin_name=None,
image=img if (img := x["image"]) else None,
),
)
if x["name"]
else None,
staff=Person(
slug=to_slug(x["personName"]),
name=x["personName"],
@ -844,5 +846,5 @@ class TVDB(Provider):
),
)
for x in show["characters"]
if x["name"] and x["personName"]
if x["personName"]
]