From 96560d114c81317d018cabcc65c3ace573103669 Mon Sep 17 00:00:00 2001 From: John Ballesteros <johnshift.dev@gmail.com> Date: Thu, 26 Sep 2024 10:51:27 +0800 Subject: [PATCH] refactor: add search components --- src/app/search/page.tsx | 1 + src/search/components/search-category.tsx | 54 +++++++++++++++ src/search/components/search-input.tsx | 67 +++++++++++++++++++ src/search/components/search-result.tsx | 36 ++++++++++ .../components/search-results-skeleton.tsx | 29 ++++++++ src/search/components/search-results.tsx | 26 +++++++ src/search/core/atoms.ts | 3 + src/search/core/query-keys.ts | 6 ++ src/search/core/schemas.ts | 16 +++++ src/search/data/search.ts | 6 ++ src/search/hooks/use-search-input.ts | 19 ++++++ src/search/hooks/use-search-results.ts | 24 +++++++ src/search/pages/search-page.tsx | 11 +++ src/search/testutils/fake-search-results.ts | 49 ++++++++++++++ src/shared/components/icons/close-icon.tsx | 18 +++++ .../components/icons/seach-input-icon.tsx | 20 ------ 16 files changed, 365 insertions(+), 20 deletions(-) create mode 100644 src/app/search/page.tsx create mode 100644 src/search/components/search-category.tsx create mode 100644 src/search/components/search-input.tsx create mode 100644 src/search/components/search-result.tsx create mode 100644 src/search/components/search-results-skeleton.tsx create mode 100644 src/search/components/search-results.tsx create mode 100644 src/search/core/atoms.ts create mode 100644 src/search/core/query-keys.ts create mode 100644 src/search/core/schemas.ts create mode 100644 src/search/data/search.ts create mode 100644 src/search/hooks/use-search-input.ts create mode 100644 src/search/hooks/use-search-results.ts create mode 100644 src/search/pages/search-page.tsx create mode 100644 src/search/testutils/fake-search-results.ts create mode 100644 src/shared/components/icons/close-icon.tsx delete mode 100644 src/shared/components/icons/seach-input-icon.tsx diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx new file mode 100644 index 00000000..d842ceb0 --- /dev/null +++ b/src/app/search/page.tsx @@ -0,0 +1 @@ +export { SearchPage as default } from '@/search/pages/search-page'; diff --git a/src/search/components/search-category.tsx b/src/search/components/search-category.tsx new file mode 100644 index 00000000..f51c08f9 --- /dev/null +++ b/src/search/components/search-category.tsx @@ -0,0 +1,54 @@ +'use client'; + +import Link from 'next/link'; + +import { Button } from '@nextui-org/react'; + +import { ExternalIcon } from '@/shared/components/icons/external-icon'; + +import { SearchResultDto } from '@/search/core/schemas'; + +const escapeRegExp = (str: string): string => + str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const highlightText = (text: string, query: string): React.ReactNode => { + if (!query) return text; + + const escapedQuery = escapeRegExp(query); + const regex = new RegExp(`(${escapedQuery})`, 'gi'); + const parts = text.split(regex); + + return ( + <div className=""> + {parts.map((part, index) => + regex.test(part) ? ( + <span key={index} className="font-bold text-[#98eebe]"> + {part} + </span> + ) : ( + <span key={index}>{part}</span> + ), + )} + </div> + ); +}; + +type Category = SearchResultDto['categories'][number]; + +interface Props extends Category { + query: string; +} + +export const SearchCategory = ({ label, url, query }: Props) => { + return ( + <Button + as={Link} + href={url} + size="sm" + className="" + endContent={<ExternalIcon />} + > + {highlightText(label, query)} + </Button> + ); +}; diff --git a/src/search/components/search-input.tsx b/src/search/components/search-input.tsx new file mode 100644 index 00000000..5cc448e6 --- /dev/null +++ b/src/search/components/search-input.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { Button, Input } from '@nextui-org/react'; + +import { CloseIcon } from '@/shared/components/icons/close-icon'; +import { SearchIcon } from '@/shared/components/icons/sidebar-search-icon'; + +import { useSearchInput } from '@/search/hooks/use-search-input'; + +export const SearchInput = () => { + const { value, onChange, onClear } = useSearchInput(); + + return ( + <Input + placeholder="Search ..." + startContent={ + <div className="shrink-0"> + <SearchIcon /> + </div> + } + endContent={ + value ? ( + <div className="flex items-center gap-2"> + <span>Cancel</span> + <Button isIconOnly size="sm" onClick={onClear}> + <CloseIcon /> + </Button> + </div> + ) : null + } + value={value} + onChange={onChange} + /> + ); +}; + +// 'use client'; + +// import { Button } from '@nextui-org/react'; + +// import { CloseIcon } from '@/shared/components/icons/close-icon'; +// import { SearchIcon } from '@/shared/components/icons/sidebar-search-icon'; + +// import { useSearchInput } from '@/search/hooks/use-search-input'; + +// export const SearchInput = () => { +// const { value, onChange } = useSearchInput(); + +// return ( +// <div className="flex items-center gap-4 rounded-xl bg-white/5 p-2"> +// <SearchIcon /> +// <input +// type="text" +// placeholder="Search ..." +// className="size-full grow bg-transparent text-white/90" +// value={value} +// onChange={onChange} +// /> +// <div className="flex items-center gap-2"> +// <span>Cancel</span> +// <Button isIconOnly size="sm"> +// <CloseIcon /> +// </Button> +// </div> +// </div> +// ); +// }; diff --git a/src/search/components/search-result.tsx b/src/search/components/search-result.tsx new file mode 100644 index 00000000..86051ea6 --- /dev/null +++ b/src/search/components/search-result.tsx @@ -0,0 +1,36 @@ +import { SearchResultDto } from '@/search/core/schemas'; +import { SearchCategory } from '@/search/components/search-category'; + +export const SearchResultLayout = ({ + label, + categories, +}: { + label: React.ReactNode; + categories: React.ReactNode; +}) => { + return ( + <div className="flex flex-col gap-4"> + {label} + <div className="flex flex-wrap gap-4">{categories}</div> + </div> + ); +}; + +interface Props extends SearchResultDto { + query: string; +} + +export const SearchResult = ({ query, title, categories }: Props) => { + return ( + <SearchResultLayout + label={<span>{title}</span>} + categories={ + <> + {categories.map(({ label, url }) => ( + <SearchCategory key={label} query={query} label={label} url={url} /> + ))} + </> + } + /> + ); +}; diff --git a/src/search/components/search-results-skeleton.tsx b/src/search/components/search-results-skeleton.tsx new file mode 100644 index 00000000..0c7b2ac3 --- /dev/null +++ b/src/search/components/search-results-skeleton.tsx @@ -0,0 +1,29 @@ +import { Fragment } from 'react'; + +import { Skeleton } from '@nextui-org/react'; + +import { Divider } from '@/shared/components/divider'; + +import { SearchResultLayout } from '@/search/components/search-result'; + +export const SearchResultsSkeleton = () => { + return ( + <div className="flex flex-col gap-8"> + {Array.from({ length: 5 }).map((_, i) => ( + <Fragment key={i}> + <Divider /> + <SearchResultLayout + label={<Skeleton className="h-5 w-20 rounded-md" />} + categories={ + <> + {Array.from({ length: 5 }).map((_, i) => ( + <Skeleton key={i} className="h-9 w-40 rounded-lg" /> + ))} + </> + } + /> + </Fragment> + ))} + </div> + ); +}; diff --git a/src/search/components/search-results.tsx b/src/search/components/search-results.tsx new file mode 100644 index 00000000..39c6e04c --- /dev/null +++ b/src/search/components/search-results.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { Fragment } from 'react'; + +import { Divider } from '@/shared/components/divider'; + +import { useSearchResults } from '@/search/hooks/use-search-results'; +import { SearchResult } from '@/search/components/search-result'; +import { SearchResultsSkeleton } from '@/search/components/search-results-skeleton'; + +export const SearchResults = () => { + const { query, data } = useSearchResults(); + + if (!data) return <SearchResultsSkeleton />; + + return ( + <div className="flex flex-col gap-8"> + {data.map(({ title: label, categories }) => ( + <Fragment key={label}> + <Divider /> + <SearchResult query={query} title={label} categories={categories} /> + </Fragment> + ))} + </div> + ); +}; diff --git a/src/search/core/atoms.ts b/src/search/core/atoms.ts new file mode 100644 index 00000000..55e616af --- /dev/null +++ b/src/search/core/atoms.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const searchQueryAtom = atom<string>(''); diff --git a/src/search/core/query-keys.ts b/src/search/core/query-keys.ts new file mode 100644 index 00000000..fcaad9b0 --- /dev/null +++ b/src/search/core/query-keys.ts @@ -0,0 +1,6 @@ +export const searchQueryKeys = { + all: ['search'] as const, + search: (query: string) => [...searchQueryKeys.all, 'search', query] as const, +}; + +export type SearchQueryKeys = typeof searchQueryKeys; diff --git a/src/search/core/schemas.ts b/src/search/core/schemas.ts new file mode 100644 index 00000000..20d3b663 --- /dev/null +++ b/src/search/core/schemas.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const searchResultsDtoSchema = z.array( + z.object({ + title: z.string(), + categories: z.array( + z.object({ + label: z.string(), + url: z.string(), + }), + ), + }), +); + +export type SearchResultsDto = z.infer<typeof searchResultsDtoSchema>; +export type SearchResultDto = SearchResultsDto[number]; diff --git a/src/search/data/search.ts b/src/search/data/search.ts new file mode 100644 index 00000000..8b14737a --- /dev/null +++ b/src/search/data/search.ts @@ -0,0 +1,6 @@ +import { fakeSearchResults } from '@/search/testutils/fake-search-results'; + +export const search = async (_query: string) => { + await new Promise((r) => setTimeout(r, 200)); + return fakeSearchResults(); +}; diff --git a/src/search/hooks/use-search-input.ts b/src/search/hooks/use-search-input.ts new file mode 100644 index 00000000..18959d6d --- /dev/null +++ b/src/search/hooks/use-search-input.ts @@ -0,0 +1,19 @@ +import { useAtom } from 'jotai'; + +import { searchQueryAtom } from '@/search/core/atoms'; + +export const useSearchInput = () => { + const [value, setValue] = useAtom(searchQueryAtom); + + const onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { + setValue(e.target.value); + }; + + const onClear = () => setValue(''); + + return { + value, + onChange, + onClear, + }; +}; diff --git a/src/search/hooks/use-search-results.ts b/src/search/hooks/use-search-results.ts new file mode 100644 index 00000000..f5d643aa --- /dev/null +++ b/src/search/hooks/use-search-results.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import { useAtomValue } from 'jotai'; + +import { QUERY_STALETIME } from '@/shared/core/constants'; + +import { searchQueryKeys } from '@/search/core/query-keys'; +import { searchQueryAtom } from '@/search/core/atoms'; +import { search } from '@/search/data/search'; + +export const useSearchResults = () => { + const query = useAtomValue(searchQueryAtom); + + const fetchResult = useQuery({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps + queryKey: searchQueryKeys.search(query.toLowerCase()), + queryFn: () => search(query), + staleTime: QUERY_STALETIME.DEFAULT, + }); + + return { + query, + ...fetchResult, + }; +}; diff --git a/src/search/pages/search-page.tsx b/src/search/pages/search-page.tsx new file mode 100644 index 00000000..78fefcda --- /dev/null +++ b/src/search/pages/search-page.tsx @@ -0,0 +1,11 @@ +import { SearchInput } from '@/search/components/search-input'; +import { SearchResults } from '@/search/components/search-results'; + +export const SearchPage = () => { + return ( + <div className="flex flex-col gap-8 p-8"> + <SearchInput /> + <SearchResults /> + </div> + ); +}; diff --git a/src/search/testutils/fake-search-results.ts b/src/search/testutils/fake-search-results.ts new file mode 100644 index 00000000..80c16952 --- /dev/null +++ b/src/search/testutils/fake-search-results.ts @@ -0,0 +1,49 @@ +import { SearchResultsDto } from '@/search/core/schemas'; + +export const fakeSearchResults = (): SearchResultsDto => { + return [ + { + title: 'Jobs', + categories: [ + { label: 'Creative Designer', url: '#' }, + { label: 'Staff Product Designer', url: '#' }, + { label: 'Design Customer Support', url: '#' }, + { label: 'Cyber Design', url: '#' }, + { label: 'Design Science', url: '#' }, + { label: 'Design Engineering', url: '#' }, + ], + }, + { + title: 'Organizations', + categories: [ + { label: 'Business Design', url: '#' }, + { label: 'Golang Design', url: '#' }, + { label: 'Smart Design Contracts', url: '#' }, + ], + }, + { + title: 'Projects', + categories: [ + { label: 'Analysis Design', url: '#' }, + { label: 'Project 1 Design', url: '#' }, + { label: 'Project 2 Design', url: '#' }, + ], + }, + { + title: 'Categories', + categories: [ + { label: 'Design Category 1', url: '#' }, + { label: 'Design Category 2', url: '#' }, + { label: 'Design Category 3', url: '#' }, + ], + }, + { + title: 'Skills', + categories: [ + { label: 'Design Skill 1', url: '#' }, + { label: 'Design Skill 2', url: '#' }, + { label: 'Design Skill 3', url: '#' }, + ], + }, + ]; +}; diff --git a/src/shared/components/icons/close-icon.tsx b/src/shared/components/icons/close-icon.tsx new file mode 100644 index 00000000..50c5fd33 --- /dev/null +++ b/src/shared/components/icons/close-icon.tsx @@ -0,0 +1,18 @@ +import { memo } from 'react'; + +export const CloseIcon = memo(() => ( + <svg + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M16.192 6.34375L11.949 10.5858L7.70697 6.34375L6.29297 7.75775L10.535 11.9998L6.29297 16.2418L7.70697 17.6558L11.949 13.4137L16.192 17.6558L17.606 16.2418L13.364 11.9998L17.606 7.75775L16.192 6.34375Z" + fill="white" + /> + </svg> +)); + +CloseIcon.displayName = 'CloseIcon'; diff --git a/src/shared/components/icons/seach-input-icon.tsx b/src/shared/components/icons/seach-input-icon.tsx deleted file mode 100644 index 0d1cdc92..00000000 --- a/src/shared/components/icons/seach-input-icon.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { memo } from 'react'; - -export const SearchInputIcon = memo(() => ( - <svg - xmlns="http://www.w3.org/2000/svg" - fill="none" - viewBox="0 0 24 24" - strokeWidth={1.5} - stroke="currentColor" - className="size-6" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" - /> - </svg> -)); - -SearchInputIcon.displayName = 'SearchInputIcon';