From 36abadc2cc57f8979c4c06939af56f77e48b7218 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 17 Jun 2025 09:22:46 +0200 Subject: [PATCH] Rework browse page --- front/bun.lock | 3 + front/package.json | 1 + front/packages/ui/src/fetch-infinite.tsx | 169 ------------- front/packages/ui/src/fetch-infinite.web.tsx | 222 ------------------ front/src/components/context-menus.tsx | 160 +++++++++++++ front/src/components/index.ts | 2 + .../grid.tsx => components/item-grid.tsx} | 60 +---- front/src/components/item-helpers.tsx | 47 ++++ front/src/components/item-list.tsx | 180 ++++++++++++++ front/src/primitives/utils/breakpoint.ts | 41 ++++ front/src/primitives/utils/index.tsx | 1 + front/src/providers/error-provider.tsx | 2 +- front/src/query/fetch-infinite.tsx | 103 ++++++++ front/src/query/index.tsx | 1 + front/src/ui/browse/index.tsx | 13 +- front/src/ui/browse/list.tsx | 4 +- 16 files changed, 556 insertions(+), 453 deletions(-) delete mode 100644 front/packages/ui/src/fetch-infinite.tsx delete mode 100644 front/packages/ui/src/fetch-infinite.web.tsx create mode 100644 front/src/components/context-menus.tsx create mode 100644 front/src/components/index.ts rename front/src/{ui/browse/grid.tsx => components/item-grid.tsx} (72%) create mode 100644 front/src/components/item-helpers.tsx create mode 100644 front/src/components/item-list.tsx create mode 100644 front/src/primitives/utils/breakpoint.ts create mode 100644 front/src/query/fetch-infinite.tsx diff --git a/front/bun.lock b/front/bun.lock index f3e03e18..ed24c47e 100644 --- a/front/bun.lock +++ b/front/bun.lock @@ -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=="], diff --git a/front/package.json b/front/package.json index ea4814f0..fa36444f 100644 --- a/front/package.json +++ b/front/package.json @@ -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", diff --git a/front/packages/ui/src/fetch-infinite.tsx b/front/packages/ui/src/fetch-infinite.tsx deleted file mode 100644 index ba823573..00000000 --- a/front/packages/ui/src/fetch-infinite.tsx +++ /dev/null @@ -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 . - */ - -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 = ({ - query, - placeholderCount = 2, - incremental = false, - Render, - Loader, - layout, - empty, - divider = false, - Header, - headerProps, - getItemType, - getItemSize, - fetchMore = true, - nested = false, - contentContainerStyle, - ...props -}: { - query: ReturnType>; - 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 | 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(); - let { items, isPaused, error, fetchNextPage, isFetching, refetch, isRefetching } = query; - if (incremental && items) oldItems.current = items; - - if (error) return ; - if (isPaused) return ; - if (empty && items && items.length === 0) { - if (typeof empty !== "string") return addHeader(Header, empty, headerProps); - return addHeader(Header, , 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 =
; - return ( - ( - - {item ? : } - - )} - 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 = ({ - query, - ...props -}: { - query: QueryIdentifier<_, Data>; -} & Omit>, "query">) => { - if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch."); - - const ret = useInfiniteFetch(query); - return ; -}; diff --git a/front/packages/ui/src/fetch-infinite.web.tsx b/front/packages/ui/src/fetch-infinite.web.tsx deleted file mode 100644 index 087c5d31..00000000 --- a/front/packages/ui/src/fetch-infinite.web.tsx +++ /dev/null @@ -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 . - */ - -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 = ({ - 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 | ReactElement; - headerProps?: Props; - fetchMore?: boolean; - contentContainerStyle?: ContentStyle; -} & Stylable) => { - const ref = useRef(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) => ( -
`${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} -
- ); - - if (!Header) return list({ ...scrollProps, ...props }); - if (!isValidElement(Header)) - return ( - // @ts-ignore -
- {list(props)} -
- ); - return ( - <> - {Header} - {list({ ...scrollProps, ...props })} - - ); -}; - -export const InfiniteFetchList = ({ - query, - incremental = false, - placeholderCount = 2, - Render, - layout, - empty, - divider: Divider = false, - Header, - headerProps, - getItemType, - getItemSize, - nested, - Loader, - ...props -}: { - query: ReturnType>; - 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(); - const { items, error, fetchNextPage, hasNextPage, isFetching } = query; - if (incremental && items) oldItems.current = items; - - if (error) return addHeader(Header, , headerProps); - if (empty && items && items.length === 0) { - if (typeof empty !== "string") return addHeader(Header, empty, headerProps); - return addHeader(Header, , headerProps); - } - - return ( - ( - - {Divider && i !== 0 && (Divider === true ?
: )} - -
- ))} - Header={Header} - headerProps={headerProps} - {...props} - > - {(items ?? oldItems.current)?.map((item, i) => ( - - {Divider && i !== 0 && (Divider === true ?
: )} - -
- ))} -
- ); -}; - -export const InfiniteFetch = ({ - query, - ...props -}: { - query: QueryIdentifier<_, Data>; -} & Omit>, "query">) => { - if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch."); - - const ret = useInfiniteFetch(query); - return ; -}; diff --git a/front/src/components/context-menus.tsx b/front/src/components/context-menus.tsx new file mode 100644 index 00000000..ca89af3e --- /dev/null +++ b/front/src/components/context-menus.tsx @@ -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 . + */ + +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>>) => { + 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 ( + <> + + {showSlug && ( + + )} + + {Object.values(WatchStatusV).map((x) => ( + }`)} + onSelect={() => mutation.mutate(x)} + selected={x === status} + /> + ))} + {status !== null && ( + mutation.mutate(null)} + /> + )} + + {type !== "serie" && ( + <> + {/* downloader(type, slug)} */} + {/* /> */} + + + )} + {account?.isAdmin === true && ( + <> +
+ metadataRefreshMutation.mutate()} + /> + + )} +
+ + ); +}; + +export const ItemContext = ({ + type, + slug, + status, + force, + ...props +}: { + type: "movie" | "serie"; + slug: string; + status: WatchStatusV | null; + force?: boolean; +} & Partial>>) => { + return ( + + ); +}; diff --git a/front/src/components/index.ts b/front/src/components/index.ts new file mode 100644 index 00000000..d41506ac --- /dev/null +++ b/front/src/components/index.ts @@ -0,0 +1,2 @@ +export * from "./item-grid"; +export * from "./item-list"; diff --git a/front/src/ui/browse/grid.tsx b/front/src/components/item-grid.tsx similarity index 72% rename from front/src/ui/browse/grid.tsx rename to front/src/components/item-grid.tsx index f0259f30..fe9fc966 100644 --- a/front/src/ui/browse/grid.tsx +++ b/front/src/components/item-grid.tsx @@ -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 ( - theme.darkOverlay, - borderRadius: 999999, - }, - props, - )} - > - {watchStatus === WatchStatusV.Completed ? ( - - ) : ( -

- {unseenEpisodesCount} -

- )} -
- ); -}; +} 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); diff --git a/front/src/components/item-helpers.tsx b/front/src/components/item-helpers.tsx new file mode 100644 index 00000000..e0c0d0f0 --- /dev/null +++ b/front/src/components/item-helpers.tsx @@ -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 ( + theme.darkOverlay, + borderRadius: 999999, + }, + props, + )} + > + {watchStatus === WatchStatusV.Completed ? ( + + ) : ( +

+ {unseenEpisodesCount} +

+ )} +
+ ); +}; diff --git a/front/src/components/item-list.tsx b/front/src/components/item-list.tsx new file mode 100644 index 00000000..e8012011 --- /dev/null +++ b/front/src/components/item-list.tsx @@ -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 ( + 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, + )} + > + + + + {name} + + {type !== "collection" && ( + 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) }, + ])} + /> + )} + + {subtitle && ( +

+ {subtitle} +

+ )} +
+ + + +
+ ); +}; + +ItemList.Loader = (props: object) => { + const { css } = useYoshiki(); + + return ( + theme.dark.background, + marginX: ItemList.layout.gap, + }, + props, + )} + > + + + + + + + ); +}; + +ItemList.layout = { + numColumns: 1, + size: 300, + layout: "vertical", + gap: ts(2), +} satisfies Layout; diff --git a/front/src/primitives/utils/breakpoint.ts b/front/src/primitives/utils/breakpoint.ts new file mode 100644 index 00000000..49768ecc --- /dev/null +++ b/front/src/primitives/utils/breakpoint.ts @@ -0,0 +1,41 @@ +import { useWindowDimensions } from "react-native"; +import { type Breakpoints as YoshikiBreakpoint, breakpoints, isBreakpoints } from "yoshiki/native"; + +type AtLeastOne }> = Partial & U[keyof U]; +export type Breakpoint = T | AtLeastOne>; + +// 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 = (value: Breakpoint, breakpoint: number): T => { + if (!isBreakpoints(value)) return value; + const bpKeys = Object.keys(breakpoints) as Array>; + 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 = (value: Breakpoint): T => { + const breakpoint = useBreakpoint(); + return getBreakpointValue(value, breakpoint); +}; + +export const useBreakpointMap = >( + value: T, +): { [key in keyof T]: T[key] extends Breakpoint ? V : T } => { + const breakpoint = useBreakpoint(); + // @ts-ignore + return Object.fromEntries( + Object.entries(value).map(([key, val]) => [key, getBreakpointValue(val, breakpoint)]), + ); +}; diff --git a/front/src/primitives/utils/index.tsx b/front/src/primitives/utils/index.tsx index 9e3bea8e..5d553a93 100644 --- a/front/src/primitives/utils/index.tsx +++ b/front/src/primitives/utils/index.tsx @@ -4,3 +4,4 @@ export * from "./spacing"; export * from "./capitalize"; export * from "./touchonly"; export * from "./page-style"; +export * from "./breakpoint"; diff --git a/front/src/providers/error-provider.tsx b/front/src/providers/error-provider.tsx index f4e4e753..132ffdb7 100644 --- a/front/src/providers/error-provider.tsx +++ b/front/src/providers/error-provider.tsx @@ -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 & { key?: Error["key"] }) => + const set = ({ key: nKey, ...obj }: Omit & { key?: Error["key"] } = {}) => setError({ key: nKey ?? key, ...obj }); const clear = () => clearError(key); return [set, clear] as const; diff --git a/front/src/query/fetch-infinite.tsx b/front/src/query/fetch-infinite.tsx new file mode 100644 index 00000000..ca5f6026 --- /dev/null +++ b/front/src/query/fetch-infinite.tsx @@ -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; + size: Breakpoint; + gap: Breakpoint; + layout: "grid" | "horizontal" | "vertical"; +}; + +export const InfiniteFetchList = ({ + query, + placeholderCount = 2, + incremental = false, + Render, + Loader, + layout, + Empty, + divider, + Header, + headerProps, + getItemType, + getItemSize, + fetchMore = true, + nested = false, + ...props +}: { + query: ReturnType>; + 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 | 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(undefined); + let { items, isPaused, error, fetchNextPage, isFetching, refetch, isRefetching } = query; + if (incremental && items) oldItems.current = items; + + if (isPaused) setOffline(); + else clearOffline(); + + if (error) return ; + + 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 ( + + item ? : + } + 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 = ({ + query, + ...props +}: { + query: QueryIdentifier<_, Data>; +} & Omit>, "query">) => { + if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch."); + + const ret = useInfiniteFetch(query); + return ; +}; diff --git a/front/src/query/index.tsx b/front/src/query/index.tsx index db4c7731..63bad09f 100644 --- a/front/src/query/index.tsx +++ b/front/src/query/index.tsx @@ -1,2 +1,3 @@ export * from "./query"; export * from "./fetch"; +export * from "./fetch-infinite"; diff --git a/front/src/ui/browse/index.tsx b/front/src/ui/browse/index.tsx index 00ba0ca9..ab31056b 100644 --- a/front/src/ui/browse/index.tsx +++ b/front/src/ui/browse/index.tsx @@ -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 & ComponentProps => ({ @@ -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); diff --git a/front/src/ui/browse/list.tsx b/front/src/ui/browse/list.tsx index 217d3930..7619d65a 100644 --- a/front/src/ui/browse/list.tsx +++ b/front/src/ui/browse/list.tsx @@ -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,