mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-09 03:04:20 -04:00
Rework browse page
This commit is contained in:
parent
8de7f20ac9
commit
36abadc2cc
@ -6,6 +6,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/html-elements": "^0.12.5",
|
"@expo/html-elements": "^0.12.5",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
|
"@legendapp/list": "^1.0.20",
|
||||||
"@material-symbols/svg-400": "^0.31.6",
|
"@material-symbols/svg-400": "^0.31.6",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/html-elements": "^0.12.5",
|
"@expo/html-elements": "^0.12.5",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
|
"@legendapp/list": "^1.0.20",
|
||||||
"@material-symbols/svg-400": "^0.31.6",
|
"@material-symbols/svg-400": "^0.31.6",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||||
|
@ -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} />;
|
|
||||||
};
|
|
@ -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} />;
|
|
||||||
};
|
|
160
front/src/components/context-menus.tsx
Normal file
160
front/src/components/context-menus.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
2
front/src/components/index.ts
Normal file
2
front/src/components/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./item-grid";
|
||||||
|
export * from "./item-list";
|
@ -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 {
|
import {
|
||||||
Icon,
|
|
||||||
Link,
|
Link,
|
||||||
P,
|
P,
|
||||||
Poster,
|
Poster,
|
||||||
@ -10,55 +12,9 @@ import {
|
|||||||
focusReset,
|
focusReset,
|
||||||
important,
|
important,
|
||||||
ts,
|
ts,
|
||||||
} from "@kyoo/primitives";
|
} from "~/primitives";
|
||||||
import Done from "@material-symbols/svg-400/rounded/check-fill.svg";
|
import type { Layout } from "~/query";
|
||||||
import { useState } from "react";
|
import { ItemWatchStatus } from "./item-helpers";
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ItemProgress = ({ watchPercent }: { watchPercent: number }) => {
|
export const ItemProgress = ({ watchPercent }: { watchPercent: number }) => {
|
||||||
const { css } = useYoshiki("episodebox");
|
const { css } = useYoshiki("episodebox");
|
||||||
@ -106,7 +62,7 @@ export const ItemGrid = ({
|
|||||||
poster: KyooImage | null;
|
poster: KyooImage | null;
|
||||||
watchStatus: WatchStatusV | null;
|
watchStatus: WatchStatusV | null;
|
||||||
watchPercent: number | null;
|
watchPercent: number | null;
|
||||||
type: "movie" | "show" | "collection";
|
type: "movie" | "serie" | "collection";
|
||||||
unseenEpisodesCount: number | null;
|
unseenEpisodesCount: number | null;
|
||||||
} & Stylable<"text">) => {
|
} & Stylable<"text">) => {
|
||||||
const [moreOpened, setMoreOpened] = useState(false);
|
const [moreOpened, setMoreOpened] = useState(false);
|
47
front/src/components/item-helpers.tsx
Normal file
47
front/src/components/item-helpers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
180
front/src/components/item-list.tsx
Normal file
180
front/src/components/item-list.tsx
Normal 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;
|
41
front/src/primitives/utils/breakpoint.ts
Normal file
41
front/src/primitives/utils/breakpoint.ts
Normal 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)]),
|
||||||
|
);
|
||||||
|
};
|
@ -4,3 +4,4 @@ export * from "./spacing";
|
|||||||
export * from "./capitalize";
|
export * from "./capitalize";
|
||||||
export * from "./touchonly";
|
export * from "./touchonly";
|
||||||
export * from "./page-style";
|
export * from "./page-style";
|
||||||
|
export * from "./breakpoint";
|
||||||
|
@ -46,7 +46,7 @@ export const ErrorProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
|
|
||||||
export const useSetError = (key: string) => {
|
export const useSetError = (key: string) => {
|
||||||
const { setError, clearError } = useContext(ErrorSetterContext);
|
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 });
|
setError({ key: nKey ?? key, ...obj });
|
||||||
const clear = () => clearError(key);
|
const clear = () => clearError(key);
|
||||||
return [set, clear] as const;
|
return [set, clear] as const;
|
||||||
|
103
front/src/query/fetch-infinite.tsx
Normal file
103
front/src/query/fetch-infinite.tsx
Normal 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} />;
|
||||||
|
};
|
@ -1,2 +1,3 @@
|
|||||||
export * from "./query";
|
export * from "./query";
|
||||||
export * from "./fetch";
|
export * from "./fetch";
|
||||||
|
export * from "./fetch-infinite";
|
||||||
|
@ -6,11 +6,12 @@ import {
|
|||||||
type QueryPage,
|
type QueryPage,
|
||||||
getDisplayDate,
|
getDisplayDate,
|
||||||
} from "~/models";
|
} from "~/models";
|
||||||
import { InfiniteFetch } from "../../query/fetch-infinite"
|
import { useQueryState } from "~/utils";
|
||||||
import { DefaultLayout } from "../../../packages/ui/src/layout";
|
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 { BrowseSettings } from "./header";
|
||||||
import { ItemList } from "./list";
|
import { ItemList } from "../../components/item-list";
|
||||||
import {
|
import {
|
||||||
Layout,
|
Layout,
|
||||||
type MediaType,
|
type MediaType,
|
||||||
@ -21,8 +22,6 @@ import {
|
|||||||
SortOrd,
|
SortOrd,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const { useParam } = createParam<{ sortBy?: string; mediaType?: string }>();
|
|
||||||
|
|
||||||
export const itemMap = (
|
export const itemMap = (
|
||||||
item: LibraryItem,
|
item: LibraryItem,
|
||||||
): ComponentProps<typeof ItemGrid> & ComponentProps<typeof ItemList> => ({
|
): ComponentProps<typeof ItemGrid> & ComponentProps<typeof ItemList> => ({
|
||||||
@ -66,8 +65,8 @@ export const getMediaTypeFromParam = (mediaTypeParam?: string): MediaType => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BrowsePage: QueryPage = () => {
|
export const BrowsePage: QueryPage = () => {
|
||||||
const [sort, setSort] = useParam("sortBy");
|
const [sort, setSort] = useQueryState("sortBy", "");
|
||||||
const [mediaTypeParam, setMediaTypeParam] = useParam("mediaType");
|
const [mediaTypeParam, setMediaTypeParam] = useQueryState("mediaType", "");
|
||||||
const sortKey = (sort?.split(":")[0] as SortBy) || SortBy.Name;
|
const sortKey = (sort?.split(":")[0] as SortBy) || SortBy.Name;
|
||||||
const sortOrd = (sort?.split(":")[1] as SortOrd) || SortOrd.Asc;
|
const sortOrd = (sort?.split(":")[1] as SortOrd) || SortOrd.Asc;
|
||||||
const [layout, setLayout] = useState(Layout.Grid);
|
const [layout, setLayout] = useState(Layout.Grid);
|
||||||
|
@ -14,9 +14,9 @@ import {
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { percent, px, rem, useYoshiki } from "yoshiki/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 type { Layout } from "../fetch";
|
||||||
import { ItemWatchStatus } from "./grid";
|
import { ItemWatchStatus } from "../ui/browse/grid";
|
||||||
|
|
||||||
export const ItemList = ({
|
export const ItemList = ({
|
||||||
href,
|
href,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user