Rework browse page

This commit is contained in:
Zoe Roux 2025-06-17 09:22:46 +02:00
parent 8de7f20ac9
commit 36abadc2cc
No known key found for this signature in database
16 changed files with 556 additions and 453 deletions

View File

@ -6,6 +6,7 @@
"dependencies": {
"@expo/html-elements": "^0.12.5",
"@gorhom/portal": "^1.0.14",
"@legendapp/list": "^1.0.20",
"@material-symbols/svg-400": "^0.31.6",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@react-navigation/bottom-tabs": "^7.3.10",
@ -350,6 +351,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@legendapp/list": ["@legendapp/list@1.0.20", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ZePX6kPMHEcr/LFqLvCTlODWd9IG8UHTMJXzoTxTKJp+fqEGBtc4f5ELln0L8/3gn9ViMFAPmx18k/+0qoSDnA=="],
"@material-symbols/svg-400": ["@material-symbols/svg-400@0.31.6", "", {}, "sha512-zaJDxeDuzW1dh2GQTfB4hNiAddsolW5BCrzAWvf5Jbm5vaNJ/8lWyRx/o2W00Lk6TFt3aKXU1TVEvahG2NhhRQ=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],

View File

@ -15,6 +15,7 @@
"dependencies": {
"@expo/html-elements": "^0.12.5",
"@gorhom/portal": "^1.0.14",
"@legendapp/list": "^1.0.20",
"@material-symbols/svg-400": "^0.31.6",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@react-navigation/bottom-tabs": "^7.3.10",

View File

@ -1,169 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { type QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
import { HR, useBreakpointMap } from "@kyoo/primitives";
import { type ContentStyle, FlashList } from "@shopify/flash-list";
import {
type ComponentProps,
type ComponentType,
type ReactElement,
isValidElement,
useRef,
} from "react";
import { FlatList, View, type ViewStyle } from "react-native";
import { ErrorView } from "../../../src/ui/errors";
import { EmptyView, type Layout, OfflineView, addHeader } from "./fetch";
const emulateGap = (
layout: "grid" | "vertical" | "horizontal",
gap: number,
numColumns: number,
index: number,
itemsCount: number,
): ViewStyle => {
let marginLeft = 0;
let marginRight = 0;
if (layout !== "vertical" && numColumns > 1) {
if (index % numColumns === 0) {
marginRight = (gap * 2) / 3;
} else if ((index + 1) % numColumns === 0) {
marginLeft = (gap * 2) / 3;
} else {
marginLeft = gap / 3;
marginRight = gap / 3;
}
}
return {
marginLeft,
marginRight,
marginTop: layout !== "horizontal" && index >= numColumns ? gap : 0,
marginBottom: layout !== "horizontal" && itemsCount - index <= numColumns ? gap : 0,
};
};
export const InfiniteFetchList = <Data, Props, _, Kind extends number | string>({
query,
placeholderCount = 2,
incremental = false,
Render,
Loader,
layout,
empty,
divider = false,
Header,
headerProps,
getItemType,
getItemSize,
fetchMore = true,
nested = false,
contentContainerStyle,
...props
}: {
query: ReturnType<typeof useInfiniteFetch<_, Data>>;
placeholderCount?: number;
layout: Layout;
horizontal?: boolean;
Render: (props: { item: Data; index: number }) => ReactElement | null;
Loader: (props: { index: number }) => ReactElement | null;
empty?: string | JSX.Element;
incremental?: boolean;
divider?: boolean | 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;
contentContainerStyle?: ContentStyle;
}): JSX.Element | null => {
const { numColumns, size, gap } = useBreakpointMap(layout);
const oldItems = useRef<Data[] | undefined>();
let { items, isPaused, error, fetchNextPage, isFetching, refetch, isRefetching } = query;
if (incremental && items) oldItems.current = items;
if (error) return <ErrorView error={error} />;
if (isPaused) return <OfflineView />;
if (empty && items && items.length === 0) {
if (typeof empty !== "string") return addHeader(Header, empty, headerProps);
return addHeader(Header, <EmptyView message={empty} />, headerProps);
}
if (incremental) items ??= oldItems.current;
const count = items ? numColumns - (items.length % numColumns) : placeholderCount;
const placeholders = [...Array(count === 0 ? numColumns : count)].fill(null);
const data = isFetching || !items ? [...(items || []), ...placeholders] : items;
const List = nested ? (FlatList as unknown as typeof FlashList) : FlashList;
// @ts-ignore
if (headerProps && !isValidElement(Header)) Header = <Header {...headerProps} />;
return (
<List
contentContainerStyle={{
paddingHorizontal: layout.layout !== "vertical" ? gap : 0,
...contentContainerStyle,
}}
renderItem={({ item, index }) => (
<View
style={[
emulateGap(layout.layout, gap, numColumns, index, data.length),
layout.layout === "horizontal" && {
width:
size * (getItemType && getItemSize ? getItemSize(getItemType(item, index)) : 1),
height: size * 2,
},
]}
>
{item ? <Render index={index} item={item} /> : <Loader index={index} />}
</View>
)}
data={data}
horizontal={layout.layout === "horizontal"}
keyExtractor={(item: any, index) => (item ? item.id : index)}
numColumns={layout.layout === "horizontal" ? 1 : numColumns}
estimatedItemSize={size}
onEndReached={fetchMore ? fetchNextPage : undefined}
onEndReachedThreshold={0.5}
onRefresh={layout.layout !== "horizontal" ? refetch : null}
refreshing={isRefetching}
ItemSeparatorComponent={divider === true ? HR : divider || null}
ListHeaderComponent={Header}
getItemType={getItemType}
nestedScrollEnabled={nested}
scrollEnabled={!nested}
{...props}
/>
);
};
export const InfiniteFetch = <Data, Props, _, Kind extends number | string>({
query,
...props
}: {
query: QueryIdentifier<_, Data>;
} & Omit<ComponentProps<typeof InfiniteFetchList<Data, Props, _, Kind>>, "query">) => {
if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch.");
const ret = useInfiniteFetch(query);
return <InfiniteFetchList query={ret} {...props} />;
};

View File

@ -1,222 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { type QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
import { HR } from "@kyoo/primitives";
import type { ContentStyle } from "@shopify/flash-list";
import {
type ComponentProps,
type ComponentType,
Fragment,
type ReactElement,
isValidElement,
useCallback,
useEffect,
useRef,
} from "react";
import { type Stylable, nativeStyleToCss, useYoshiki, ysMap } from "yoshiki";
import { ErrorView } from "../../../src/ui/errors";
import { EmptyView, type Layout, addHeader } from "./fetch";
const InfiniteScroll = <Props,>({
children,
loader,
layout,
loadMore,
hasMore = true,
isFetching,
Header,
headerProps,
fetchMore = true,
contentContainerStyle,
...props
}: {
children?: ReactElement | (ReactElement | null)[] | null;
loader?: (ReactElement | null)[];
layout: Layout;
loadMore: () => void;
hasMore: boolean;
isFetching: boolean;
Header?: ComponentType<Props & { children: JSX.Element }> | ReactElement;
headerProps?: Props;
fetchMore?: boolean;
contentContainerStyle?: ContentStyle;
} & Stylable) => {
const ref = useRef<HTMLDivElement>(null);
const { css } = useYoshiki();
const onScroll = useCallback(() => {
if (!ref.current || !hasMore || isFetching || !fetchMore) return;
const scroll =
layout.layout === "horizontal"
? ref.current.scrollWidth - ref.current.scrollLeft
: ref.current.scrollHeight - ref.current.scrollTop;
const offset =
layout.layout === "horizontal" ? ref.current.offsetWidth : ref.current.offsetHeight;
// Load more if less than 3 element's worth of scroll is left
if (scroll <= offset * 3) loadMore();
}, [hasMore, isFetching, layout, loadMore, fetchMore]);
const scrollProps = { ref, onScroll };
// Automatically trigger a scroll check on start and after a fetch end in case the user is already
// at the bottom of the page or if there is no scroll bar (ultrawide or something like that)
// biome-ignore lint/correctness/useExhaustiveDependencies: Check for scroll pause after fetch ends
useEffect(() => {
onScroll();
}, [isFetching, onScroll]);
const list = (props: object) => (
<div
{...css(
[
{
display: "grid",
gridAutoRows: "max-content",
// the as any is due to differencies between css types of native and web (already accounted for in yoshiki)
gridGap: layout.gap as any,
},
layout.layout === "vertical" && {
gridTemplateColumns: "1fr",
alignItems: "stretch",
overflowY: "auto",
paddingY: layout.gap as any,
},
layout.layout === "horizontal" && {
alignItems: "stretch",
overflowX: "auto",
overflowY: "hidden",
gridAutoFlow: "column",
gridAutoColumns: ysMap(layout.numColumns, (x) => `${100 / x}%`),
gridTemplateRows: "max-content",
paddingX: layout.gap as any,
},
layout.layout === "grid" && {
gridTemplateColumns: ysMap(layout.numColumns, (x) => `repeat(${x}, 1fr)`),
justifyContent: "center",
alignItems: "flex-start",
overflowY: "auto",
padding: layout.gap as any,
},
contentContainerStyle as any,
],
nativeStyleToCss(props),
)}
>
{children}
{isFetching && loader}
</div>
);
if (!Header) return list({ ...scrollProps, ...props });
if (!isValidElement(Header))
return (
// @ts-ignore
<Header {...scrollProps} {...headerProps}>
{list(props)}
</Header>
);
return (
<>
{Header}
{list({ ...scrollProps, ...props })}
</>
);
};
export const InfiniteFetchList = <Data, _, HeaderProps, Kind extends number | string>({
query,
incremental = false,
placeholderCount = 2,
Render,
layout,
empty,
divider: Divider = false,
Header,
headerProps,
getItemType,
getItemSize,
nested,
Loader,
...props
}: {
query: ReturnType<typeof useInfiniteFetch<_, Data>>;
incremental?: boolean;
placeholderCount?: number;
layout: Layout;
Render: (props: { item: Data; index: number }) => ReactElement | null;
Loader: (props: { index: number }) => ReactElement | null;
empty?: string | JSX.Element;
divider?: boolean | ComponentType;
Header?: ComponentType<{ children: JSX.Element } & HeaderProps> | ReactElement;
headerProps: HeaderProps;
getItemType?: (item: Data | null, index: number) => Kind;
getItemSize?: (kind: Kind) => number;
fetchMore?: boolean;
contentContainerStyle?: ContentStyle;
nested?: boolean;
}): JSX.Element | null => {
const oldItems = useRef<Data[] | undefined>();
const { items, error, fetchNextPage, hasNextPage, isFetching } = query;
if (incremental && items) oldItems.current = items;
if (error) return addHeader(Header, <ErrorView error={error} />, headerProps);
if (empty && items && items.length === 0) {
if (typeof empty !== "string") return addHeader(Header, empty, headerProps);
return addHeader(Header, <EmptyView message={empty} />, headerProps);
}
return (
<InfiniteScroll
layout={layout}
loadMore={fetchNextPage}
hasMore={hasNextPage!}
isFetching={isFetching}
loader={[...Array(placeholderCount)].map((_, i) => (
<Fragment key={i.toString()}>
{Divider && i !== 0 && (Divider === true ? <HR /> : <Divider />)}
<Loader index={i} />
</Fragment>
))}
Header={Header}
headerProps={headerProps}
{...props}
>
{(items ?? oldItems.current)?.map((item, i) => (
<Fragment key={(item as any).id}>
{Divider && i !== 0 && (Divider === true ? <HR /> : <Divider />)}
<Render item={item} index={i} />
</Fragment>
))}
</InfiniteScroll>
);
};
export const InfiniteFetch = <Data, Props, _, Kind extends number | string>({
query,
...props
}: {
query: QueryIdentifier<_, Data>;
} & Omit<ComponentProps<typeof InfiniteFetchList<Data, Props, _, Kind>>, "query">) => {
if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch.");
const ret = useInfiniteFetch(query);
return <InfiniteFetchList query={ret} {...props} />;
};

View File

@ -0,0 +1,160 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import Refresh from "@material-symbols/svg-400/rounded/autorenew.svg";
// import Download from "@material-symbols/svg-400/rounded/download.svg";
import Info from "@material-symbols/svg-400/rounded/info.svg";
import MoreVert from "@material-symbols/svg-400/rounded/more_vert.svg";
import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { WatchStatusV } from "~/models";
import { HR, IconButton, Menu, tooltip } from "~/primitives";
import { useAccount } from "~/providers/account-context";
import { queryFn } from "~/query";
// import { useDownloader } from "../../packages/ui/src/downloadses/ui/src/downloads";
export const EpisodesContext = ({
type = "episode",
slug,
showSlug,
status,
force,
...props
}: {
type?: "serie" | "movie" | "episode";
showSlug?: string | null;
slug: string;
status: WatchStatusV | null;
force?: boolean;
} & Partial<ComponentProps<typeof Menu<typeof IconButton>>>) => {
const account = useAccount();
// const downloader = useDownloader();
const { css } = useYoshiki();
const { t } = useTranslation();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newStatus: WatchStatusV | null) =>
queryFn({
path: [type, slug, "watchStatus", newStatus && `?status=${newStatus}`],
method: newStatus ? "POST" : "DELETE",
}),
onSettled: async () => await queryClient.invalidateQueries({ queryKey: [type, slug] }),
});
const metadataRefreshMutation = useMutation({
mutationFn: () =>
queryFn({
path: [type, slug, "refresh"],
method: "POST",
}),
});
return (
<>
<Menu
Trigger={IconButton}
icon={MoreVert}
{...tooltip(t("misc.more"))}
{...(css([Platform.OS !== "web" && !force && { display: "none" }], props) as any)}
>
{showSlug && (
<Menu.Item
label={t("home.episodeMore.goToShow")}
icon={Info}
href={`/serie/${showSlug}`}
/>
)}
<Menu.Sub
label={account ? t("show.watchlistEdit") : t("show.watchlistLogin")}
disabled={!account}
icon={watchListIcon(status)}
>
{Object.values(WatchStatusV).map((x) => (
<Menu.Item
key={x}
label={t(`show.watchlistMark.${x.toLowerCase() as Lowercase<WatchStatusV>}`)}
onSelect={() => mutation.mutate(x)}
selected={x === status}
/>
))}
{status !== null && (
<Menu.Item
label={t("show.watchlistMark.null")}
onSelect={() => mutation.mutate(null)}
/>
)}
</Menu.Sub>
{type !== "serie" && (
<>
{/* <Menu.Item */}
{/* label={t("home.episodeMore.download")} */}
{/* icon={Download} */}
{/* onSelect={() => downloader(type, slug)} */}
{/* /> */}
<Menu.Item
label={t("home.episodeMore.mediainfo")}
icon={MovieInfo}
href={`/${type}/${slug}/info`}
/>
</>
)}
{account?.isAdmin === true && (
<>
<HR />
<Menu.Item
label={t("home.refreshMetadata")}
icon={Refresh}
onSelect={() => metadataRefreshMutation.mutate()}
/>
</>
)}
</Menu>
</>
);
};
export const ItemContext = ({
type,
slug,
status,
force,
...props
}: {
type: "movie" | "serie";
slug: string;
status: WatchStatusV | null;
force?: boolean;
} & Partial<ComponentProps<typeof Menu<typeof IconButton>>>) => {
return (
<EpisodesContext
type={type}
slug={slug}
status={status}
showSlug={null}
force={force}
{...props}
/>
);
};

View File

@ -0,0 +1,2 @@
export * from "./item-grid";
export * from "./item-list";

View File

@ -1,6 +1,8 @@
import { type KyooImage, WatchStatusV } from "@kyoo/models";
import { useState } from "react";
import { type ImageStyle, Platform, View } from "react-native";
import { type Stylable, type Theme, percent, px, useYoshiki } from "yoshiki/native";
import type { KyooImage, WatchStatusV } from "~/models";
import {
Icon,
Link,
P,
Poster,
@ -10,55 +12,9 @@ import {
focusReset,
important,
ts,
} from "@kyoo/primitives";
import Done from "@material-symbols/svg-400/rounded/check-fill.svg";
import { useState } from "react";
import { type ImageStyle, Platform, View } from "react-native";
import { type Stylable, type Theme, max, percent, px, rem, useYoshiki } from "yoshiki/native";
import { ItemContext } from "../../../packages/ui/src/components/context-menus";
import type { Layout } from "../fetch";
export const ItemWatchStatus = ({
watchStatus,
unseenEpisodesCount,
...props
}: {
watchStatus?: WatchStatusV | null;
unseenEpisodesCount?: number | null;
}) => {
const { css } = useYoshiki();
if (watchStatus !== WatchStatusV.Completed && !unseenEpisodesCount) return null;
return (
<View
{...css(
{
position: "absolute",
top: 0,
right: 0,
minWidth: max(rem(1), ts(3.5)),
aspectRatio: 1,
justifyContent: "center",
alignItems: "center",
m: ts(0.5),
pX: ts(0.5),
bg: (theme) => theme.darkOverlay,
borderRadius: 999999,
},
props,
)}
>
{watchStatus === WatchStatusV.Completed ? (
<Icon icon={Done} size={16} />
) : (
<P {...css({ marginVertical: 0, verticalAlign: "middle", textAlign: "center" })}>
{unseenEpisodesCount}
</P>
)}
</View>
);
};
} from "~/primitives";
import type { Layout } from "~/query";
import { ItemWatchStatus } from "./item-helpers";
export const ItemProgress = ({ watchPercent }: { watchPercent: number }) => {
const { css } = useYoshiki("episodebox");
@ -106,7 +62,7 @@ export const ItemGrid = ({
poster: KyooImage | null;
watchStatus: WatchStatusV | null;
watchPercent: number | null;
type: "movie" | "show" | "collection";
type: "movie" | "serie" | "collection";
unseenEpisodesCount: number | null;
} & Stylable<"text">) => {
const [moreOpened, setMoreOpened] = useState(false);

View File

@ -0,0 +1,47 @@
import Done from "@material-symbols/svg-400/rounded/check-fill.svg";
import { View } from "react-native";
import { max, rem, useYoshiki } from "yoshiki/native";
import { WatchStatusV } from "~/models";
import { Icon, P, ts } from "~/primitives";
export const ItemWatchStatus = ({
watchStatus,
unseenEpisodesCount,
...props
}: {
watchStatus?: WatchStatusV | null;
unseenEpisodesCount?: number | null;
}) => {
const { css } = useYoshiki();
if (watchStatus !== WatchStatusV.Completed && !unseenEpisodesCount) return null;
return (
<View
{...css(
{
position: "absolute",
top: 0,
right: 0,
minWidth: max(rem(1), ts(3.5)),
aspectRatio: 1,
justifyContent: "center",
alignItems: "center",
m: ts(0.5),
pX: ts(0.5),
bg: (theme) => theme.darkOverlay,
borderRadius: 999999,
},
props,
)}
>
{watchStatus === WatchStatusV.Completed ? (
<Icon icon={Done} size={16} />
) : (
<P {...css({ marginVertical: 0, verticalAlign: "middle", textAlign: "center" })}>
{unseenEpisodesCount}
</P>
)}
</View>
);
};

View File

@ -0,0 +1,180 @@
import { useState } from "react";
import { Platform, View } from "react-native";
import { percent, px, rem, useYoshiki } from "yoshiki/native";
import type { KyooImage, WatchStatusV } from "~/models";
import {
GradientImageBackground,
Heading,
Link,
P,
Poster,
PosterBackground,
Skeleton,
imageBorderRadius,
important,
ts,
} from "~/primitives";
import type { Layout } from "~/query";
import { ItemWatchStatus } from "./item-helpers";
export const ItemList = ({
href,
slug,
type,
name,
subtitle,
thumbnail,
poster,
watchStatus,
unseenEpisodesCount,
...props
}: {
href: string;
slug: string;
type: "movie" | "show" | "collection";
name: string;
subtitle: string | null;
poster: KyooImage | null;
thumbnail: KyooImage | null;
watchStatus: WatchStatusV | null;
unseenEpisodesCount: number | null;
}) => {
const { css } = useYoshiki("line");
const [moreOpened, setMoreOpened] = useState(false);
return (
<GradientImageBackground
src={thumbnail}
alt={name}
quality="medium"
as={Link}
href={moreOpened ? undefined : href}
onLongPress={() => setMoreOpened(true)}
{...css(
{
alignItems: "center",
justifyContent: "space-evenly",
flexDirection: "row",
height: ItemList.layout.size,
borderRadius: px(imageBorderRadius),
overflow: "hidden",
marginX: ItemList.layout.gap,
child: {
more: {
opacity: 0,
},
},
fover: {
title: {
textDecorationLine: "underline",
},
more: {
opacity: 100,
},
},
},
props,
)}
>
<View
{...css({
width: { xs: "50%", lg: "30%" },
})}
>
<View
{...css({
flexDirection: "row",
justifyContent: "center",
})}
>
<Heading
{...css([
"title",
{
textAlign: "center",
fontSize: rem(2),
letterSpacing: rem(0.002),
fontWeight: "900",
textTransform: "uppercase",
},
])}
>
{name}
</Heading>
{type !== "collection" && (
<ItemContext
type={type}
slug={slug}
status={watchStatus}
isOpen={moreOpened}
setOpen={(v) => setMoreOpened(v)}
{...css([
{
// I dont know why marginLeft gets overwritten by the margin: px(2) so we important
marginLeft: important(ts(2)),
bg: (theme) => theme.darkOverlay,
},
"more",
Platform.OS === "web" && moreOpened && { opacity: important(100) },
])}
/>
)}
</View>
{subtitle && (
<P
{...css({
textAlign: "center",
marginRight: ts(4),
})}
>
{subtitle}
</P>
)}
</View>
<PosterBackground src={poster} alt="" quality="low" layout={{ height: percent(80) }}>
<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
</PosterBackground>
</GradientImageBackground>
);
};
ItemList.Loader = (props: object) => {
const { css } = useYoshiki();
return (
<View
{...css(
{
alignItems: "center",
justifyContent: "space-evenly",
flexDirection: "row",
height: ItemList.layout.size,
borderRadius: px(imageBorderRadius),
overflow: "hidden",
bg: (theme) => theme.dark.background,
marginX: ItemList.layout.gap,
},
props,
)}
>
<View
{...css({
width: { xs: "50%", lg: "30%" },
flexDirection: "column",
justifyContent: "center",
})}
>
<Skeleton {...css({ height: rem(2), alignSelf: "center" })} />
<Skeleton {...css({ width: rem(5), alignSelf: "center" })} />
</View>
<Poster.Loader layout={{ height: percent(80) }} />
</View>
);
};
ItemList.layout = {
numColumns: 1,
size: 300,
layout: "vertical",
gap: ts(2),
} satisfies Layout;

View File

@ -0,0 +1,41 @@
import { useWindowDimensions } from "react-native";
import { type Breakpoints as YoshikiBreakpoint, breakpoints, isBreakpoints } from "yoshiki/native";
type AtLeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> & U[keyof U];
export type Breakpoint<T> = T | AtLeastOne<YoshikiBreakpoint<T>>;
// copied from yoshiki.
const useBreakpoint = () => {
const { width } = useWindowDimensions();
const idx = Object.values(breakpoints).findIndex((x) => width <= x);
if (idx === -1) return 0;
return idx - 1;
};
const getBreakpointValue = <T>(value: Breakpoint<T>, breakpoint: number): T => {
if (!isBreakpoints(value)) return value;
const bpKeys = Object.keys(breakpoints) as Array<keyof YoshikiBreakpoint<T>>;
for (let i = breakpoint; i >= 0; i--) {
if (bpKeys[i] in value) {
const val = value[bpKeys[i]];
if (val) return val;
}
}
// This should never be reached.
return undefined!;
};
export const useBreakpointValue = <T>(value: Breakpoint<T>): T => {
const breakpoint = useBreakpoint();
return getBreakpointValue(value, breakpoint);
};
export const useBreakpointMap = <T extends Record<string, unknown>>(
value: T,
): { [key in keyof T]: T[key] extends Breakpoint<infer V> ? V : T } => {
const breakpoint = useBreakpoint();
// @ts-ignore
return Object.fromEntries(
Object.entries(value).map(([key, val]) => [key, getBreakpointValue(val, breakpoint)]),
);
};

View File

@ -4,3 +4,4 @@ export * from "./spacing";
export * from "./capitalize";
export * from "./touchonly";
export * from "./page-style";
export * from "./breakpoint";

View File

@ -46,7 +46,7 @@ export const ErrorProvider = ({ children }: { children: ReactNode }) => {
export const useSetError = (key: string) => {
const { setError, clearError } = useContext(ErrorSetterContext);
const set = ({ key: nKey, ...obj }: Omit<Error, "key"> & { key?: Error["key"] }) =>
const set = ({ key: nKey, ...obj }: Omit<Error, "key"> & { key?: Error["key"] } = {}) =>
setError({ key: nKey ?? key, ...obj });
const clear = () => clearError(key);
return [set, clear] as const;

View File

@ -0,0 +1,103 @@
import { LegendList } from "@legendapp/list";
import { type ComponentProps, type ComponentType, type ReactElement, useRef } from "react";
import { type Breakpoint, HR, useBreakpointMap } from "~/primitives";
import { useSetError } from "~/providers/error-provider";
import { type QueryIdentifier, useInfiniteFetch } from "~/query";
import { ErrorView } from "../ui/errors";
export type Layout = {
numColumns: Breakpoint<number>;
size: Breakpoint<number>;
gap: Breakpoint<number>;
layout: "grid" | "horizontal" | "vertical";
};
export const InfiniteFetchList = <Data, Props, _, Kind extends number | string>({
query,
placeholderCount = 2,
incremental = false,
Render,
Loader,
layout,
Empty,
divider,
Header,
headerProps,
getItemType,
getItemSize,
fetchMore = true,
nested = false,
...props
}: {
query: ReturnType<typeof useInfiniteFetch<_, Data>>;
placeholderCount?: number;
layout: Layout;
horizontal?: boolean;
Render: (props: { item: Data; index: number }) => ReactElement | null;
Loader: (props: { index: number }) => ReactElement | null;
Empty?: JSX.Element;
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");
const oldItems = useRef<Data[] | undefined>(undefined);
let { items, isPaused, error, fetchNextPage, isFetching, refetch, isRefetching } = query;
if (incremental && items) oldItems.current = items;
if (isPaused) setOffline();
else clearOffline();
if (error) return <ErrorView error={error} />;
if (incremental) items ??= oldItems.current;
const count = items ? numColumns - (items.length % numColumns) : placeholderCount;
const placeholders = [...Array(count === 0 ? numColumns : count)].fill(null);
const data = isFetching || !items ? [...(items || []), ...placeholders] : items;
return (
<LegendList
data={data}
recycleItems
renderItem={({ item, index }) =>
item ? <Render index={index} item={item} /> : <Loader index={index} />
}
keyExtractor={(item: any, index) => (item ? item.id : index)}
estimatedItemSize={size}
horizontal={layout.layout === "horizontal"}
numColumns={layout.layout === "horizontal" ? 1 : numColumns}
onEndReached={fetchMore ? () => fetchNextPage() : undefined}
onEndReachedThreshold={0.5}
onRefresh={layout.layout !== "horizontal" ? refetch: undefined}
refreshing={isRefetching}
ListHeaderComponent={Header}
ItemSeparatorComponent={divider === true ? HR : divider as any || undefined}
ListEmptyComponent={Empty}
contentContainerStyle={{gap}}
{...props}
/>
);
};
export const InfiniteFetch = <Data, Props, _, Kind extends number | string>({
query,
...props
}: {
query: QueryIdentifier<_, Data>;
} & Omit<ComponentProps<typeof InfiniteFetchList<Data, Props, _, Kind>>, "query">) => {
if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch.");
const ret = useInfiniteFetch(query);
return <InfiniteFetchList query={ret} {...props} />;
};

View File

@ -1,2 +1,3 @@
export * from "./query";
export * from "./fetch";
export * from "./fetch-infinite";

View File

@ -6,11 +6,12 @@ import {
type QueryPage,
getDisplayDate,
} from "~/models";
import { InfiniteFetch } from "../../query/fetch-infinite"
import { useQueryState } from "~/utils";
import { DefaultLayout } from "../../../packages/ui/src/layout";
import { ItemGrid } from "./grid";
import { InfiniteFetch } from "../../query/fetch-infinite";
import { ItemGrid } from "~/components";
import { BrowseSettings } from "./header";
import { ItemList } from "./list";
import { ItemList } from "../../components/item-list";
import {
Layout,
type MediaType,
@ -21,8 +22,6 @@ import {
SortOrd,
} from "./types";
const { useParam } = createParam<{ sortBy?: string; mediaType?: string }>();
export const itemMap = (
item: LibraryItem,
): ComponentProps<typeof ItemGrid> & ComponentProps<typeof ItemList> => ({
@ -66,8 +65,8 @@ export const getMediaTypeFromParam = (mediaTypeParam?: string): MediaType => {
};
export const BrowsePage: QueryPage = () => {
const [sort, setSort] = useParam("sortBy");
const [mediaTypeParam, setMediaTypeParam] = useParam("mediaType");
const [sort, setSort] = useQueryState("sortBy", "");
const [mediaTypeParam, setMediaTypeParam] = useQueryState("mediaType", "");
const sortKey = (sort?.split(":")[0] as SortBy) || SortBy.Name;
const sortOrd = (sort?.split(":")[1] as SortOrd) || SortOrd.Asc;
const [layout, setLayout] = useState(Layout.Grid);

View File

@ -14,9 +14,9 @@ import {
import { useState } from "react";
import { Platform, View } from "react-native";
import { percent, px, rem, useYoshiki } from "yoshiki/native";
import { ItemContext } from "../../../packages/ui/src/components/context-menus";
import { ItemContext } from "../../packages/ui/src/components/context-menus";
import type { Layout } from "../fetch";
import { ItemWatchStatus } from "./grid";
import { ItemWatchStatus } from "../ui/browse/grid";
export const ItemList = ({
href,