Add staff page with all their roles (#1399)

This commit is contained in:
Zoe Roux 2026-03-28 22:13:35 +01:00 committed by GitHub
commit 045cf0196b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 165 additions and 24 deletions

View File

@ -1,4 +1,4 @@
import { and, eq, exists, ne, type SQL, sql } from "drizzle-orm";
import { and, eq, exists, ne, or, type SQL, sql } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import { db } from "~/db";
import {
@ -364,7 +364,17 @@ export async function getShows({
.where(
and(
filter,
query ? sql`${transQ.name} %> ${query}::text` : undefined,
query
? or(
sql`${transQ.name} %> ${query}::text`,
exists(
db
.select()
.from(sql`unnest(${transQ.tags}) as tag`)
.where(sql`tag %> ${query}::text`),
),
)
: undefined,
keysetPaginate({ after, sort }),
),
)

View File

@ -48,7 +48,16 @@
"version": "Version {{number}}",
"part": "Part {{number}}",
"videos-map": "Edit video mappings",
"staff-as":"as {{character}}"
"staff-as":"as {{character}}",
"staff-kind": {
"actor": "Actor",
"director": "Director",
"writter": "Writer",
"producer": "Producer",
"music": "Music",
"crew": "Crew",
"other": "Other"
}
},
"videos-map": {
"none": "NONE",

View File

@ -0,0 +1,5 @@
import { StaffPage } from "~/ui/staff";
export { ErrorBoundary } from "~/ui/error-boundary";
export default StaffPage;

View File

@ -1,4 +1,5 @@
import { z } from "zod/v4";
import { Show } from "./show";
import { KImage } from "./utils/images";
import { Metadata } from "./utils/metadata";
import { zdate } from "./utils/utils";
@ -22,7 +23,7 @@ export const Staff = z.object({
});
export type Staff = z.infer<typeof Staff>;
export const Role = z.object({
const BaseRole = z.object({
kind: z.enum([
"actor",
"director",
@ -33,6 +34,17 @@ export const Role = z.object({
"other",
]),
character: Character.nullable(),
});
export const Role = BaseRole.extend({
staff: Staff,
});
export type Role = z.infer<typeof Role>;
export const RoleWithShow = BaseRole.extend({
show: Show,
}).transform((x) => ({
...x,
id: `${x.show.id}-${x.kind}-${x.character?.name ?? "none"}`,
}));
export type RoleWithShow = z.infer<typeof RoleWithShow>;

View File

@ -90,6 +90,7 @@ export const IconButton = <AsProps = PressableProps>({
"outline-0 hover:bg-gray-400/50 focus-visible:bg-gray-400/50",
className,
)}
disabled={disabled}
{...(asProps as AsProps)}
>
<Icon

View File

@ -1,43 +1,52 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Role } from "~/models";
import { Container, H2, P, Poster, Skeleton, SubP } from "~/primitives";
import { type KImage, Role } from "~/models";
import { Container, H2, Link, 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();
export const CharacterCard = ({
href,
name,
subtitle,
image,
characterImage,
}: {
href: string;
name: string;
subtitle: string;
image: KImage | null;
characterImage?: KImage | null;
}) => {
return (
<View className="flex-row items-center overflow-hidden rounded-xl bg-card">
<Poster src={item.staff.image} quality="low" className="w-28" />
<Link
href={href}
className="flex-row items-center overflow-hidden rounded-xl bg-card"
>
<Poster src={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}
{name}
</P>
<SubP className="mt-1 text-center" numberOfLines={2}>
{item.character
? t("show.staff-as", {
character: item.character.name,
})
: item.kind}
{subtitle}
</SubP>
</View>
{item.character && (
<Poster src={item.character.image} quality="low" className="w-28" />
{characterImage && (
<Poster src={characterImage} quality="low" className="w-28" />
)}
</View>
</Link>
);
};
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-row items-center overflow-hidden rounded-xl bg-card">
<Poster.Loader className="w-28" />
<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" />
<Poster.Loader className="w-28" />
</View>
);
@ -66,7 +75,21 @@ export const Staff = ({
</View>
)}
Empty={<EmptyView message={t("show.staff-none")} />}
Render={({ item }) => <CharacterCard item={item} />}
Render={({ item }) => (
<CharacterCard
href={`/staff/${item.staff.slug}`}
name={item.staff.name}
subtitle={
item.character
? t("show.staff-as", {
character: item.character.name,
})
: t(`show.staff-kind.${item.kind}`)
}
image={item.staff.image}
characterImage={item.character?.image}
/>
)}
Loader={() => <CharacterCard.Loader />}
getItemKey={(item) =>
`${item.staff.id}-${item.kind}-${item.character?.name ?? "none"}`

View File

@ -0,0 +1,81 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { RoleWithShow, Staff as StaffModel } from "~/models";
import { Container, H1, Head, Poster, Skeleton, SubP } from "~/primitives";
import { Fetch, InfiniteFetch, type QueryIdentifier } from "~/query";
import { useQueryState } from "~/utils";
import { CharacterCard } from "../details/staff";
import { EmptyView } from "../empty-view";
const StaffHeader = ({ slug }: { slug: string }) => {
return (
<Fetch
query={StaffPage.staffQuery(slug)}
Render={(staff) => (
<Container className="my-4 flex-row items-center gap-4 rounded-2xl bg-card p-4">
<Head title={staff.name} />
<Poster src={staff.image} quality="medium" className="w-32" />
<View className="flex-1">
<H1 className="text-3xl">{staff.name}</H1>
{staff.latinName && <SubP>{staff.latinName}</SubP>}
</View>
</Container>
)}
Loader={() => (
<Container className="my-4 flex-row items-center gap-4 rounded-2xl bg-card p-4">
<Skeleton className="h-24 w-24 rounded-xl" />
<View className="flex-1">
<Skeleton className="h-9 w-2/3" />
<Skeleton className="mt-2 h-5 w-1/2" />
</View>
</Container>
)}
/>
);
};
export const StaffPage = () => {
const { t } = useTranslation();
const [slug] = useQueryState<string>("slug", undefined!);
return (
<InfiniteFetch
query={StaffPage.rolesQuery(slug)}
layout={{
layout: "grid",
numColumns: { xs: 1, md: 2, lg: 3, xl: 4 },
gap: { xs: 8, lg: 12 },
size: 112,
}}
Header={<StaffHeader slug={slug} />}
Render={({ item }) => (
<CharacterCard
href={`/${item.show.kind}s/${item.show.slug}`}
name={item.show.name}
subtitle={
item.character
? t("show.staff-as", {
character: item.character.name,
})
: t(`show.staff-kind.${item.kind}`)
}
image={item.show.poster}
characterImage={item.character?.image}
/>
)}
Loader={() => <CharacterCard.Loader />}
Empty={<EmptyView message={t("show.staff-none")} className="py-8" />}
/>
);
};
StaffPage.staffQuery = (slug: string): QueryIdentifier<StaffModel> => ({
path: ["api", "staff", slug],
parser: StaffModel,
});
StaffPage.rolesQuery = (slug: string): QueryIdentifier<RoleWithShow> => ({
path: ["api", "staff", slug, "roles"],
parser: RoleWithShow,
infinite: true,
});