search: added filter button
continuous-integration/drone/push Build is failing Details

nextui
Guus van Meerveld 2 months ago
parent ec1b131c3c
commit 306585ad98

@ -7,7 +7,7 @@ import { Card, CardBody } from "@nextui-org/card";
import { Image } from "@nextui-org/image"; import { Image } from "@nextui-org/image";
import Link from "next/link"; import Link from "next/link";
import NextImage from "next/image"; import NextImage from "next/image";
import formatViewCount from "@/utils/formatViewCount"; import formatBigNumber from "@/utils/formatBigNumber";
export const Channel: Component<{ data: ChannelProps }> = ({ data }) => { export const Channel: Component<{ data: ChannelProps }> = ({ data }) => {
const url = `/channel/${data.id}`; const url = `/channel/${data.id}`;
@ -32,9 +32,9 @@ export const Channel: Component<{ data: ChannelProps }> = ({ data }) => {
<div className="flex-1 flex flex-col justify-center"> <div className="flex-1 flex flex-col justify-center">
<h1 className="text-lg">{data.name}</h1> <h1 className="text-lg">{data.name}</h1>
<div className="flex flex-row gap-4 items-center font-semibold text-default-600"> <div className="flex flex-row gap-4 items-center font-semibold text-default-600">
<h1>{formatViewCount(data.subscribers)} subscribers</h1> <h1>{formatBigNumber(data.subscribers)} subscribers</h1>
{data.videos !== 0 && ( {data.videos !== 0 && (
<h1>{formatViewCount(data.videos)} videos</h1> <h1>{formatBigNumber(data.videos)} videos</h1>
)} )}
</div> </div>
<p className="text-default-600">{data.description}</p> <p className="text-default-600">{data.description}</p>

@ -0,0 +1,56 @@
import { SearchType } from "@/client/typings/search/options";
import { Component } from "@/typings/component";
import { FiFilter as FilterIcon } from "react-icons/fi";
import { Button } from "@nextui-org/button";
import {
Dropdown,
DropdownTrigger,
DropdownMenu,
DropdownItem
} from "@nextui-org/dropdown";
import { useMemo } from "react";
export const Filter: Component<{
filter: SearchType;
setFilter: (filter: SearchType) => void;
}> = ({ setFilter, filter }) => {
const filterMenuItems: { key: SearchType; label: string }[] = useMemo(
() => [
{ key: "all", label: "All" },
{ key: "video", label: "Videos" },
{ key: "channel", label: "Channels" },
{ key: "playlist", label: "Playlists" }
],
[]
);
return (
<Dropdown>
<DropdownTrigger>
<Button className="h-full" variant="bordered" isIconOnly>
<FilterIcon className="text-xl" />
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="Static Actions"
items={filterMenuItems}
selectionMode="single"
selectedKeys={[filter]}
onSelectionChange={(keys) => {
const selectedKeys = keys as Set<SearchType>;
const selectedKey = Array.from(selectedKeys)[0];
if (!selectedKey) return;
setFilter(selectedKey);
}}
>
{(item) => <DropdownItem key={item.key}>{item.label}</DropdownItem>}
</DropdownMenu>
</Dropdown>
);
};

@ -5,49 +5,74 @@ import { useClient } from "@/hooks/useClient";
import { Component } from "@/typings/component"; import { Component } from "@/typings/component";
import { Spacer } from "@nextui-org/spacer"; import { Spacer } from "@nextui-org/spacer";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation";
import { Channel } from "./Channel"; import { Channel } from "./Channel";
import { Container } from "@/components/Container"; import { Container } from "@/components/Container";
import { LoadingPage } from "@/components/LoadingPage"; import { LoadingPage } from "@/components/LoadingPage";
import { Button } from "@nextui-org/button"; import { Button } from "@nextui-org/button";
import { Video } from "./Video"; import { Video } from "./Video";
import { Playlist } from "./Playlist"; import { Playlist } from "./Playlist";
import { Fragment, useCallback } from "react"; import { Fragment, useCallback, useMemo } from "react";
import { CircularProgress } from "@nextui-org/progress";
import { useVisibility } from "reactjs-visibility";
import { Loading } from "./Loading"; import { Loading } from "./Loading";
import { Filter } from "./Filter";
import { useSearchParams } from "next/navigation";
import { SearchType, SearchTypeModel } from "@/client/typings/search/options";
import { useSearch } from "@/hooks/useSearch";
export const Search: Component = () => { export const Search: Component = () => {
const client = useClient();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const query = searchParams.get("search_query"); const query = searchParams.get("search_query") as string;
const client = useClient(); 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 { const {
data, data,
error, error: fetchError,
fetchNextPage, fetchNextPage,
hasNextPage, hasNextPage,
refetch, refetch,
isFetching, isFetching,
isFetchingNextPage isFetchingNextPage
} = useInfiniteQuery({ } = useInfiniteQuery({
queryKey: ["search", query], queryKey: ["search", query, filter],
queryFn: async ({ pageParam }) => { queryFn: async ({ pageParam }) => {
return await client.getSearch(query ?? "", { pageParam: pageParam }); return await client.getSearch(query ?? "", {
pageParam: pageParam,
type: filter
});
}, },
enabled: query !== null, enabled: !!invalidQuery || !!invalidFilter,
initialPageParam: "", initialPageParam: "",
getNextPageParam: (lastPage, pages) => lastPage.nextCursor getNextPageParam: (lastPage, pages) => lastPage.nextCursor
}); });
const handleUserReachedPageEnd = useCallback( const error = invalidQuery ?? invalidFilter ?? fetchError ?? undefined;
(visiblity: boolean) => {
console.log(visiblity);
console.log(visiblity, !isFetchingNextPage, hasNextPage); const searchFor = useSearch();
const setFilter = useCallback(
(filter: SearchType) => {
searchFor(query, filter);
},
[query]
);
const handleUserReachedPageEnd = useCallback(
(visiblity: boolean) => {
if (visiblity && !isFetchingNextPage) fetchNextPage(); if (visiblity && !isFetchingNextPage) fetchNextPage();
}, },
[hasNextPage, isFetchingNextPage] [hasNextPage, isFetchingNextPage]
@ -56,9 +81,16 @@ export const Search: Component = () => {
return ( return (
<> <>
<Container> <Container>
<SearchInput initialQueryValue={query ?? undefined} /> <div className="flex flex-row gap-2">
<div className="flex-1">
<SearchInput initialQueryValue={query ?? undefined} />
</div>
<div>
<Filter filter={filter} setFilter={setFilter} />
</div>
</div>
<Spacer y={4} /> <Spacer y={4} />
{isFetching && <LoadingPage />} {isFetching && !error && <LoadingPage />}
{error && ( {error && (
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="text-center"> <div className="text-center">
@ -74,33 +106,30 @@ export const Search: Component = () => {
</div> </div>
)} )}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{data?.pages.map((page, i) => { {!error &&
return ( data?.pages.map((page, i) => {
<Fragment key={i}> return (
{page.items.map((result) => { <Fragment key={i}>
switch (result.type) { {page.items.map((result) => {
case "channel": switch (result.type) {
return <Channel key={result.id} data={result} />; case "channel":
return <Channel key={result.id} data={result} />;
case "video":
return <Video key={result.id} data={result} />; case "video":
return <Video key={result.id} data={result} />;
case "playlist":
return <Playlist key={result.id} data={result} />; case "playlist":
} return <Playlist key={result.id} data={result} />;
})} }
</Fragment> })}
); </Fragment>
})} );
})}
<Loading <Loading
isFetching={isFetchingNextPage} isFetching={isFetchingNextPage}
onVisible={handleUserReachedPageEnd} onVisible={handleUserReachedPageEnd}
/> />
{/* {!isFetching && !isFetchingNextPage && !error && (
<Button onClick={() => fetchNextPage()}>Load more</Button>
)} */}
</div> </div>
</Container> </Container>
</> </>

@ -7,7 +7,7 @@ import { Image } from "@nextui-org/image";
import NextImage from "next/image"; import NextImage from "next/image";
import { useMemo } from "react"; import { useMemo } from "react";
import formatViewCount from "@/utils/formatViewCount"; import formatBigNumber from "@/utils/formatBigNumber";
import formatUploadedTime from "@/utils/formatUploadedTime"; import formatUploadedTime from "@/utils/formatUploadedTime";
import { Link } from "@nextui-org/link"; import { Link } from "@nextui-org/link";
import NextLink from "next/link"; import NextLink from "next/link";
@ -47,7 +47,7 @@ export const Video: Component<{ data: VideoProps }> = ({ data }) => {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h1 className="text-xl">{data.title}</h1> <h1 className="text-xl">{data.title}</h1>
<div className="flex flex-row gap-4 items-center font-semibold text-default-600"> <div className="flex flex-row gap-4 items-center font-semibold text-default-600">
<h1>{formatViewCount(data.views)} views</h1> <h1>{formatBigNumber(data.views)} views</h1>
<h1>{formatUploadedTime(data.uploaded)}</h1> <h1>{formatUploadedTime(data.uploaded)}</h1>
</div> </div>
<Link <Link

@ -1,6 +1,12 @@
import z from "zod";
export interface SearchOptions { export interface SearchOptions {
pageParam?: string; pageParam?: string;
type?: SearchType; type?: SearchType;
} }
export type SearchType = "video" | "playlist" | "channel" | "all"; export const searchTypes = ["video", "playlist", "channel", "all"] as const;
export const SearchTypeModel = z.enum(searchTypes);
export type SearchType = z.infer<typeof SearchTypeModel>;

@ -8,7 +8,7 @@ import { useCallback, useMemo, useState } from "react";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { FiSearch as SearchIcon } from "react-icons/fi"; import { FiSearch as SearchIcon } from "react-icons/fi";
import { useRouter } from "next/navigation"; import { useSearch } from "@/hooks/useSearch";
export const Search: Component<{ export const Search: Component<{
initialQueryValue?: string; initialQueryValue?: string;
@ -17,7 +17,7 @@ export const Search: Component<{
const [searchQuery, setSearchQuery] = useState(initialQueryValue ?? ""); const [searchQuery, setSearchQuery] = useState(initialQueryValue ?? "");
const router = useRouter(); const searchFor = useSearch();
const [searchQueryDebounced] = useDebounce(searchQuery, 250); const [searchQueryDebounced] = useDebounce(searchQuery, 250);
@ -31,7 +31,7 @@ export const Search: Component<{
}); });
const submit = useCallback((query: string) => { const submit = useCallback((query: string) => {
router.push(`/results?search_query=${query}`); searchFor(query);
}, []); }, []);
const suggestions = useMemo( const suggestions = useMemo(

@ -4,7 +4,7 @@ import { Card, CardFooter, CardBody } from "@nextui-org/card";
import { Image } from "@nextui-org/image"; import { Image } from "@nextui-org/image";
import { Divider } from "@nextui-org/divider"; import { Divider } from "@nextui-org/divider";
import Link from "next/link"; import Link from "next/link";
import formatViewCount from "@/utils/formatViewCount"; import formatBigNumber from "@/utils/formatBigNumber";
import formatDuration from "@/utils/formatDuration"; import formatDuration from "@/utils/formatDuration";
import { ContextMenu } from "./ContextMenu"; import { ContextMenu } from "./ContextMenu";
import formatUploadedTime from "@/utils/formatUploadedTime"; import formatUploadedTime from "@/utils/formatUploadedTime";
@ -89,7 +89,7 @@ export const Video: Component<{ data: VideoProps }> = ({ data }) => {
</Tooltip> </Tooltip>
<p className="text-small tracking-tight text-default-400"> <p className="text-small tracking-tight text-default-400">
Views: {formatViewCount(data.views)} Views: {formatBigNumber(data.views)}
</p> </p>
</div> </div>
</div> </div>

@ -0,0 +1,17 @@
import { SearchType } from "@/client/typings/search/options";
import { useRouter } from "next/navigation";
const searchPathname = "/results";
export const useSearch = (): ((query: string, filter?: SearchType) => void) => {
const router = useRouter();
return (query, filter = "all") => {
const params = new URLSearchParams();
params.set("search_query", query);
params.set("filter", filter);
router.push(searchPathname + "?" + params.toString());
};
};

@ -0,0 +1,26 @@
const billion = 1.0e9;
const million = 1.0e6;
const thousand = 1.0e3;
const formatBigNumber = (num: number): string => {
const abs = Math.abs(num);
// Nine Zeroes for Billions
if (abs >= billion) return (abs / billion).toPrecision(3) + "B";
if (abs >= million) {
if (abs >= million * 10) return (abs / million).toPrecision(3) + "M";
return (abs / million).toPrecision(2) + "M";
}
if (abs >= thousand) {
if (abs >= thousand * 10) return (abs / thousand).toPrecision(3) + "K";
return (abs / thousand).toPrecision(2) + "K";
}
return abs.toString();
};
export default formatBigNumber;

@ -1,14 +0,0 @@
const formatViewCount = (num: number): string => {
// Nine Zeroes for Billions
return Math.abs(num) >= 1.0e9
? (Math.abs(num) / 1.0e9).toPrecision(3) + "B"
: // Six Zeroes for Millions
Math.abs(num) >= 1.0e6
? (Math.abs(num) / 1.0e6).toPrecision(3) + "M"
: // Three Zeroes for Thousands
Math.abs(num) >= 1.0e3
? (Math.abs(num) / 1.0e3).toPrecision(3) + "K"
: Math.abs(num).toString();
};
export default formatViewCount;
Loading…
Cancel
Save