diff --git a/src/app/results/ErrorPage.tsx b/src/app/results/ErrorPage.tsx new file mode 100644 index 0000000..63ae427 --- /dev/null +++ b/src/app/results/ErrorPage.tsx @@ -0,0 +1,22 @@ +import { FC } from "react"; + +import { Button } from "@nextui-org/button"; +import { Spacer } from "@nextui-org/spacer"; + +export const ErrorPage: FC<{ data: Error; refetch: () => void }> = ({ + data: error, + refetch +}) => { + return ( +
+
+

An error occurred loading the search page

+

{error.toString()}

+ + +
+
+ ); +}; diff --git a/src/app/results/Search.tsx b/src/app/results/Search.tsx deleted file mode 100644 index eba280c..0000000 --- a/src/app/results/Search.tsx +++ /dev/null @@ -1,154 +0,0 @@ -"use client"; - -import { useInfiniteQuery } from "@tanstack/react-query"; -import { useSearchParams } from "next/navigation"; -import { Fragment, useCallback, useMemo } from "react"; - -import { Button } from "@nextui-org/button"; -import { Spacer } from "@nextui-org/spacer"; - -import { useClient } from "@/hooks/useClient"; -import { useSearch } from "@/hooks/useSearch"; - -import { SearchType, SearchTypeModel } from "@/client/typings/search/options"; - -import { Container } from "@/components/Container"; -import { LoadingPage } from "@/components/LoadingPage"; -import { Search as SearchInput } from "@/components/Search"; - -import { Channel } from "./Channel"; -import { Filter } from "./Filter"; -import { Loading } from "./Loading"; -import { Playlist } from "./Playlist"; -import { Video } from "./Video"; - -import { Component } from "@/typings/component"; - -export const Search: Component = () => { - const client = useClient(); - - const searchParams = useSearchParams(); - - const query = searchParams.get("search_query") as string; - - const invalidQuery = useMemo(() => { - if (query === null || query.length === 0) - return new Error(`The required parameter 'query' is missing`); - }, [query]); - - const filter = (searchParams.get("filter") ?? "all") as SearchType; - - const invalidFilter = useMemo(() => { - const parsed = SearchTypeModel.safeParse(filter); - - if (!parsed.success) - return new Error(`The provided filter \`${filter}\` is invalid`); - }, [filter]); - - const canSearch = !(!!invalidQuery || !!invalidFilter); - - const { - data, - error: fetchError, - fetchNextPage, - refetch, - isPending, - isFetchingNextPage - } = useInfiniteQuery({ - queryKey: ["search", query, filter], - queryFn: async ({ pageParam }) => { - return await client.getSearch(query ?? "", { - pageParam: pageParam, - type: filter - }); - }, - enabled: canSearch, - initialPageParam: "", - getNextPageParam: (lastPage) => lastPage.nextCursor - }); - - const error = invalidFilter ?? fetchError ?? undefined; - - const searchFor = useSearch(); - - const setFilter = useCallback( - (filter: SearchType) => { - searchFor(query, filter); - }, - [query, searchFor] - ); - - const handleUserReachedPageEnd = useCallback( - (visiblity: boolean) => { - if (visiblity && !isFetchingNextPage) fetchNextPage(); - }, - [isFetchingNextPage, fetchNextPage] - ); - - const hasLoadedData = - canSearch && data?.pages.flat().length - ? data?.pages.flat().length !== 0 - : false; - - const isLoadingInitialData = canSearch && !isFetchingNextPage && isPending; - - return ( - <> - -
-
- -
- {canSearch && ( -
- -
- )} -
- {isLoadingInitialData && } - {error && ( -
-
-

- An error occurred loading the search page -

-

{error.toString()}

- - -
-
- )} - {hasLoadedData && ( - <> -
- {data?.pages.map((page, i) => { - return ( - - {page.items.map((result) => { - switch (result.type) { - case "channel": - return ; - - case "video": - return - ); - })} -
- - - )} -
- - ); -}; diff --git a/src/app/results/SearchPage.tsx b/src/app/results/SearchPage.tsx new file mode 100644 index 0000000..da92e59 --- /dev/null +++ b/src/app/results/SearchPage.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { FC, useMemo } from "react"; + +import { SearchType, SearchTypeModel } from "@/client/typings/search/options"; + +import { Container } from "@/components/Container"; + +import { SearchPageBody } from "./SearchPageBody"; +import { SearchPageHeader } from "./SearchPageHeader"; + +export const SearchPage: FC = () => { + const searchParams = useSearchParams(); + + const query = useMemo(() => { + const param = searchParams.get("search_query"); + + if (param === null || param.length === null) return; + + return param; + }, [searchParams]); + + const filter: SearchType = useMemo(() => { + const param = searchParams.get("filter"); + + const parsed = SearchTypeModel.safeParse(param); + + if (!parsed.success) return "all"; + + return parsed.data; + }, [searchParams]); + + return ( + <> + + + {query && } + + + ); +}; diff --git a/src/app/results/Channel.tsx b/src/app/results/SearchPageBody/Channel.tsx similarity index 90% rename from src/app/results/Channel.tsx rename to src/app/results/SearchPageBody/Channel.tsx index 27de4d7..ab5561a 100644 --- a/src/app/results/Channel.tsx +++ b/src/app/results/SearchPageBody/Channel.tsx @@ -2,6 +2,7 @@ import NextImage from "next/image"; import Link from "next/link"; +import { FC } from "react"; import { Card, CardBody } from "@nextui-org/card"; import { Image } from "@nextui-org/image"; @@ -9,9 +10,7 @@ import { Image } from "@nextui-org/image"; import { ChannelItem } from "@/client/typings/item"; import formatBigNumber from "@/utils/formatBigNumber"; -import { Component } from "@/typings/component"; - -export const Channel: Component<{ data: ChannelItem }> = ({ data }) => { +export const Channel: FC<{ data: ChannelItem }> = ({ data }) => { const url = `/channel/${data.id}`; const imageSize = 200; diff --git a/src/app/results/Loading.tsx b/src/app/results/SearchPageBody/LoadingNextPage.tsx similarity index 92% rename from src/app/results/Loading.tsx rename to src/app/results/SearchPageBody/LoadingNextPage.tsx index 260003a..dd6b09c 100644 --- a/src/app/results/Loading.tsx +++ b/src/app/results/SearchPageBody/LoadingNextPage.tsx @@ -4,7 +4,7 @@ import { CircularProgress } from "@nextui-org/progress"; import { Component } from "@/typings/component"; -export const Loading: Component<{ +export const LoadingNextPage: Component<{ isFetching: boolean; onVisible: (visiblity: boolean) => void; }> = ({ onVisible, isFetching }) => { diff --git a/src/app/results/Playlist.tsx b/src/app/results/SearchPageBody/Playlist.tsx similarity index 94% rename from src/app/results/Playlist.tsx rename to src/app/results/SearchPageBody/Playlist.tsx index 8c27ba4..0f1a92f 100644 --- a/src/app/results/Playlist.tsx +++ b/src/app/results/SearchPageBody/Playlist.tsx @@ -2,6 +2,7 @@ import NextImage from "next/image"; import NextLink from "next/link"; +import { FC } from "react"; import { Card, CardBody } from "@nextui-org/card"; import { Image } from "@nextui-org/image"; @@ -12,9 +13,7 @@ import { PlaylistItem } from "@/client/typings/item"; import { videoUrl } from "@/utils/urls"; import { videoSize } from "@/utils/videoSize"; -import { Component } from "@/typings/component"; - -export const Playlist: Component<{ data: PlaylistItem }> = ({ data }) => { +export const Playlist: FC<{ data: PlaylistItem }> = ({ data }) => { const url = `/playlist/${data.id}`; const channelUrl = `/channel/${data.author.id}`; diff --git a/src/app/results/Video.tsx b/src/app/results/SearchPageBody/Video.tsx similarity index 94% rename from src/app/results/Video.tsx rename to src/app/results/SearchPageBody/Video.tsx index f11c1c6..027c862 100644 --- a/src/app/results/Video.tsx +++ b/src/app/results/SearchPageBody/Video.tsx @@ -2,6 +2,7 @@ import NextImage from "next/image"; import NextLink from "next/link"; +import { FC } from "react"; import { Avatar } from "@nextui-org/avatar"; import { Card, CardBody } from "@nextui-org/card"; @@ -14,9 +15,7 @@ import formatDuration from "@/utils/formatDuration"; import formatUploadedTime from "@/utils/formatUploadedTime"; import { videoSize } from "@/utils/videoSize"; -import { Component } from "@/typings/component"; - -export const Video: Component<{ data: VideoItem }> = ({ data }) => { +export const Video: FC<{ data: VideoItem }> = ({ data }) => { const url = `/watch?v=${data.id}`; const channelUrl = `/channel/${data.author.id}`; diff --git a/src/app/results/SearchPageBody/index.tsx b/src/app/results/SearchPageBody/index.tsx new file mode 100644 index 0000000..90c31a4 --- /dev/null +++ b/src/app/results/SearchPageBody/index.tsx @@ -0,0 +1,84 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { FC, Fragment, useCallback } from "react"; + +import { useClient } from "@/hooks/useClient"; + +import { SearchType } from "@/client/typings/search/options"; + +import { LoadingPage } from "@/components/LoadingPage"; + +import { ErrorPage } from "../ErrorPage"; +import { Channel } from "./Channel"; +import { LoadingNextPage } from "./LoadingNextPage"; +import { Playlist } from "./Playlist"; +import { Video } from "./Video"; + +export const SearchPageBody: FC<{ query: string; filter: SearchType }> = ({ + filter, + query +}) => { + const client = useClient(); + + const { + data, + error, + fetchNextPage, + refetch, + isPending: isFetchingInitialData, + isFetchingNextPage + } = useInfiniteQuery({ + queryKey: ["search", query, filter], + queryFn: async ({ pageParam }) => { + return await client.getSearch(query, { + pageParam: pageParam, + type: filter + }); + }, + initialPageParam: "", + getNextPageParam: (lastPage) => lastPage.nextCursor + }); + + const isFetchingNewPage = isFetchingNextPage && !isFetchingInitialData; + + const fetchNewData = useCallback( + (visiblity: boolean) => { + if (visiblity && !isFetchingNextPage) fetchNextPage(); + }, + [isFetchingNextPage, fetchNextPage] + ); + + return ( + <> + {error !== null && } + {isFetchingInitialData && ( + + )} + {error === null && data && ( +
+ {data.pages.map((page, i) => { + return ( + + {page.items.map((result) => { + switch (result.type) { + case "channel": + return ; + + case "video": + return + ); + })} + +
+ )} + + ); +}; diff --git a/src/app/results/SearchPageHeader.tsx b/src/app/results/SearchPageHeader.tsx new file mode 100644 index 0000000..8bb9a2b --- /dev/null +++ b/src/app/results/SearchPageHeader.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { FC } from "react"; + +import { useSearch } from "@/hooks/useSearch"; + +import { SearchType } from "@/client/typings/search/options"; + +import { Search as SearchInput } from "@/components/Search"; + +import { Filter } from "./Filter"; + +export const SearchPageHeader: FC<{ + query?: string; + filter?: SearchType; +}> = ({ query, filter }) => { + const searchFor = useSearch(); + + const searchForQuery = (query: string): void => { + searchFor(query, filter); + }; + + const searchWithFilter = (filter: SearchType): void => { + if (query) searchFor(query, filter); + }; + + return ( +
+
+ +
+
+ +
+
+ ); +}; diff --git a/src/app/results/page.tsx b/src/app/results/page.tsx index 330af9e..74f42e5 100644 --- a/src/app/results/page.tsx +++ b/src/app/results/page.tsx @@ -1,13 +1,22 @@ import { NextPage } from "next"; import { Suspense } from "react"; -import { Search } from "./Search"; +import { Container } from "@/components/Container"; + +import { SearchPage } from "./SearchPage"; +import { SearchPageHeader } from "./SearchPageHeader"; const Page: NextPage = () => { return ( <> - - + + + + } + > + ); diff --git a/src/components/LoadingPage.tsx b/src/components/LoadingPage.tsx index 00e073a..016e398 100644 --- a/src/components/LoadingPage.tsx +++ b/src/components/LoadingPage.tsx @@ -1,13 +1,16 @@ "use client"; -import { CircularProgress } from "@nextui-org/progress"; +import { FC } from "react"; -import { Component } from "@/typings/component"; +import { CircularProgress } from "@nextui-org/progress"; -export const LoadingPage: Component = () => { +export const LoadingPage: FC<{ text?: string }> = ({ text }) => { return (
- +
+ + {text &&

{text}

} +
); }; diff --git a/src/components/Search.tsx b/src/components/Search.tsx index c696f46..9cc0989 100644 --- a/src/components/Search.tsx +++ b/src/components/Search.tsx @@ -9,16 +9,14 @@ import { FiSearch as SearchIcon } from "react-icons/fi"; import { Autocomplete, AutocompleteItem } from "@nextui-org/autocomplete"; import { useClient } from "@/hooks/useClient"; -import { useSearch } from "@/hooks/useSearch"; export const Search: FC<{ - initialQueryValue?: string; -}> = ({ initialQueryValue }) => { + query?: string; + setQuery: (query: string) => void; +}> = ({ setQuery, query }) => { const client = useClient(); - const [searchQuery, setSearchQuery] = useState(initialQueryValue ?? ""); - - const searchFor = useSearch(); + const [searchQuery, setSearchQuery] = useState(query ?? ""); const [searchQueryDebounced] = useDebounce(searchQuery, 250); @@ -32,7 +30,7 @@ export const Search: FC<{ }); const submit = (query: string): void => { - searchFor(query); + setQuery(query); }; const suggestions = useMemo( @@ -51,7 +49,7 @@ export const Search: FC<{ name="search_query" value={searchQuery} isLoading={isLoading} - defaultInputValue={initialQueryValue} + defaultInputValue={query} onValueChange={setSearchQuery} onKeyDown={(e) => { if (e.key === "Enter") {