Skip to content

Commit

Permalink
refactor: add search components
Browse files Browse the repository at this point in the history
  • Loading branch information
johnshift committed Sep 26, 2024
1 parent a6a2927 commit 96560d1
Show file tree
Hide file tree
Showing 16 changed files with 365 additions and 20 deletions.
1 change: 1 addition & 0 deletions src/app/search/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { SearchPage as default } from '@/search/pages/search-page';
54 changes: 54 additions & 0 deletions src/search/components/search-category.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
67 changes: 67 additions & 0 deletions src/search/components/search-input.tsx
Original file line number Diff line number Diff line change
@@ -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>
// );
// };
36 changes: 36 additions & 0 deletions src/search/components/search-result.tsx
Original file line number Diff line number Diff line change
@@ -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} />
))}
</>
}
/>
);
};
29 changes: 29 additions & 0 deletions src/search/components/search-results-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
26 changes: 26 additions & 0 deletions src/search/components/search-results.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
3 changes: 3 additions & 0 deletions src/search/core/atoms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { atom } from 'jotai';

export const searchQueryAtom = atom<string>('');
6 changes: 6 additions & 0 deletions src/search/core/query-keys.ts
Original file line number Diff line number Diff line change
@@ -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;
16 changes: 16 additions & 0 deletions src/search/core/schemas.ts
Original file line number Diff line number Diff line change
@@ -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];
6 changes: 6 additions & 0 deletions src/search/data/search.ts
Original file line number Diff line number Diff line change
@@ -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();
};
19 changes: 19 additions & 0 deletions src/search/hooks/use-search-input.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
24 changes: 24 additions & 0 deletions src/search/hooks/use-search-results.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
11 changes: 11 additions & 0 deletions src/search/pages/search-page.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
49 changes: 49 additions & 0 deletions src/search/testutils/fake-search-results.ts
Original file line number Diff line number Diff line change
@@ -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: '#' },
],
},
];
};
18 changes: 18 additions & 0 deletions src/shared/components/icons/close-icon.tsx
Original file line number Diff line number Diff line change
@@ -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';
Loading

0 comments on commit 96560d1

Please sign in to comment.