diff --git a/front/apps/mobile/app/_layout.tsx b/front/apps/mobile/app/_layout.tsx index de7aea67..666a348e 100644 --- a/front/apps/mobile/app/_layout.tsx +++ b/front/apps/mobile/app/_layout.tsx @@ -21,7 +21,7 @@ import { Stack } from "expo-router"; import { ThemeSelector } from "@kyoo/primitives"; import { useTheme } from "yoshiki/native"; -import { LoginAvatar, NavbarTitle } from "@kyoo/ui"; +import { NavbarRight, NavbarTitle } from "@kyoo/ui"; import { useState } from "react"; import { QueryClientProvider } from "@tanstack/react-query"; import { createQueryClient } from "@kyoo/models"; @@ -54,7 +54,7 @@ const ThemedStack = () => { , - headerRight: () => , + headerRight: () => , headerStyle: { backgroundColor: theme.appbar, }, diff --git a/front/apps/mobile/app/search/index.tsx b/front/apps/mobile/app/search/index.tsx new file mode 100644 index 00000000..c611cb17 --- /dev/null +++ b/front/apps/mobile/app/search/index.tsx @@ -0,0 +1,24 @@ +/* + * 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 { SearchPage } from "@kyoo/ui"; +import { withRoute } from "../../utils"; + +export default withRoute(SearchPage); diff --git a/front/apps/web/src/pages/_app.tsx b/front/apps/web/src/pages/_app.tsx index 392457e9..9ee59a6e 100755 --- a/front/apps/web/src/pages/_app.tsx +++ b/front/apps/web/src/pages/_app.tsx @@ -20,7 +20,6 @@ import "../polyfill"; -import { createTheme, ThemeProvider as MTheme } from "@mui/material"; import { Hydrate, QueryClientProvider } from "@tanstack/react-query"; import { ReactNode, useState } from "react"; import NextApp, { AppContext, type AppProps } from "next/app"; @@ -92,7 +91,9 @@ const GlobalCssTheme = () => { const App = ({ Component, pageProps }: AppProps) => { const [queryClient] = useState(() => createQueryClient()); const { queryState, ...props } = superjson.deserialize(pageProps ?? { json: {} }); - const Layout = (Component as QueryPage).getLayout ?? (({ page }) => page); + const layoutInfo = (Component as QueryPage).getLayout ?? (({ page }) => page); + const { Layout, props: layoutProps } = + typeof layoutInfo === "function" ? { Layout: layoutInfo, props: {} } : layoutInfo; useMobileHover(); @@ -105,7 +106,7 @@ const App = ({ Component, pageProps }: AppProps) => { - } /> + } {...layoutProps} /> diff --git a/front/apps/web/src/pages/search/index.tsx b/front/apps/web/src/pages/search/index.tsx new file mode 100644 index 00000000..b85ad55d --- /dev/null +++ b/front/apps/web/src/pages/search/index.tsx @@ -0,0 +1,24 @@ +/* + * 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 { SearchPage } from "@kyoo/ui"; +import { withRoute } from "~/router"; + +export default withRoute(SearchPage); diff --git a/front/packages/models/src/query.tsx b/front/packages/models/src/query.tsx index b7c612f7..e6867bb5 100644 --- a/front/packages/models/src/query.tsx +++ b/front/packages/models/src/query.tsx @@ -18,12 +18,13 @@ * along with Kyoo. If not, see . */ -import { ComponentType, ReactElement, ReactNode } from "react"; +import { ComponentProps, ComponentType, ReactElement } from "react"; import { dehydrate, QueryClient, QueryFunctionContext, useInfiniteQuery, + UseInfiniteQueryOptions, useQuery, } from "@tanstack/react-query"; import { z } from "zod"; @@ -40,8 +41,8 @@ const queryFn = async ( ? process.env.PUBLIC_BACK_URL : typeof window === "undefined" ? process.env.KYOO_URL ?? "http://localhost:5000" - // TODO remove the hardcoded fallback. This is just for testing purposes - : "/api") ?? "https://beta.sdg.moe"; + : // TODO remove the hardcoded fallback. This is just for testing purposes + "/api") ?? "https://beta.sdg.moe"; if (!kyooUrl) console.error("Kyoo's url is not defined."); let resp; @@ -105,11 +106,17 @@ export type QueryIdentifier = { path: (string | undefined)[]; params?: { [query: string]: boolean | number | string | string[] | undefined }; infinite?: boolean; + /** + * A custom get next function if the infinite query is not a page. + */ + getNext?: (item: unknown) => string | undefined; }; export type QueryPage = ComponentType & { getFetchUrls?: (route: { [key: string]: string }) => QueryIdentifier[]; - getLayout?: ({ page }: { page: ReactElement }) => JSX.Element; + getLayout?: + | ComponentType<{ page: ReactElement }> + | { Layout: ComponentType<{ page: ReactElement }>; props: object }; }; const toQueryKey = (query: QueryIdentifier) => { @@ -134,7 +141,21 @@ export const useFetch = (query: QueryIdentifier) => { }); }; -export const useInfiniteFetch = (query: QueryIdentifier) => { +export const useInfiniteFetch = ( + query: QueryIdentifier, + options?: Partial>, +) => { + if (query.getNext) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const ret = useInfiniteQuery({ + queryKey: toQueryKey(query), + queryFn: (ctx) => queryFn(z.array(query.parser), ctx), + getNextPageParam: query.getNext, + ...options, + }); + return { ...ret, items: ret.data?.pages.flatMap((x) => x) }; + } + // eslint-disable-next-line react-hooks/rules-of-hooks const ret = useInfiniteQuery, KyooErrors>({ queryKey: toQueryKey(query), queryFn: (ctx) => queryFn(Paged(query.parser), ctx), diff --git a/front/packages/primitives/src/index.ts b/front/packages/primitives/src/index.ts index 8f97aef1..69f3d97b 100644 --- a/front/packages/primitives/src/index.ts +++ b/front/packages/primitives/src/index.ts @@ -32,6 +32,7 @@ export * from "./divider"; export * from "./progress"; export * from "./slider"; export * from "./menu"; +export * from "./input"; export * from "./animated"; export * from "./utils"; diff --git a/front/packages/primitives/src/input.tsx b/front/packages/primitives/src/input.tsx new file mode 100644 index 00000000..1e23083f --- /dev/null +++ b/front/packages/primitives/src/input.tsx @@ -0,0 +1,56 @@ +/* + * 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 { TextInput } from "react-native"; +import { px, Stylable, useYoshiki } from "yoshiki/native"; +import { ts } from "./utils"; + +export const Input = ({ + onChange, + value, + placeholder, + placeholderTextColor, + ...props +}: { + onChange: (value: string) => void; + value?: string; + placeholder?: string; + placeholderTextColor?: string; +} & Stylable) => { + const { css, theme } = useYoshiki(); + + return ( + theme.accent, + borderRadius: ts(1), + borderWidth: px(1), + padding: ts(0.5), + }, + props, + )} + /> + ); +}; diff --git a/front/packages/ui/src/browse/index.tsx b/front/packages/ui/src/browse/index.tsx index 2fc1fcea..e668aca7 100644 --- a/front/packages/ui/src/browse/index.tsx +++ b/front/packages/ui/src/browse/index.tsx @@ -34,7 +34,7 @@ import { ItemGrid } from "./grid"; import { ItemList } from "./list"; import { SortBy, SortOrd, Layout } from "./types"; -const itemMap = ( +export const itemMap = ( item: WithLoading, ): WithLoading & ComponentProps> => { if (item.isLoading) return item; diff --git a/front/packages/ui/src/details/movie.tsx b/front/packages/ui/src/details/movie.tsx index df90fa85..5a29681d 100644 --- a/front/packages/ui/src/details/movie.tsx +++ b/front/packages/ui/src/details/movie.tsx @@ -21,7 +21,7 @@ import { Movie, MovieP, QueryIdentifier, QueryPage } from "@kyoo/models"; import { Platform, ScrollView } from "react-native"; import { useYoshiki } from "yoshiki/native"; -import { TransparentLayout } from "../layout"; +import { DefaultLayout } from "../layout"; import { Header } from "./header"; const query = (slug: string): QueryIdentifier => ({ @@ -48,4 +48,4 @@ MovieDetails.getFetchUrls = ({ slug }) => [ // ShowStaff.query(slug), ]; -MovieDetails.getLayout = TransparentLayout; +MovieDetails.getLayout = { Layout: DefaultLayout, props: { transparent: true } }; diff --git a/front/packages/ui/src/details/show.tsx b/front/packages/ui/src/details/show.tsx index 1b23769c..d93faeca 100644 --- a/front/packages/ui/src/details/show.tsx +++ b/front/packages/ui/src/details/show.tsx @@ -19,9 +19,9 @@ */ import { QueryIdentifier, QueryPage, Show, ShowP } from "@kyoo/models"; -import { Platform, ScrollView, View, ViewProps } from "react-native"; +import { Platform, View, ViewProps } from "react-native"; import { percent, useYoshiki, vh } from "yoshiki/native"; -import { TransparentLayout } from "../layout"; +import { DefaultLayout } from "../layout"; import { EpisodeList, SeasonTab } from "./season"; import { Header } from "./header"; import Svg, { Path, SvgProps } from "react-native-svg"; @@ -89,7 +89,7 @@ export const ShowDetails: QueryPage<{ slug: string; season: string }> = ({ slug, ShowDetails.getFetchUrls = ({ slug, season = 1 }) => [ query(slug), // ShowStaff.query(slug), - // EpisodeGrid.query(slug, season), + EpisodeList.query(slug, season), ]; -ShowDetails.getLayout = TransparentLayout; +ShowDetails.getLayout = { Layout: DefaultLayout, props: { transparent: true }}; diff --git a/front/packages/ui/src/fetch-infinite.tsx b/front/packages/ui/src/fetch-infinite.tsx index 6fc94b2c..2391cd67 100644 --- a/front/packages/ui/src/fetch-infinite.tsx +++ b/front/packages/ui/src/fetch-infinite.tsx @@ -27,6 +27,7 @@ import { EmptyView, ErrorView, Layout, WithLoading } from "./fetch"; export const InfiniteFetch = ({ query, placeholderCount = 15, + suspense = false, horizontal = false, children, layout, @@ -44,14 +45,20 @@ export const InfiniteFetch = ({ i: number, ) => ReactElement | null; empty?: string | JSX.Element; + suspense?: boolean; divider?: boolean | ComponentType; Header?: ComponentType<{ children: JSX.Element }>; }): JSX.Element | null => { if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch."); const { numColumns, size } = useBreakpointMap(layout); - const { items, error, fetchNextPage, hasNextPage, refetch, isRefetching } = - useInfiniteFetch(query); + const { items, error, fetchNextPage, hasNextPage, refetch, isRefetching } = useInfiniteFetch( + query, + { + suspense: suspense, + useErrorBoundary: false, + }, + ); if (error) return ; if (empty && items && items.length === 0) { diff --git a/front/packages/ui/src/fetch-infinite.web.tsx b/front/packages/ui/src/fetch-infinite.web.tsx index 11da71d7..472e4ba4 100644 --- a/front/packages/ui/src/fetch-infinite.web.tsx +++ b/front/packages/ui/src/fetch-infinite.web.tsx @@ -20,7 +20,7 @@ import { Page, QueryIdentifier, useInfiniteFetch } from "@kyoo/models"; import { HR } from "@kyoo/primitives"; -import { ComponentType, Fragment, ReactElement, useRef } from "react"; +import { ComponentType, Fragment, ReactElement, useMemo, useRef } from "react"; import { Stylable, useYoshiki } from "yoshiki"; import { EmptyView, ErrorView, Layout, WithLoading } from "./fetch"; @@ -88,6 +88,7 @@ const InfiniteScroll = ({ export const InfiniteFetch = ({ query, + suspense = false, placeholderCount = 15, children, layout, @@ -98,6 +99,7 @@ export const InfiniteFetch = ({ ...props }: { query: QueryIdentifier; + suspense?: boolean; placeholderCount?: number; layout: Layout; horizontal?: boolean; @@ -111,7 +113,10 @@ export const InfiniteFetch = ({ }): JSX.Element | null => { if (!query.infinite) console.warn("A non infinite query was passed to an InfiniteFetch."); - const { items, error, fetchNextPage, hasNextPage, isFetching } = useInfiniteFetch(query); + const { items, error, fetchNextPage, hasNextPage, isFetching } = useInfiniteFetch(query, { + suspense: suspense, + useErrorBoundary: false, + }); const grid = layout.numColumns !== 1; if (error) return ; diff --git a/front/packages/ui/src/index.ts b/front/packages/ui/src/index.ts index 65e1d0f1..ca995b98 100644 --- a/front/packages/ui/src/index.ts +++ b/front/packages/ui/src/index.ts @@ -22,3 +22,4 @@ export * from "./navbar"; export { BrowsePage } from "./browse"; export { MovieDetails, ShowDetails } from "./details"; export { Player } from "./player"; +export { SearchPage } from "./search"; diff --git a/front/packages/ui/src/layout.tsx b/front/packages/ui/src/layout.tsx index f96f11eb..986b5e8b 100644 --- a/front/packages/ui/src/layout.tsx +++ b/front/packages/ui/src/layout.tsx @@ -23,39 +23,21 @@ import { Navbar } from "./navbar"; import { useYoshiki } from "yoshiki/native"; import { Main } from "@kyoo/primitives"; -export const DefaultLayout = ({ page }: { page: ReactElement }) => { - const { css } = useYoshiki(); - - return ( - <> - -
- {page} -
- - ); -}; -DefaultLayout.getFetchUrls = () => [Navbar.query()]; - -export const TransparentLayout = ({ page }: { page: ReactElement }) => { +export const DefaultLayout = ({ page, transparent }: { page: ReactElement, transparent?: boolean }) => { const { css } = useYoshiki(); return ( <>
{ ); }; -TransparentLayout.getFetchUrls = () => [Navbar.query()]; +DefaultLayout.getFetchUrls = () => [Navbar.query()]; diff --git a/front/packages/ui/src/navbar/index.tsx b/front/packages/ui/src/navbar/index.tsx index d32977df..1e34e694 100644 --- a/front/packages/ui/src/navbar/index.tsx +++ b/front/packages/ui/src/navbar/index.tsx @@ -19,13 +19,27 @@ */ import { Library, LibraryP, Page, Paged, QueryIdentifier } from "@kyoo/models"; -import { IconButton, Header, Avatar, A, Skeleton, tooltip, ts } from "@kyoo/primitives"; -import { View } from "react-native"; +import { + Input, + IconButton, + Header, + Avatar, + A, + Skeleton, + tooltip, + ts, + Link, +} from "@kyoo/primitives"; +import { Platform, View } from "react-native"; import { useTranslation } from "react-i18next"; +import { createParam } from "solito"; +import { useRouter } from "solito/router"; import { rem, Stylable, useTheme, useYoshiki } from "yoshiki/native"; import Menu from "@material-symbols/svg-400/rounded/menu-fill.svg"; +import Search from "@material-symbols/svg-400/rounded/search-fill.svg"; import { Fetch } from "../fetch"; import { KyooLongLogo } from "./icon"; +import { useState } from "react"; export const NavbarTitle = (props: Stylable) => { const { t } = useTranslation(); @@ -37,14 +51,51 @@ export const NavbarTitle = (props: Stylable) => { ); }; -export const LoginAvatar = (props: Stylable) => { +const { useParam } = createParam<{ q?: string }>(); + +const SearchBar = () => { + const { css, theme } = useYoshiki(); + const { t } = useTranslation(); + const { push, replace, back } = useRouter(); + // eslint-disable-next-line react-hooks/rules-of-hooks + // const [query, setQuery] = Platform.OS === "web" ? useState("") : useParam("q"); + const [query, setQuery] = useParam("q"); + + return ( + { + setQuery(q); + if (Platform.OS === "web") { + const action = window.location.pathname.startsWith("/search") ? replace : push; + if (q) action(`/search?q=${q}`, undefined, { shallow: true }); + else back(); + } + }} + placeholder={t("navbar.search")} + placeholderTextColor={theme.light.overlay0} + {...tooltip(t("navbar.search"))} + {...css({ borderColor: (theme) => theme.colors.white })} + /> + ); +}; + +const Right = () => { const theme = useTheme(); + const { css } = useYoshiki(); const { t } = useTranslation(); return ( - - - + <> + {Platform.OS === "web" ? ( + + ) : ( + + )} + + + + ); }; @@ -112,7 +163,7 @@ export const Navbar = (props: Stylable) => { } - + ); }; diff --git a/front/packages/ui/src/search/index.tsx b/front/packages/ui/src/search/index.tsx new file mode 100644 index 00000000..10fe1171 --- /dev/null +++ b/front/packages/ui/src/search/index.tsx @@ -0,0 +1,70 @@ +/* + * 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 { LibraryItem, LibraryItemP, QueryIdentifier, QueryPage } from "@kyoo/models"; +import { Suspense, useRef, useDeferredValue } from "react"; +import { useTranslation } from "react-i18next"; +import { ItemGrid } from "../browse/grid"; +import { itemMap } from "../browse/index"; +import { EmptyView } from "../fetch"; +import { InfiniteFetch } from "../fetch-infinite"; +import { DefaultLayout } from "../layout"; + +const useIsFirstRender = () => { + const isFirst = useRef(true); + + if (isFirst.current) { + isFirst.current = false; + return true; + } + return false; +}; + +const query = (query: string): QueryIdentifier => ({ + parser: LibraryItemP, + path: ["search", query, "items"], + infinite: true, + getNext: () => undefined, +}); + +export const SearchPage: QueryPage<{ q?: string }> = ({ q }) => { + const deferredQuery = useDeferredValue(q); + const { t } = useTranslation(); + const isFirst = useIsFirstRender(); + + const empty = ; + if (!deferredQuery) return empty; + return ( + + + {(item) => } + + + ); +}; + +SearchPage.getLayout = DefaultLayout; +SearchPage.getFetchUrls = ({ q }) => (q ? [query(q)] : []); diff --git a/front/translations/en.json b/front/translations/en.json index 4a063675..ef2ff1a0 100644 --- a/front/translations/en.json +++ b/front/translations/en.json @@ -32,6 +32,7 @@ }, "navbar": { "home": "Home", + "search": "Search", "login": "Login" }, "player": { @@ -45,5 +46,8 @@ "subtitles": "Subtitles", "subtitle-none": "None", "fullscreen": "Fullscreen" + }, + "search": { + "empty": "No result found. Try a different query." } } diff --git a/front/translations/fr.json b/front/translations/fr.json index 80098798..dbe40e0d 100644 --- a/front/translations/fr.json +++ b/front/translations/fr.json @@ -32,6 +32,7 @@ }, "navbar": { "home": "Accueil", + "search": "Rechercher", "login": "Connexion" }, "player": { @@ -45,5 +46,8 @@ "subtitles": "Sous titres", "subtitle-none": "Aucun", "fullscreen": "Plein-écran" + }, + "search": { + "empty": "Aucun résultat trouvé. Essayer avec une autre recherche." } }