Make series page

This commit is contained in:
Zoe Roux 2025-07-14 22:59:55 +02:00
parent d5c7ee40bc
commit 42cce837e4
11 changed files with 138 additions and 180 deletions

View File

@ -0,0 +1,3 @@
import { SerieDetails } from "~/ui/details";
export default SerieDetails;

View File

@ -6,7 +6,7 @@ import { Platform, type PressableProps, View } from "react-native";
import { percent, type Stylable, useYoshiki } from "yoshiki/native"; import { percent, type Stylable, useYoshiki } from "yoshiki/native";
import { EntryContext } from "~/components/items/context-menus"; import { EntryContext } from "~/components/items/context-menus";
import { ItemProgress } from "~/components/items/item-grid"; import { ItemProgress } from "~/components/items/item-grid";
import type { KImage, WatchStatusV } from "~/models"; import type { KImage } from "~/models";
import { import {
focusReset, focusReset,
H6, H6,
@ -34,7 +34,6 @@ export const EntryLine = ({
airDate, airDate,
runtime, runtime,
watchedPercent, watchedPercent,
watchedStatus,
href, href,
...props ...props
}: { }: {
@ -48,7 +47,6 @@ export const EntryLine = ({
airDate: Date | null; airDate: Date | null;
runtime: number | null; runtime: number | null;
watchedPercent: number | null; watchedPercent: number | null;
watchedStatus: WatchStatusV | null;
href: string; href: string;
} & PressableProps) => { } & PressableProps) => {
const [moreOpened, setMoreOpened] = useState(false); const [moreOpened, setMoreOpened] = useState(false);
@ -92,7 +90,7 @@ export const EntryLine = ({
}} }}
{...(css({ flexShrink: 0, m: ts(1), borderRadius: 6 }) as any)} {...(css({ flexShrink: 0, m: ts(1), borderRadius: 6 }) as any)}
> >
{(watchedPercent || watchedStatus === "completed") && ( {watchedPercent && (
<ItemProgress watchPercent={watchedPercent ?? 100} /> <ItemProgress watchPercent={watchedPercent ?? 100} />
)} )}
</ImageBackground> </ImageBackground>
@ -124,7 +122,6 @@ export const EntryLine = ({
<EntryContext <EntryContext
slug={slug} slug={slug}
serieSlug={serieSlug} serieSlug={serieSlug}
status={watchedStatus}
isOpen={moreOpened} isOpen={moreOpened}
setOpen={(v) => setMoreOpened(v)} setOpen={(v) => setMoreOpened(v)}
{...css([ {...css([

View File

@ -1,16 +1,17 @@
export * from "./entry-box"; import type { Entry } from "~/models";
export * from "./entry-list";
export const episodeDisplayNumber = (episode: { export * from "./entry-box";
seasonNumber?: number | null; export * from "./entry-line";
episodeNumber?: number | null;
absoluteNumber?: number | null; export const entryDisplayNumber = (entry: Entry) => {
}) => { switch (entry.kind) {
if ( case "episode":
typeof episode.seasonNumber === "number" && return `S${entry.seasonNumber}:E${entry.episodeNumber}`;
typeof episode.episodeNumber === "number" case "special":
) return `SP${entry.number}`
return `S${episode.seasonNumber}:E${episode.episodeNumber}`; case "movie":
if (episode.absoluteNumber) return episode.absoluteNumber.toString(); return "";
default:
return "??"; return "??";
}
}; };

View File

@ -15,14 +15,54 @@ import { watchListIcon } from "./watchlist-info";
// import { useDownloader } from "../../packages/ui/src/downloadses/ui/src/downloads"; // import { useDownloader } from "../../packages/ui/src/downloadses/ui/src/downloads";
export const EntryContext = ({ export const EntryContext = ({
kind = "entry",
slug, slug,
serieSlug, serieSlug,
...props
}: {
serieSlug: string | null;
slug: string;
} & Partial<ComponentProps<typeof Menu<typeof IconButton>>>) => {
// const downloader = useDownloader();
const { css } = useYoshiki();
const { t } = useTranslation();
return (
<>
<Menu
Trigger={IconButton}
icon={MoreVert}
{...tooltip(t("misc.more"))}
{...(css([Platform.OS !== "web" && { display: "none" }], props) as any)}
>
{serieSlug && (
<Menu.Item
label={t("home.episodeMore.goToShow")}
icon={Info}
href={`/series/${serieSlug}`}
/>
)}
{/* <Menu.Item */}
{/* label={t("home.episodeMore.download")} */}
{/* icon={Download} */}
{/* onSelect={() => downloader(type, slug)} */}
{/* /> */}
<Menu.Item
label={t("home.episodeMore.mediainfo")}
icon={MovieInfo}
href={`/entries/${slug}/info`}
/>
</Menu>
</>
);
};
export const ItemContext = ({
kind,
slug,
status, status,
...props ...props
}: { }: {
kind?: "serie" | "movie" | "entry"; kind: "movie" | "serie";
serieSlug?: string | null;
slug: string; slug: string;
status: WatchStatusV | null; status: WatchStatusV | null;
} & Partial<ComponentProps<typeof Menu<typeof IconButton>>>) => { } & Partial<ComponentProps<typeof Menu<typeof IconButton>>>) => {
@ -32,10 +72,7 @@ export const EntryContext = ({
const { t } = useTranslation(); const { t } = useTranslation();
const mutation = useMutation({ const mutation = useMutation({
path: path: [kind, slug, "watchStatus"],
kind === "entry"
? ["serie", serieSlug!, "entries", slug]
: [kind, slug, "watchStatus"],
compute: (newStatus: WatchStatusV | null) => ({ compute: (newStatus: WatchStatusV | null) => ({
method: newStatus ? "POST" : "DELETE", method: newStatus ? "POST" : "DELETE",
params: newStatus ? { status: newStatus } : undefined, params: newStatus ? { status: newStatus } : undefined,
@ -55,18 +92,8 @@ export const EntryContext = ({
Trigger={IconButton} Trigger={IconButton}
icon={MoreVert} icon={MoreVert}
{...tooltip(t("misc.more"))} {...tooltip(t("misc.more"))}
{...(css( {...(css([Platform.OS !== "web" && { display: "none" }], props) as any)}
[Platform.OS !== "web" && { display: "none" }],
props,
) as any)}
> >
{serieSlug && (
<Menu.Item
label={t("home.episodeMore.goToShow")}
icon={Info}
href={`/serie/${serieSlug}`}
/>
)}
<Menu.Sub <Menu.Sub
label={account ? t("show.watchlistEdit") : t("show.watchlistLogin")} label={account ? t("show.watchlistEdit") : t("show.watchlistLogin")}
disabled={!account} disabled={!account}
@ -89,7 +116,7 @@ export const EntryContext = ({
/> />
)} )}
</Menu.Sub> </Menu.Sub>
{kind !== "serie" && ( {kind === "movie" && (
<> <>
{/* <Menu.Item */} {/* <Menu.Item */}
{/* label={t("home.episodeMore.download")} */} {/* label={t("home.episodeMore.download")} */}
@ -99,7 +126,7 @@ export const EntryContext = ({
<Menu.Item <Menu.Item
label={t("home.episodeMore.mediainfo")} label={t("home.episodeMore.mediainfo")}
icon={MovieInfo} icon={MovieInfo}
href={`/${kind}/${slug}/info`} href={`/movies/${slug}/info`}
/> />
</> </>
)} )}
@ -117,24 +144,3 @@ export const EntryContext = ({
</> </>
); );
}; };
export const ItemContext = ({
kind,
slug,
status,
...props
}: {
kind: "movie" | "serie";
slug: string;
status: WatchStatusV | null;
} & Partial<ComponentProps<typeof Menu<typeof IconButton>>>) => {
return (
<EntryContext
kind={kind}
slug={slug}
status={status}
serieSlug={null}
{...props}
/>
);
};

View File

@ -74,9 +74,11 @@ export const Special = Base.extend({
}); });
export type Special = z.infer<typeof Special>; export type Special = z.infer<typeof Special>;
export const Entry = z.discriminatedUnion("kind", [ export const Entry = z
Episode, .discriminatedUnion("kind", [Episode, MovieEntry, Special])
MovieEntry, .transform((x) => ({
Special, ...x,
]); // TODO: don't just pick the first video, be smart about it
href: x.videos.length ? `/watch/${x.videos[0].slug}` : null,
}));
export type Entry = z.infer<typeof Entry>; export type Entry = z.infer<typeof Entry>;

View File

@ -2,6 +2,5 @@ export * from "./breakpoint";
export * from "./capitalize"; export * from "./capitalize";
export * from "./head"; export * from "./head";
export * from "./nojs"; export * from "./nojs";
export * from "./page-style";
export * from "./spacing"; export * from "./spacing";
export * from "./touchonly"; export * from "./touchonly";

View File

@ -1,6 +0,0 @@
import { useSafeAreaInsets } from "react-native-safe-area-context";
export const usePageStyle = () => {
const insets = useSafeAreaInsets();
return { paddingBottom: insets.bottom } as const;
};

View File

@ -1,3 +0,0 @@
export const usePageStyle = () => {
return {} as const;
};

View File

@ -12,7 +12,7 @@ export type Layout = {
layout: "grid" | "horizontal" | "vertical"; layout: "grid" | "horizontal" | "vertical";
}; };
export const InfiniteFetch = <Data, Props, _, Kind extends number | string>({ export const InfiniteFetch = <Data, Props>({
query, query,
placeholderCount = 2, placeholderCount = 2,
incremental = false, incremental = false,
@ -22,11 +22,7 @@ export const InfiniteFetch = <Data, Props, _, Kind extends number | string>({
Empty, Empty,
divider, divider,
Header, Header,
headerProps,
getItemType,
getItemSize,
fetchMore = true, fetchMore = true,
nested = false,
...props ...props
}: { }: {
query: QueryIdentifier<Data>; query: QueryIdentifier<Data>;
@ -39,11 +35,7 @@ export const InfiniteFetch = <Data, Props, _, Kind extends number | string>({
incremental?: boolean; incremental?: boolean;
divider?: true | ComponentType; divider?: true | ComponentType;
Header?: ComponentType<Props & { children: JSX.Element }> | ReactElement; Header?: ComponentType<Props & { children: JSX.Element }> | ReactElement;
headerProps?: Props;
getItemType?: (item: Data | null, index: number) => Kind;
getItemSize?: (kind: Kind) => number;
fetchMore?: boolean; fetchMore?: boolean;
nested?: boolean;
}): JSX.Element | null => { }): JSX.Element | null => {
const { numColumns, size, gap } = useBreakpointMap(layout); const { numColumns, size, gap } = useBreakpointMap(layout);
const [setOffline, clearOffline] = useSetError("offline"); const [setOffline, clearOffline] = useSetError("offline");

View File

@ -1,9 +1,10 @@
import MenuIcon from "@material-symbols/svg-400/rounded/menu-fill.svg"; import MenuIcon from "@material-symbols/svg-400/rounded/menu-fill.svg";
import type { ComponentType } from "react"; import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { rem, useYoshiki } from "yoshiki/native"; import { rem, useYoshiki } from "yoshiki/native";
import { type Episode, type Season, useInfiniteFetch } from "~/models"; import { EntryLine, entryDisplayNumber } from "~/components/entries";
import { Entry, Season } from "~/models";
import { import {
H2, H2,
HR, HR,
@ -13,22 +14,21 @@ import {
Skeleton, Skeleton,
tooltip, tooltip,
ts, ts,
usePageStyle,
} from "~/primitives"; } from "~/primitives";
import type { QueryIdentifier } from "~/query"; import { type QueryIdentifier, useInfiniteFetch } from "~/query";
import { InfiniteFetch } from "~/query/fetch-infinite"; import { InfiniteFetch } from "~/query/fetch-infinite";
import { EpisodeLine, episodeDisplayNumber } from "./episode"; import { EmptyView } from "~/ui/errors";
type SeasonProcessed = Season & { href: string };
export const SeasonHeader = ({ export const SeasonHeader = ({
serieSlug,
seasonNumber, seasonNumber,
name, name,
seasons, seasons,
}: { }: {
serieSlug: string;
seasonNumber: number; seasonNumber: number;
name: string | null; name: string | null;
seasons?: SeasonProcessed[]; seasons: Season[];
}) => { }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
@ -62,15 +62,13 @@ export const SeasonHeader = ({
icon={MenuIcon} icon={MenuIcon}
{...tooltip(t("show.jumpToSeason"))} {...tooltip(t("show.jumpToSeason"))}
> >
{seasons {seasons.map((x) => (
?.filter((x) => x.episodesCount > 0)
.map((x) => (
<Menu.Item <Menu.Item
key={x.seasonNumber} key={x.seasonNumber}
label={`${x.seasonNumber}: ${ label={`${x.seasonNumber}: ${
x.name ?? t("show.season", { number: x.seasonNumber }) x.name ?? t("show.season", { number: x.seasonNumber })
} (${x.episodesCount})`} } (${x.entryCount})`}
href={x.href} href={`/series/${serieSlug}?season=${x.seasonNumber}`}
/> />
))} ))}
</Menu> </Menu>
@ -115,33 +113,22 @@ SeasonHeader.Loader = () => {
SeasonHeader.query = (slug: string): QueryIdentifier<Season> => ({ SeasonHeader.query = (slug: string): QueryIdentifier<Season> => ({
parser: Season, parser: Season,
path: ["series", slug, "seasons"], path: ["api", "series", slug, "seasons"],
params: { params: {
// Fetch all seasons at one, there won't be hundred of thems anyways. // Fetch all seasons at one, there won't be hundred of them anyways.
limit: 0, limit: 0,
}, },
infinite: { infinite: true,
value: true,
map: (seasons) =>
seasons.map((x) => ({
...x,
href: `/show/${slug}?season=${x.seasonNumber}`,
})),
},
}); });
export const EpisodeList = <Props,>({ export const EntryList = ({
slug, slug,
season, season,
Header, ...props
headerProps,
}: { }: {
slug: string; slug: string;
season: string | number; season: string | number;
Header: ComponentType<Props & { children: JSX.Element }>; } & Partial<ComponentProps<typeof InfiniteFetch>>) => {
headerProps: Props;
}) => {
const pageStyle = usePageStyle();
const { t } = useTranslation(); const { t } = useTranslation();
const { items: seasons, error } = useInfiniteFetch(SeasonHeader.query(slug)); const { items: seasons, error } = useInfiniteFetch(SeasonHeader.query(slug));
@ -149,40 +136,35 @@ export const EpisodeList = <Props,>({
return ( return (
<InfiniteFetch <InfiniteFetch
query={EpisodeList.query(slug, season)} query={EntryList.query(slug, season)}
layout={EpisodeLine.layout} layout={EntryLine.layout}
empty={t("show.episode-none")} Empty={<EmptyView message={t("show.episode-none")} />}
divider divider
Header={Header} // getItemType={(item) =>
headerProps={headerProps} // item.kind === "episode" && item.episodeNumber === 1? "withHeader" : "normal"
getItemType={(item) => // }
!item || item.firstOfSeason ? "withHeader" : "normal"
}
contentContainerStyle={pageStyle}
placeholderCount={5} placeholderCount={5}
Render={({ item }) => { Render={({ item }) => {
const sea = item?.firstOfSeason const sea =
item.kind === "episode" && item.episodeNumber === 1
? seasons?.find((x) => x.seasonNumber === item.seasonNumber) ? seasons?.find((x) => x.seasonNumber === item.seasonNumber)
: null; : null;
return ( return (
<> <>
{item.firstOfSeason && {sea && (
(sea ? (
<SeasonHeader <SeasonHeader
serieSlug={slug}
name={sea.name} name={sea.name}
seasonNumber={sea.seasonNumber} seasonNumber={sea.seasonNumber}
seasons={seasons} seasons={seasons ?? []}
/> />
) : ( )}
<SeasonHeader.Loader /> <EntryLine
))}
<EpisodeLine
{...item} {...item}
// Don't display "Go to show" // Don't display "Go to serie"
showSlug={null} serieSlug={null}
displayNumber={episodeDisplayNumber(item)} displayNumber={entryDisplayNumber(item)}
watchedPercent={item.watchStatus?.watchedPercent ?? null} watchedPercent={item.progress.percent}
watchedStatus={item.watchStatus?.status ?? null}
/> />
</> </>
); );
@ -190,32 +172,22 @@ export const EpisodeList = <Props,>({
Loader={({ index }) => ( Loader={({ index }) => (
<> <>
{index === 0 && <SeasonHeader.Loader />} {index === 0 && <SeasonHeader.Loader />}
<EpisodeLine.Loader /> <EntryLine.Loader />
</> </>
)} )}
{...props}
/> />
); );
}; };
EpisodeList.query = ( EntryList.query = (
slug: string, slug: string,
season: string | number, season: string | number,
): QueryIdentifier<Episode, Episode & { firstOfSeason?: boolean }> => ({ ): QueryIdentifier<Entry> => ({
parser: EpisodeP, parser: Entry,
path: ["show", slug, "episode"], path: ["api", "series", slug, "entries"],
params: { params: {
filter: season ? `seasonNumber gte ${season}` : undefined, filter: season ? `seasonNumber gte ${season}` : undefined,
fields: ["watchStatus"],
},
infinite: {
value: true,
map: (episodes) => {
let currentSeason: number | null = null;
return episodes.map((x) => {
if (x.seasonNumber === currentSeason) return x;
currentSeason = x.seasonNumber;
return { ...x, firstOfSeason: true };
});
},
}, },
infinite: true,
}); });

View File

@ -3,11 +3,11 @@ import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import Svg, { Path, type SvgProps } from "react-native-svg"; import Svg, { Path, type SvgProps } from "react-native-svg";
import { percent, useYoshiki } from "yoshiki/native"; import { percent, useYoshiki } from "yoshiki/native";
import { EntryLine, entryDisplayNumber } from "~/components/entries";
import { Container, focusReset, H2, SwitchVariant, ts } from "~/primitives"; import { Container, focusReset, H2, SwitchVariant, ts } from "~/primitives";
import { useQueryState } from "~/utils"; import { useQueryState } from "~/utils";
import { EpisodeLine, episodeDisplayNumber } from "./episode";
import { Header } from "./header"; import { Header } from "./header";
import { EpisodeList } from "./season"; import { EntryList } from "./season";
export const SvgWave = (props: SvgProps) => { export const SvgWave = (props: SvgProps) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
@ -31,7 +31,6 @@ export const SvgWave = (props: SvgProps) => {
export const ShowWatchStatusCard = ({ export const ShowWatchStatusCard = ({
watchedPercent, watchedPercent,
status,
nextEpisode, nextEpisode,
}: ShowWatchStatus) => { }: ShowWatchStatus) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -60,12 +59,11 @@ export const ShowWatchStatusCard = ({
])} ])}
> >
<H2 {...css({ marginLeft: ts(2) })}>{t("show.nextUp")}</H2> <H2 {...css({ marginLeft: ts(2) })}>{t("show.nextUp")}</H2>
<EpisodeLine <EntryLine
{...nextEpisode} {...nextEpisode}
showSlug={null} serieSlug={null}
watchedPercent={watchedPercent || null} watchedPercent={watchedPercent || null}
watchedStatus={status || null} displayNumber={entryDisplayNumber(nextEpisode)}
displayNumber={episodeDisplayNumber(nextEpisode)}
onHoverIn={() => setFocus(true)} onHoverIn={() => setFocus(true)}
onHoverOut={() => setFocus(false)} onHoverOut={() => setFocus(false)}
onFocus={() => setFocus(true)} onFocus={() => setFocus(true)}
@ -77,8 +75,9 @@ export const ShowWatchStatusCard = ({
); );
}; };
const ShowHeader = ({ children, slug, ...props }: any) => { const SerieHeader = ({ children, ...props }: any) => {
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
const [slug] = useQueryState("slug", undefined!);
return ( return (
<View <View
@ -109,18 +108,14 @@ const ShowHeader = ({ children, slug, ...props }: any) => {
); );
}; };
export const ShowDetails = () => { export const SerieDetails = () => {
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
const [slug] = useQueryState("slug", undefined!); const [slug] = useQueryState("slug", undefined!);
const [season] = useQueryState("season", undefined!);
return ( return (
<View {...css({ bg: theme.variant.background, flex: 1 })}> <View {...css({ bg: theme.variant.background, flex: 1 })}>
<EpisodeList <EntryList slug={slug} season={season} Header={SerieHeader} />
slug={slug}
season={season}
Header={ShowHeader}
headerProps={{ slug }}
/>
</View> </View>
); );
}; };