mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-31 14:33:50 -04:00
Make series page
This commit is contained in:
parent
d5c7ee40bc
commit
42cce837e4
3
front/src/app/(app)/series/[slug].tsx
Normal file
3
front/src/app/(app)/series/[slug].tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import { SerieDetails } from "~/ui/details";
|
||||
|
||||
export default SerieDetails;
|
@ -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([
|
@ -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 "??";
|
||||
}
|
||||
};
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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>;
|
||||
|
@ -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";
|
||||
|
@ -1,6 +0,0 @@
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export const usePageStyle = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
return { paddingBottom: insets.bottom } as const;
|
||||
};
|
@ -1,3 +0,0 @@
|
||||
export const usePageStyle = () => {
|
||||
return {} as const;
|
||||
};
|
@ -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");
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user