Add next up in series detail page

This commit is contained in:
Zoe Roux 2025-07-14 23:34:10 +02:00
parent 666e4e2e6b
commit 5ed43c85de
10 changed files with 54 additions and 24 deletions

View File

@ -15,7 +15,7 @@ import { BaseEntry, EntryTranslation } from "./base-entry";
export const BaseEpisode = t.Composite([
t.Object({
kind: t.Literal("episode"),
order: t.Number({ minimum: 1, description: "Absolute playback order." }),
order: t.Number({ description: "Absolute playback order." }),
seasonNumber: t.Integer(),
episodeNumber: t.Integer(),
externalId: EpisodeId,

View File

@ -18,7 +18,6 @@ export const BaseMovieEntry = t.Composite(
t.Object({
kind: t.Literal("movie"),
order: t.Number({
minimum: 1,
description: "Absolute playback order. Can be mixed with episodes.",
}),
externalId: ExternalId(),

View File

@ -17,10 +17,9 @@ export const BaseSpecial = t.Composite(
t.Object({
kind: t.Literal("special"),
order: t.Number({
minimum: 1,
description: "Absolute playback order. Can be mixed with episodes.",
}),
number: t.Integer({ minimum: 1 }),
number: t.Integer(),
externalId: EpisodeId,
}),
BaseEntry(),

View File

@ -47,7 +47,7 @@ export const EntryLine = ({
airDate: Date | null;
runtime: number | null;
watchedPercent: number | null;
href: string;
href: string | null;
} & PressableProps) => {
const [moreOpened, setMoreOpened] = useState(false);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);

View File

@ -8,7 +8,7 @@ export const Season = z.object({
seasonNumber: z.int().gte(0),
name: z.string().nullable(),
description: z.string().nullable(),
entryCount: z.int().gte(0),
entriesCount: z.int().gte(0),
availableCount: z.int().gte(0),
startAir: zdate().nullable(),
endAir: zdate().nullable(),

View File

@ -12,7 +12,7 @@ export type Layout = {
layout: "grid" | "horizontal" | "vertical";
};
export const InfiniteFetch = <Data, Props>({
export const InfiniteFetch = <Data,>({
query,
placeholderCount = 2,
incremental = false,
@ -34,7 +34,7 @@ export const InfiniteFetch = <Data, Props>({
Empty?: JSX.Element;
incremental?: boolean;
divider?: true | ComponentType;
Header?: ComponentType<Props & { children: JSX.Element }> | ReactElement;
Header?: ComponentType<{ children: JSX.Element }> | ReactElement;
fetchMore?: boolean;
}): JSX.Element | null => {
const { numColumns, size, gap } = useBreakpointMap(layout);

View File

@ -9,8 +9,8 @@ export const Fetch = <Data,>({
Loader,
}: {
query: QueryIdentifier<Data>;
Render: (item: Data) => ReactElement;
Loader: () => ReactElement;
Render: (item: Data) => ReactElement | null;
Loader: () => ReactElement | null;
}): JSX.Element | null => {
const { data, isPaused, error } = useFetch(query);
const [setError] = useSetError("fetch");

View File

@ -826,6 +826,6 @@ Header.query = (
parser: Show,
path: ["api", `${kind}s`, slug],
params: {
with: ["studios"],
with: ["studios", ...(kind === "serie" ? ["firstEntry", "nextEntry"] : [])],
},
});

View File

@ -67,7 +67,7 @@ export const SeasonHeader = ({
key={x.seasonNumber}
label={`${x.seasonNumber}: ${
x.name ?? t("show.season", { number: x.seasonNumber })
} (${x.entryCount})`}
} (${x.entriesCount})`}
href={`/series/${serieSlug}?season=${x.seasonNumber}`}
/>
))}
@ -115,8 +115,8 @@ SeasonHeader.query = (slug: string): QueryIdentifier<Season> => ({
parser: Season,
path: ["api", "series", slug, "seasons"],
params: {
// Fetch all seasons at one, there won't be hundred of them anyways.
limit: 0,
// I don't wanna deal with pagination, no serie has more than 100 seasons anyways, right?
limit: 100,
},
infinite: true,
});
@ -128,7 +128,7 @@ export const EntryList = ({
}: {
slug: string;
season: string | number;
} & Partial<ComponentProps<typeof InfiniteFetch>>) => {
} & Partial<ComponentProps<typeof InfiniteFetch<Entry>>>) => {
const { t } = useTranslation();
const { items: seasons, error } = useInfiniteFetch(SeasonHeader.query(slug));

View File

@ -4,7 +4,9 @@ import { Platform, View } from "react-native";
import Svg, { Path, type SvgProps } from "react-native-svg";
import { percent, useYoshiki } from "yoshiki/native";
import { EntryLine, entryDisplayNumber } from "~/components/entries";
import type { Entry, Serie } from "~/models";
import { Container, focusReset, H2, SwitchVariant, ts } from "~/primitives";
import { Fetch } from "~/query";
import { useQueryState } from "~/utils";
import { Header } from "./header";
import { EntryList } from "./season";
@ -29,15 +31,10 @@ export const SvgWave = (props: SvgProps) => {
);
};
export const ShowWatchStatusCard = ({
watchedPercent,
nextEpisode,
}: ShowWatchStatus) => {
export const NextUp = (nextEntry: Entry) => {
const { t } = useTranslation();
const [focused, setFocus] = useState(false);
if (!nextEpisode) return null;
return (
<SwitchVariant>
{({ css }) => (
@ -60,10 +57,10 @@ export const ShowWatchStatusCard = ({
>
<H2 {...css({ marginLeft: ts(2) })}>{t("show.nextUp")}</H2>
<EntryLine
{...nextEpisode}
{...nextEntry}
serieSlug={null}
watchedPercent={watchedPercent || null}
displayNumber={entryDisplayNumber(nextEpisode)}
watchedPercent={nextEntry.progress.percent}
displayNumber={entryDisplayNumber(nextEntry)}
onHoverIn={() => setFocus(true)}
onHoverOut={() => setFocus(false)}
onFocus={() => setFocus(true)}
@ -75,6 +72,31 @@ export const ShowWatchStatusCard = ({
);
};
NextUp.Loader = () => {
const { t } = useTranslation();
return (
<SwitchVariant>
{({ css }) => (
<Container
{...css({
marginY: ts(2),
borderRadius: 16,
overflow: "hidden",
borderWidth: ts(0.5),
borderStyle: "solid",
borderColor: (theme) => theme.background,
backgroundColor: (theme) => theme.background,
})}
>
<H2 {...css({ marginLeft: ts(2) })}>{t("show.nextUp")}</H2>
<EntryLine.Loader />
</Container>
)}
</SwitchVariant>
);
};
const SerieHeader = ({ children, ...props }: any) => {
const { css, theme } = useYoshiki();
const [slug] = useQueryState("slug", undefined!);
@ -95,6 +117,16 @@ const SerieHeader = ({ children, ...props }: any) => {
)}
>
<Header kind="serie" slug={slug} />
<Fetch
// Use the same fetch query as header
query={Header.query("serie", slug)}
Render={(serie) => {
const nextEntry = (serie as Serie).nextEntry;
if (!nextEntry) return null;
return <NextUp {...nextEntry} />;
}}
Loader={NextUp.Loader}
/>
{/* <DetailsCollections type="serie" slug={slug} /> */}
{/* <Staff slug={slug} /> */}
<SvgWave