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": {
|
||||
"@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=="],
|
||||
|
@ -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",
|
||||
|
@ -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 {
|
||||
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);
|
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 "./touchonly";
|
||||
export * from "./page-style";
|
||||
export * from "./breakpoint";
|
||||
|
@ -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;
|
||||
|
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 "./fetch";
|
||||
export * from "./fetch-infinite";
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user