Create a search page

This commit is contained in:
Zoe Roux 2023-01-02 18:57:37 +09:00
parent 8ca120aa6f
commit 942f4f1c75
18 changed files with 308 additions and 57 deletions

View File

@ -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 = () => {
<Stack
screenOptions={{
headerTitle: () => <NavbarTitle />,
headerRight: () => <LoginAvatar />,
headerRight: () => <NavbarRight />,
headerStyle: {
backgroundColor: theme.appbar,
},

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
import { SearchPage } from "@kyoo/ui";
import { withRoute } from "../../utils";
export default withRoute(SearchPage);

View File

@ -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<any>(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) => {
<Hydrate state={queryState}>
<ThemeSelector>
<GlobalCssTheme />
<Layout page={<Component {...props} />} />
<Layout page={<Component {...props} />} {...layoutProps} />
</ThemeSelector>
</Hydrate>
</QueryClientProvider>

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
import { SearchPage } from "@kyoo/ui";
import { withRoute } from "~/router";
export default withRoute(SearchPage);

View File

@ -18,12 +18,13 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
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 <Data,>(
? 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<T = unknown> = {
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<Props = {}> = ComponentType<Props> & {
getFetchUrls?: (route: { [key: string]: string }) => QueryIdentifier[];
getLayout?: ({ page }: { page: ReactElement }) => JSX.Element;
getLayout?:
| ComponentType<{ page: ReactElement }>
| { Layout: ComponentType<{ page: ReactElement }>; props: object };
};
const toQueryKey = <Data,>(query: QueryIdentifier<Data>) => {
@ -134,7 +141,21 @@ export const useFetch = <Data,>(query: QueryIdentifier<Data>) => {
});
};
export const useInfiniteFetch = <Data,>(query: QueryIdentifier<Data>) => {
export const useInfiniteFetch = <Data,>(
query: QueryIdentifier<Data>,
options?: Partial<UseInfiniteQueryOptions<Data[], KyooErrors>>,
) => {
if (query.getNext) {
// eslint-disable-next-line react-hooks/rules-of-hooks
const ret = useInfiniteQuery<Data[], KyooErrors>({
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<Page<Data>, KyooErrors>({
queryKey: toQueryKey(query),
queryFn: (ctx) => queryFn(Paged(query.parser), ctx),

View File

@ -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";

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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 (
<TextInput
value={value ?? ""}
onChangeText={onChange}
placeholder={placeholder}
placeholderTextColor={placeholderTextColor ?? theme.overlay1}
{...css(
{
borderColor: (theme) => theme.accent,
borderRadius: ts(1),
borderWidth: px(1),
padding: ts(0.5),
},
props,
)}
/>
);
};

View File

@ -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<LibraryItem>,
): WithLoading<ComponentProps<typeof ItemGrid> & ComponentProps<typeof ItemList>> => {
if (item.isLoading) return item;

View File

@ -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<Movie> => ({
@ -48,4 +48,4 @@ MovieDetails.getFetchUrls = ({ slug }) => [
// ShowStaff.query(slug),
];
MovieDetails.getLayout = TransparentLayout;
MovieDetails.getLayout = { Layout: DefaultLayout, props: { transparent: true } };

View File

@ -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 }};

View File

@ -27,6 +27,7 @@ import { EmptyView, ErrorView, Layout, WithLoading } from "./fetch";
export const InfiniteFetch = <Data,>({
query,
placeholderCount = 15,
suspense = false,
horizontal = false,
children,
layout,
@ -44,14 +45,20 @@ export const InfiniteFetch = <Data,>({
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 <ErrorView error={error} />;
if (empty && items && items.length === 0) {

View File

@ -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 = <Data,>({
query,
suspense = false,
placeholderCount = 15,
children,
layout,
@ -98,6 +99,7 @@ export const InfiniteFetch = <Data,>({
...props
}: {
query: QueryIdentifier<Data>;
suspense?: boolean;
placeholderCount?: number;
layout: Layout;
horizontal?: boolean;
@ -111,7 +113,10 @@ export const InfiniteFetch = <Data,>({
}): 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 <ErrorView error={error} />;

View File

@ -22,3 +22,4 @@ export * from "./navbar";
export { BrowsePage } from "./browse";
export { MovieDetails, ShowDetails } from "./details";
export { Player } from "./player";
export { SearchPage } from "./search";

View File

@ -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 (
<>
<Navbar />
<Main
{...css({
display: "flex",
flexGrow: 1,
flexShrink: 1,
})}
>
{page}
</Main>
</>
);
};
DefaultLayout.getFetchUrls = () => [Navbar.query()];
export const TransparentLayout = ({ page }: { page: ReactElement }) => {
export const DefaultLayout = ({ page, transparent }: { page: ReactElement, transparent?: boolean }) => {
const { css } = useYoshiki();
return (
<>
<Navbar
{...css({
bg: "transparent",
position: "absolute",
top: 0,
left: 0,
right: 0,
})}
{...css(
transparent && {
bg: "transparent",
position: "absolute",
top: 0,
left: 0,
right: 0,
},
)}
/>
<Main
{...css({
@ -69,4 +51,4 @@ export const TransparentLayout = ({ page }: { page: ReactElement }) => {
</>
);
};
TransparentLayout.getFetchUrls = () => [Navbar.query()];
DefaultLayout.getFetchUrls = () => [Navbar.query()];

View File

@ -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 (
<Input
value={query}
onChange={(q) => {
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 (
<A href="/auth/login" {...tooltip(t("navbar.login"))}>
<Avatar alt={t("navbar.login")} size={30} color={theme.colors.white} />
</A>
<>
{Platform.OS === "web" ? (
<SearchBar />
) : (
<IconButton icon={Search} as={Link} href="/search" {...tooltip("navbar.search")} />
)}
<A href="/auth/login" {...tooltip(t("navbar.login"))} {...css({ marginLeft: ts(1) })}>
<Avatar alt={t("navbar.login")} size={30} color={theme.colors.white} />
</A>
</>
);
};
@ -112,7 +163,7 @@ export const Navbar = (props: Stylable) => {
}
</Fetch>
</View>
<LoginAvatar />
<Right />
</Header>
);
};

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<LibraryItem> => ({
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 = <EmptyView message={t("search.empty")} />;
if (!deferredQuery) return empty;
return (
<Suspense>
<InfiniteFetch
query={query(deferredQuery)}
suspense={!isFirst}
layout={ItemGrid.layout}
placeholderCount={15}
empty={empty}
>
{(item) => <ItemGrid {...itemMap(item)} />}
</InfiniteFetch>
</Suspense>
);
};
SearchPage.getLayout = DefaultLayout;
SearchPage.getFetchUrls = ({ q }) => (q ? [query(q)] : []);

View File

@ -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."
}
}

View File

@ -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."
}
}