results: improved folder structure, better fallback
continuous-integration/drone/push Build is passing Details

nextui
Guus van Meerveld 1 month ago
parent ff694c15c5
commit cb54d9f991

@ -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 (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<h1 className="text-xl">An error occurred loading the search page</h1>
<h2 className="text-lg">{error.toString()}</h2>
<Spacer y={2} />
<Button color="primary" onClick={() => refetch()}>
Retry
</Button>
</div>
</div>
);
};

@ -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 (
<>
<Container>
<div className="flex flex-row gap-2">
<div className="flex-1">
<SearchInput initialQueryValue={query ?? undefined} />
</div>
{canSearch && (
<div>
<Filter filter={filter} setFilter={setFilter} />
</div>
)}
</div>
{isLoadingInitialData && <LoadingPage />}
{error && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<h1 className="text-xl">
An error occurred loading the search page
</h1>
<h2 className="text-lg">{error.toString()}</h2>
<Spacer y={2} />
<Button color="primary" onClick={() => refetch()}>
Retry
</Button>
</div>
</div>
)}
{hasLoadedData && (
<>
<div className="flex flex-col gap-4 mt-4">
{data?.pages.map((page, i) => {
return (
<Fragment key={i}>
{page.items.map((result) => {
switch (result.type) {
case "channel":
return <Channel key={result.id} data={result} />;
case "video":
return <Video key={result.id} data={result} />;
case "playlist":
return <Playlist key={result.id} data={result} />;
}
})}
</Fragment>
);
})}
</div>
<Loading
isFetching={isFetchingNextPage && !isPending}
onVisible={handleUserReachedPageEnd}
/>
</>
)}
</Container>
</>
);
};

@ -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 (
<>
<Container>
<SearchPageHeader query={query} filter={filter} />
{query && <SearchPageBody query={query} filter={filter} />}
</Container>
</>
);
};

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

@ -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 }) => {

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

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

@ -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 && <ErrorPage data={error} refetch={refetch} />}
{isFetchingInitialData && (
<LoadingPage text={`Fetching search results for query \`${query}\``} />
)}
{error === null && data && (
<div className="flex flex-col gap-4 mt-4">
{data.pages.map((page, i) => {
return (
<Fragment key={i}>
{page.items.map((result) => {
switch (result.type) {
case "channel":
return <Channel key={result.id} data={result} />;
case "video":
return <Video key={result.id} data={result} />;
case "playlist":
return <Playlist key={result.id} data={result} />;
}
})}
</Fragment>
);
})}
<LoadingNextPage
isFetching={isFetchingNewPage}
onVisible={fetchNewData}
/>
</div>
)}
</>
);
};

@ -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 (
<div className="flex flex-row gap-2">
<div className="flex-1">
<SearchInput query={query} setQuery={searchForQuery} />
</div>
<div>
<Filter filter={filter ?? "all"} setFilter={searchWithFilter} />
</div>
</div>
);
};

@ -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 (
<>
<Suspense>
<Search />
<Suspense
fallback={
<Container>
<SearchPageHeader />
</Container>
}
>
<SearchPage />
</Suspense>
</>
);

@ -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 (
<div className="flex flex-1 justify-center items-center">
<CircularProgress aria-label="Loading page..." />
<div className="flex flex-col gap-2 items-center">
<CircularProgress aria-label="Loading page..." />
{text && <p className="text-xl">{text}</p>}
</div>
</div>
);
};

@ -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") {

Loading…
Cancel
Save