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

View File

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

View File

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

View File

@ -2,6 +2,5 @@ export * from "./breakpoint";
export * from "./capitalize";
export * from "./head";
export * from "./nojs";
export * from "./page-style";
export * from "./spacing";
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";
};
export const InfiniteFetch = <Data, Props, _, Kind extends number | string>({
export const InfiniteFetch = <Data, Props>({
query,
placeholderCount = 2,
incremental = false,
@ -22,11 +22,7 @@ export const InfiniteFetch = <Data, Props, _, Kind extends number | string>({
Empty,
divider,
Header,
headerProps,
getItemType,
getItemSize,
fetchMore = true,
nested = false,
...props
}: {
query: QueryIdentifier<Data>;
@ -39,11 +35,7 @@ export const InfiniteFetch = <Data, Props, _, Kind extends number | string>({
incremental?: boolean;
divider?: true | ComponentType;
Header?: ComponentType<Props & { children: JSX.Element }> | ReactElement;
headerProps?: Props;
getItemType?: (item: Data | null, index: number) => Kind;
getItemSize?: (kind: Kind) => number;
fetchMore?: boolean;
nested?: boolean;
}): JSX.Element | null => {
const { numColumns, size, gap } = useBreakpointMap(layout);
const [setOffline, clearOffline] = useSetError("offline");

View File

@ -1,9 +1,10 @@
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 { View } from "react-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 {
H2,
HR,
@ -13,22 +14,21 @@ import {
Skeleton,
tooltip,
ts,
usePageStyle,
} from "~/primitives";
import type { QueryIdentifier } from "~/query";
import { type QueryIdentifier, useInfiniteFetch } from "~/query";
import { InfiniteFetch } from "~/query/fetch-infinite";
import { EpisodeLine, episodeDisplayNumber } from "./episode";
type SeasonProcessed = Season & { href: string };
import { EmptyView } from "~/ui/errors";
export const SeasonHeader = ({
serieSlug,
seasonNumber,
name,
seasons,
}: {
serieSlug: string;
seasonNumber: number;
name: string | null;
seasons?: SeasonProcessed[];
seasons: Season[];
}) => {
const { css } = useYoshiki();
const { t } = useTranslation();
@ -62,17 +62,15 @@ export const SeasonHeader = ({
icon={MenuIcon}
{...tooltip(t("show.jumpToSeason"))}
>
{seasons
?.filter((x) => x.episodesCount > 0)
.map((x) => (
<Menu.Item
key={x.seasonNumber}
label={`${x.seasonNumber}: ${
x.name ?? t("show.season", { number: x.seasonNumber })
} (${x.episodesCount})`}
href={x.href}
/>
))}
{seasons.map((x) => (
<Menu.Item
key={x.seasonNumber}
label={`${x.seasonNumber}: ${
x.name ?? t("show.season", { number: x.seasonNumber })
} (${x.entryCount})`}
href={`/series/${serieSlug}?season=${x.seasonNumber}`}
/>
))}
</Menu>
</View>
<HR />
@ -115,33 +113,22 @@ SeasonHeader.Loader = () => {
SeasonHeader.query = (slug: string): QueryIdentifier<Season> => ({
parser: Season,
path: ["series", slug, "seasons"],
path: ["api", "series", slug, "seasons"],
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,
},
infinite: {
value: true,
map: (seasons) =>
seasons.map((x) => ({
...x,
href: `/show/${slug}?season=${x.seasonNumber}`,
})),
},
infinite: true,
});
export const EpisodeList = <Props,>({
export const EntryList = ({
slug,
season,
Header,
headerProps,
...props
}: {
slug: string;
season: string | number;
Header: ComponentType<Props & { children: JSX.Element }>;
headerProps: Props;
}) => {
const pageStyle = usePageStyle();
} & Partial<ComponentProps<typeof InfiniteFetch>>) => {
const { t } = useTranslation();
const { items: seasons, error } = useInfiniteFetch(SeasonHeader.query(slug));
@ -149,40 +136,35 @@ export const EpisodeList = <Props,>({
return (
<InfiniteFetch
query={EpisodeList.query(slug, season)}
layout={EpisodeLine.layout}
empty={t("show.episode-none")}
query={EntryList.query(slug, season)}
layout={EntryLine.layout}
Empty={<EmptyView message={t("show.episode-none")} />}
divider
Header={Header}
headerProps={headerProps}
getItemType={(item) =>
!item || item.firstOfSeason ? "withHeader" : "normal"
}
contentContainerStyle={pageStyle}
// getItemType={(item) =>
// item.kind === "episode" && item.episodeNumber === 1? "withHeader" : "normal"
// }
placeholderCount={5}
Render={({ item }) => {
const sea = item?.firstOfSeason
? seasons?.find((x) => x.seasonNumber === item.seasonNumber)
: null;
const sea =
item.kind === "episode" && item.episodeNumber === 1
? seasons?.find((x) => x.seasonNumber === item.seasonNumber)
: null;
return (
<>
{item.firstOfSeason &&
(sea ? (
<SeasonHeader
name={sea.name}
seasonNumber={sea.seasonNumber}
seasons={seasons}
/>
) : (
<SeasonHeader.Loader />
))}
<EpisodeLine
{sea && (
<SeasonHeader
serieSlug={slug}
name={sea.name}
seasonNumber={sea.seasonNumber}
seasons={seasons ?? []}
/>
)}
<EntryLine
{...item}
// Don't display "Go to show"
showSlug={null}
displayNumber={episodeDisplayNumber(item)}
watchedPercent={item.watchStatus?.watchedPercent ?? null}
watchedStatus={item.watchStatus?.status ?? null}
// Don't display "Go to serie"
serieSlug={null}
displayNumber={entryDisplayNumber(item)}
watchedPercent={item.progress.percent}
/>
</>
);
@ -190,32 +172,22 @@ export const EpisodeList = <Props,>({
Loader={({ index }) => (
<>
{index === 0 && <SeasonHeader.Loader />}
<EpisodeLine.Loader />
<EntryLine.Loader />
</>
)}
{...props}
/>
);
};
EpisodeList.query = (
EntryList.query = (
slug: string,
season: string | number,
): QueryIdentifier<Episode, Episode & { firstOfSeason?: boolean }> => ({
parser: EpisodeP,
path: ["show", slug, "episode"],
): QueryIdentifier<Entry> => ({
parser: Entry,
path: ["api", "series", slug, "entries"],
params: {
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 Svg, { Path, type SvgProps } from "react-native-svg";
import { percent, useYoshiki } from "yoshiki/native";
import { EntryLine, entryDisplayNumber } from "~/components/entries";
import { Container, focusReset, H2, SwitchVariant, ts } from "~/primitives";
import { useQueryState } from "~/utils";
import { EpisodeLine, episodeDisplayNumber } from "./episode";
import { Header } from "./header";
import { EpisodeList } from "./season";
import { EntryList } from "./season";
export const SvgWave = (props: SvgProps) => {
const { css } = useYoshiki();
@ -31,7 +31,6 @@ export const SvgWave = (props: SvgProps) => {
export const ShowWatchStatusCard = ({
watchedPercent,
status,
nextEpisode,
}: ShowWatchStatus) => {
const { t } = useTranslation();
@ -60,12 +59,11 @@ export const ShowWatchStatusCard = ({
])}
>
<H2 {...css({ marginLeft: ts(2) })}>{t("show.nextUp")}</H2>
<EpisodeLine
<EntryLine
{...nextEpisode}
showSlug={null}
serieSlug={null}
watchedPercent={watchedPercent || null}
watchedStatus={status || null}
displayNumber={episodeDisplayNumber(nextEpisode)}
displayNumber={entryDisplayNumber(nextEpisode)}
onHoverIn={() => setFocus(true)}
onHoverOut={() => setFocus(false)}
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 [slug] = useQueryState("slug", undefined!);
return (
<View
@ -109,18 +108,14 @@ const ShowHeader = ({ children, slug, ...props }: any) => {
);
};
export const ShowDetails = () => {
export const SerieDetails = () => {
const { css, theme } = useYoshiki();
const [slug] = useQueryState("slug", undefined!);
const [season] = useQueryState("season", undefined!);
return (
<View {...css({ bg: theme.variant.background, flex: 1 })}>
<EpisodeList
slug={slug}
season={season}
Header={ShowHeader}
headerProps={{ slug }}
/>
<EntryList slug={slug} season={season} Header={SerieHeader} />
</View>
);
};