diff --git a/site/package.json b/site/package.json
index c49ae7afb..a2ebb527d 100644
--- a/site/package.json
+++ b/site/package.json
@@ -21,6 +21,7 @@
"@next/mdx": "^11.0.0",
"downloadjs": "^1.4.7",
"framer-motion": "^4",
+ "fuse.js": "^6.5.3",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.0",
"jszip": "^3.7.0",
diff --git a/site/src/components/IconList.tsx b/site/src/components/IconList.tsx
index dc7328f61..358aa83db 100644
--- a/site/src/components/IconList.tsx
+++ b/site/src/components/IconList.tsx
@@ -1,38 +1,30 @@
-import { Button, Flex, Grid, Text, useToast } from '@chakra-ui/react';
-import download from 'downloadjs';
+import { Grid } from '@chakra-ui/react';
import Link from 'next/link';
-import copy from 'copy-to-clipboard';
-import { useCallback, useContext, useMemo } from 'react';
-import { IconStyleContext } from './CustomizeIconContext';
-import { IconWrapper } from './IconWrapper';
import { useRouter } from 'next/router';
-import ModifiedTooltip from './ModifiedTooltip';
+import { memo } from 'react';
+import { IconEntity } from '../types';
import IconListItem from './IconListItem';
-const IconList = ({ icons }) => {
- const router = useRouter();
- const toast = useToast();
- const { color, size, strokeWidth } = useContext(IconStyleContext);
- const { search } = router.query;
+interface IconListProps {
+ icons: IconEntity[];
+}
- const query = useMemo(() => (search !== undefined ? { search } : {}), [search]);
+const IconList = memo(({ icons }: IconListProps) => {
+ const router = useRouter();
return (
- {icons.map(icon => {
- const actualIcon = icon.item ? icon.item : icon;
- const { name, content, contributors } = actualIcon;
-
+ {icons.map((icon) => {
return (
@@ -42,6 +34,6 @@ const IconList = ({ icons }) => {
})}
);
-};
+});
export default IconList;
diff --git a/site/src/components/IconOverview.tsx b/site/src/components/IconOverview.tsx
index 01f2f9be3..915e261b8 100644
--- a/site/src/components/IconOverview.tsx
+++ b/site/src/components/IconOverview.tsx
@@ -1,114 +1,32 @@
-import {
- Box,
- Input,
- InputGroup,
- InputLeftElement,
- Text,
- useColorMode,
- Icon,
-} from '@chakra-ui/react';
-import IconList from './IconList';
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { Box, Text } from '@chakra-ui/react';
+import React, { useState } from 'react';
import useSearch from '../lib/useSearch';
-import { useRouter } from 'next/router';
-import theme from '../lib/theme';
-import { Search as SearchIcon } from 'lucide-react';
-import debounce from 'lodash/debounce';
+import { IconEntity } from '../types';
+import IconList from './IconList';
+import { SearchInput } from './SearchInput';
-const isFilledString = (string) => string !== undefined && string !== null && string !== '';
+interface IconOverviewProps {
+ data: IconEntity[];
+}
-const IconOverview = ({ data }) => {
- const router = useRouter();
- const { search } = router.query;
+const IconOverview = ({ data }: IconOverviewProps) => {
+ const [query, setQuery] = useState('');
- const [queryText, setQueryText] = useState(search);
- const { colorMode } = useColorMode();
-
- const inputElement = useRef(null);
-
- function handleKeyDown(event) {
- if (event.key === '/' && inputElement.current !== document.activeElement) {
- event.preventDefault();
- inputElement.current.focus();
- }
- }
-
- const setQueryParam = (searchString) => {
- const { query, asPath } = router;
- if(isFilledString(searchString)) {
- let route = {
- pathname: '',
- query
- }
-
- if(query.iconName) {
- route.query.iconName = query.iconName;
- route.pathname = '/icon/[iconName]';
- }
-
- route.query.search = searchString;
-
- router.replace(route);
- }
- else {
- if (query?.search) {
- delete query.search;
- router.replace({
- query
- })
- }
- }
- }
-
- // @ts-ignore
- const searchResults = useMemo(() => useSearch(data, queryText), [data, queryText])
-
- const handleSearchInput = debounce((event) => {
- event.persist();
- const { value = '' } = inputElement?.current;
-
- setQueryText(value)
- setQueryParam(value)
- }, 400)
-
- useEffect(() => {
- setQueryText(search)
- }, [search]);
-
- useEffect(() => {
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, []);
+ const searchResults = useSearch(query, data, [
+ { name: 'name', weight: 2 },
+ { name: 'tags', weight: 1 },
+ ]);
return (
<>
-
-
-
-
- }
- />
-
-
+
+
{searchResults.length > 0 ? (
) : (
-
- No results found for "{queryText}"
+
+ No results found for "{query}"
)}
diff --git a/site/src/components/SearchInput.tsx b/site/src/components/SearchInput.tsx
new file mode 100644
index 000000000..56b4ead44
--- /dev/null
+++ b/site/src/components/SearchInput.tsx
@@ -0,0 +1,75 @@
+import {
+ Icon,
+ Input,
+ InputGroup,
+ InputLeftElement,
+ useColorMode,
+ useUpdateEffect
+} from '@chakra-ui/react';
+import { Search as SearchIcon } from 'lucide-react';
+import React, { useEffect, useRef, useState } from 'react';
+import theme from '../lib/theme';
+import { useDebounce } from '../lib/useDebounce';
+import { useRouterParam } from '../lib/useRouterParam';
+
+interface SearchInputProps {
+ onChange(value: string): void;
+ count: number;
+}
+
+export const SearchInput = (
+ ({ onChange, count }: SearchInputProps) => {
+ const { colorMode } = useColorMode();
+
+ const [urlValue, setUrlValue] = useRouterParam('search');
+
+ const [inputValue, setInputValue] = useState('');
+ const debouncedValue = useDebounce(inputValue.trim(), 300);
+
+ useUpdateEffect(() => {
+ onChange(debouncedValue);
+ setUrlValue(debouncedValue);
+ }, [debouncedValue]);
+
+ useEffect(() => {
+ if (urlValue && !inputValue) {
+ setInputValue(urlValue);
+ onChange(urlValue);
+ }
+ }, [urlValue]);
+
+ const ref = useRef(null);
+
+ // Keyboard `/` shortcut
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === '/' && ref.current !== document.activeElement) {
+ event.preventDefault();
+ ref.current.focus();
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, []);
+
+ return (
+
+
+
+
+ }
+ />
+ setInputValue(event.target.value)}
+ value={inputValue}
+ bg={colorMode == 'light' ? theme.colors.white : theme.colors.gray[700]}
+ />
+
+ );
+ }
+);
diff --git a/site/src/hooks/useUpdateEffect.ts b/site/src/hooks/useUpdateEffect.ts
new file mode 100644
index 000000000..e4fb1d10c
--- /dev/null
+++ b/site/src/hooks/useUpdateEffect.ts
@@ -0,0 +1,15 @@
+import { DependencyList, EffectCallback, useEffect, useRef } from 'react';
+
+export const useUpdateEffect = (effect: EffectCallback, deps?: DependencyList) => {
+ const isFirstRef = useRef(true);
+
+ if (isFirstRef.current) {
+ isFirstRef.current = false;
+ }
+
+ useEffect(() => {
+ if (!isFirstRef.current) {
+ return effect();
+ }
+ }, deps);
+};
diff --git a/site/src/lib/useDebounce.tsx b/site/src/lib/useDebounce.ts
similarity index 100%
rename from site/src/lib/useDebounce.tsx
rename to site/src/lib/useDebounce.ts
diff --git a/site/src/lib/useRouterParam.ts b/site/src/lib/useRouterParam.ts
new file mode 100644
index 000000000..0143f183c
--- /dev/null
+++ b/site/src/lib/useRouterParam.ts
@@ -0,0 +1,23 @@
+import { useRouter } from 'next/router';
+
+export const useRouterParam = (key: string) => {
+ const router = useRouter();
+
+ const queryValue = router.query[key];
+ const urlValue = typeof queryValue === 'string' ? queryValue : '';
+
+ const setParam = (value: string) => {
+ router.push(
+ {
+ query: value ? { [key]: value } : undefined,
+ },
+ undefined,
+ {
+ scroll: false,
+ shallow: true,
+ }
+ );
+ };
+
+ return [urlValue, setParam] as const;
+};
diff --git a/site/src/lib/useSearch.ts b/site/src/lib/useSearch.ts
new file mode 100644
index 000000000..6a7f18770
--- /dev/null
+++ b/site/src/lib/useSearch.ts
@@ -0,0 +1,23 @@
+import Fuse from 'fuse.js';
+import { useMemo } from 'react';
+
+const useSearch = (query = '', collection: T[], keys: Fuse.FuseOptionKey[]) => {
+ const index = useMemo(() => {
+ return new Fuse(collection, {
+ threshold: 0.2,
+ keys,
+ });
+ }, [collection]);
+
+ const results = useMemo(() => {
+ if (query) {
+ return index.search(query).map((result) => result.item);
+ }
+
+ return collection;
+ }, [query, index]);
+
+ return results;
+};
+
+export default useSearch;
diff --git a/site/src/lib/useSearch.tsx b/site/src/lib/useSearch.tsx
deleted file mode 100644
index 5003dc20d..000000000
--- a/site/src/lib/useSearch.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-interface Icon {
- name: string;
- tags: string[],
-}
-
-function useSearch(icons: Icon[], query:string) {
- if(!query) return icons;
-
- const searchString = query.toLowerCase()
-
- return icons.filter(({ name, tags }: Icon) => [name, ...tags].some(
- (item:string) => item
- .toLowerCase()
- .includes(searchString)
- )
- );
-}
-
-export default useSearch;
diff --git a/site/src/types.ts b/site/src/types.ts
new file mode 100644
index 000000000..4227e88f9
--- /dev/null
+++ b/site/src/types.ts
@@ -0,0 +1,12 @@
+export interface IconEntity {
+ content: string;
+ contributors: Contributor[];
+ name: string;
+ src: string;
+ tags: string[];
+}
+
+export interface Contributor {
+ author: string;
+ commit: string;
+}
diff --git a/site/yarn.lock b/site/yarn.lock
index 55997e832..3482ce44b 100644
--- a/site/yarn.lock
+++ b/site/yarn.lock
@@ -3785,6 +3785,11 @@ functional-red-black-tree@^1.0.1:
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
+fuse.js@^6.5.3:
+ version "6.5.3"
+ resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.5.3.tgz#7446c0acbc4ab0ab36fa602e97499bdb69452b93"
+ integrity sha512-sA5etGE7yD/pOqivZRBvUBd/NaL2sjAu6QuSaFoe1H2BrJSkH/T/UXAJ8CdXdw7DvY3Hs8CXKYkDWX7RiP5KOg==
+
gensync@^1.0.0-beta.1, gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"