diff --git a/api/src/controllers/seed/insert/staff.ts b/api/src/controllers/seed/insert/staff.ts index 28e331fb..1e930d9a 100644 --- a/api/src/controllers/seed/insert/staff.ts +++ b/api/src/controllers/seed/insert/staff.ts @@ -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); diff --git a/api/src/controllers/seed/refresh.ts b/api/src/controllers/seed/refresh.ts index 19259e9b..4c6d02df 100644 --- a/api/src/controllers/seed/refresh.ts +++ b/api/src/controllers/seed/refresh.ts @@ -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); }; diff --git a/api/src/models/examples/dune-2021.ts b/api/src/models/examples/dune-2021.ts index 7bbeccf8..65d87cd8 100644 --- a/api/src/models/examples/dune-2021.ts +++ b/api/src/models/examples/dune-2021.ts @@ -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-王浩信", }, ], }, diff --git a/api/src/models/staff.ts b/api/src/models/staff.ts index c938c810..5121ea6c 100644 --- a/api/src/models/staff.ts +++ b/api/src/models/staff.ts @@ -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" }), diff --git a/api/src/models/utils/external-id.ts b/api/src/models/utils/external-id.ts index 2e98dfc5..f44f618f 100644 --- a/api/src/models/utils/external-id.ts +++ b/api/src/models/utils/external-id.ts @@ -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" })), }), ), ); diff --git a/api/src/models/utils/image.ts b/api/src/models/utils/image.ts index 6d79a4c3..20c5b2ca 100644 --- a/api/src/models/utils/image.ts +++ b/api/src/models/utils/image.ts @@ -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" }); diff --git a/front/public/translations/en.json b/front/public/translations/en.json index 7167f49d..bece652d 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -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", diff --git a/front/src/models/index.ts b/front/src/models/index.ts index 8b3ec060..1a6c9713 100644 --- a/front/src/models/index.ts +++ b/front/src/models/index.ts @@ -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"; diff --git a/front/src/models/staff.ts b/front/src/models/staff.ts new file mode 100644 index 00000000..22be5dac --- /dev/null +++ b/front/src/models/staff.ts @@ -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; + +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; + +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; diff --git a/front/src/primitives/icons.tsx b/front/src/primitives/icons.tsx index 861b2ec8..f86be613 100644 --- a/front/src/primitives/icons.tsx +++ b/front/src/primitives/icons.tsx @@ -71,12 +71,14 @@ export const IconButton = ({ as, className, iconClassName, + disabled, ...asProps }: { as?: ComponentType; icon: Icon; iconClassName?: string; className?: string; + disabled?: boolean; } & AsProps) => { const Container = as ?? PressableFeedback; @@ -90,7 +92,13 @@ export const IconButton = ({ )} {...(asProps as AsProps)} > - + ); }; diff --git a/front/src/query/fetch-grid.tsx b/front/src/query/fetch-grid.tsx new file mode 100644 index 00000000..8cc0c6dc --- /dev/null +++ b/front/src/query/fetch-grid.tsx @@ -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; + numLines: Breakpoint; + gap: Breakpoint; +}; + +export const InfiniteGrid = ({ + query, + layout, + Render, + Loader, + Empty, + Header, + Footer, + getItemKey, +}: { + query: QueryIdentifier; + 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 = ( + + setPageIndex((x) => x - 1)} + disabled={pageIndex <= 0} + {...tooltip(t("misc.prev-page"))} + /> + { + 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"))} + /> + + ); + + const header = + typeof Header === "function" ? ( +
+ ) : ( + (Header ?? controls) + ); + + const footer = typeof Footer === "function" ?