mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-03-29 04:42:11 -04:00
Implement staff display (#1397)
This commit is contained in:
commit
08529e0d55
@ -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);
|
||||
|
||||
@ -7,10 +7,14 @@ export const guessNextRefresh = (
|
||||
if (show.kind === "movie") {
|
||||
return fromAirDate(show.airDate ?? new Date());
|
||||
}
|
||||
const lastAirDate = show.entries
|
||||
const dates = show.entries
|
||||
.filter((x) => x.airDate)
|
||||
.map((x) => new Date(x.airDate!))
|
||||
.reduce((max, cur) => (cur > max ? cur : max));
|
||||
.map((x) => new Date(x.airDate!));
|
||||
const after = dates.filter((x) => x.getTime() > Date.now());
|
||||
const lastAirDate =
|
||||
after.length > 0
|
||||
? after.reduce((min, cur) => (cur < min ? cur : min))
|
||||
: dates.reduce((max, cur) => (cur > max ? cur : max));
|
||||
return fromAirDate(lastAirDate);
|
||||
};
|
||||
|
||||
|
||||
@ -53,7 +53,7 @@ export const dune: SeedMovie = {
|
||||
imdb: [
|
||||
{
|
||||
dataId: "tt1160419",
|
||||
link: "https://www.imdb.com/title/tt1160419",
|
||||
link: "https://thetvdb.com/people/353535-vincent-wong-ho-shun-王浩信",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -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" }),
|
||||
|
||||
@ -7,7 +7,7 @@ export const ExternalId = () =>
|
||||
t.Array(
|
||||
t.Object({
|
||||
dataId: t.String(),
|
||||
link: t.Nullable(t.String({ format: "uri" })),
|
||||
link: t.Nullable(t.String({ format: "url" })),
|
||||
label: t.Optional(t.Nullable(t.String())),
|
||||
}),
|
||||
),
|
||||
@ -29,7 +29,7 @@ export const EpisodeId = t.Record(
|
||||
}),
|
||||
),
|
||||
episode: t.Integer(),
|
||||
link: t.Nullable(t.String({ format: "uri" })),
|
||||
link: t.Nullable(t.String({ format: "url" })),
|
||||
label: t.Optional(t.Nullable(t.String())),
|
||||
}),
|
||||
),
|
||||
@ -42,7 +42,7 @@ export const MovieEpisodeId = t.Record(
|
||||
t.Union([
|
||||
t.Object({
|
||||
dataId: t.String(),
|
||||
link: t.Nullable(t.String({ format: "uri" })),
|
||||
link: t.Nullable(t.String({ format: "url" })),
|
||||
label: t.Optional(t.Nullable(t.String())),
|
||||
}),
|
||||
t.Object({
|
||||
@ -59,7 +59,7 @@ export const MovieEpisodeId = t.Record(
|
||||
}),
|
||||
),
|
||||
episode: t.Integer(),
|
||||
link: t.Nullable(t.String({ format: "uri" })),
|
||||
link: t.Nullable(t.String({ format: "url" })),
|
||||
label: t.Optional(t.Nullable(t.String())),
|
||||
}),
|
||||
]),
|
||||
@ -77,7 +77,7 @@ export const SeasonId = t.Record(
|
||||
`,
|
||||
}),
|
||||
season: t.Integer(),
|
||||
link: t.Nullable(t.String({ format: "uri" })),
|
||||
link: t.Nullable(t.String({ format: "url" })),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@ -2,9 +2,9 @@ import { t } from "elysia";
|
||||
|
||||
export const Image = t.Object({
|
||||
id: t.String(),
|
||||
source: t.String({ format: "uri" }),
|
||||
source: t.String({ format: "url" }),
|
||||
blurhash: t.String(),
|
||||
});
|
||||
export type Image = typeof Image.static;
|
||||
|
||||
export const SeedImage = t.String({ format: "uri" });
|
||||
export const SeedImage = t.String({ format: "url" });
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
38
front/src/models/staff.ts
Normal 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>;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
163
front/src/query/fetch-grid.tsx
Normal file
163
front/src/query/fetch-grid.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./fetch";
|
||||
export * from "./fetch-grid";
|
||||
export * from "./fetch-infinite";
|
||||
export * from "./query";
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
86
front/src/ui/details/staff.tsx
Normal file
86
front/src/ui/details/staff.tsx
Normal 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,
|
||||
});
|
||||
@ -36,6 +36,7 @@ class CompositeProvider(Provider):
|
||||
info = await self._tvdb.get_movie(MetadataId.map_dict(ret.external_id))
|
||||
if info is None:
|
||||
return ret
|
||||
ret.staff = info.staff
|
||||
if info.collection is not None:
|
||||
ret.collection = info.collection
|
||||
ret.external_id = MetadataId.merge(ret.external_id, info.external_id)
|
||||
@ -68,6 +69,7 @@ class CompositeProvider(Provider):
|
||||
)
|
||||
if info is None:
|
||||
return ret
|
||||
info.staff = ret.staff
|
||||
info.seasons = ret.seasons
|
||||
info.entries = ret.entries
|
||||
info.extras = ret.extras
|
||||
|
||||
@ -17,7 +17,8 @@ from ..models.metadataid import EpisodeId, MetadataId, SeasonId
|
||||
from ..models.movie import Movie, MovieStatus, MovieTranslation, SearchMovie
|
||||
from ..models.season import Season, SeasonTranslation
|
||||
from ..models.serie import SearchSerie, Serie, SerieStatus, SerieTranslation
|
||||
from ..models.staff import Role
|
||||
from ..models.staff import Character, Person, Role, Staff
|
||||
from ..utils import to_slug
|
||||
from .names import ProviderName
|
||||
from .provider import Provider, ProviderError
|
||||
|
||||
@ -313,11 +314,10 @@ class TVDB(Provider):
|
||||
if not skip_entries
|
||||
else [],
|
||||
entries=entries,
|
||||
# TODO: map extra entries in extra instead of entries
|
||||
extras=[],
|
||||
collection=await self._get_collection(ret),
|
||||
studios=[],
|
||||
staff=[],
|
||||
staff=self._parse_staff(ret),
|
||||
)
|
||||
|
||||
def _pick_image(
|
||||
@ -812,5 +812,39 @@ class TVDB(Provider):
|
||||
},
|
||||
collection=await self._get_collection(ret),
|
||||
studios=[],
|
||||
staff=[],
|
||||
staff=self._parse_staff(ret),
|
||||
)
|
||||
|
||||
def _parse_staff(self, show: dict[str, Any]) -> list[Staff]:
|
||||
if not "characters" in show or not show["characters"]:
|
||||
return []
|
||||
return [
|
||||
Staff(
|
||||
kind=self._roles_map.get(x["peopleType"], Role.OTHER),
|
||||
character=Character(
|
||||
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"],
|
||||
latin_name=None,
|
||||
image=img if (img := x["personImgURL"]) else None,
|
||||
external_id={
|
||||
self.name: [
|
||||
MetadataId(
|
||||
data_id=x["peopleId"],
|
||||
link=x["url"]
|
||||
if x["url"].startswith("http")
|
||||
else f"https://thetvdb.com/people/{x['url']}",
|
||||
),
|
||||
]
|
||||
},
|
||||
),
|
||||
)
|
||||
for x in show["characters"]
|
||||
if x["personName"]
|
||||
]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user