@@ -23,6 +23,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"file-saver": "^2.0.2",
|
"file-saver": "^2.0.2",
|
||||||
"framer-motion": "^2.1.0",
|
"framer-motion": "^2.1.0",
|
||||||
|
"fuse.js": "^6.4.1",
|
||||||
"phosphor-react": "^0.2.2",
|
"phosphor-react": "^0.2.2",
|
||||||
"react": "^17.0.0-rc.0",
|
"react": "^17.0.0-rc.0",
|
||||||
"react-dom": "^17.0.0-rc.0",
|
"react-dom": "^17.0.0-rc.0",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import Header from "../Header/Header";
|
|||||||
import Toolbar from "../Toolbar/Toolbar";
|
import Toolbar from "../Toolbar/Toolbar";
|
||||||
import IconGrid from "../IconGrid/IconGrid";
|
import IconGrid from "../IconGrid/IconGrid";
|
||||||
import Footer from "../Footer/Footer";
|
import Footer from "../Footer/Footer";
|
||||||
|
import ErrorBoundary from "../ErrorBoundary/ErrorBoundary";
|
||||||
|
import Warn from "../Warn/Warn";
|
||||||
|
|
||||||
const App: React.FC<any> = () => {
|
const App: React.FC<any> = () => {
|
||||||
return (
|
return (
|
||||||
@@ -12,9 +14,11 @@ const App: React.FC<any> = () => {
|
|||||||
<Header />
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
|
<ErrorBoundary fallback={<Warn message="Search error"/>}>
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
<IconGrid />
|
<IconGrid />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,19 +2,15 @@ import React, { useRef, useEffect } from "react";
|
|||||||
import { useRecoilValue } from "recoil";
|
import { useRecoilValue } from "recoil";
|
||||||
import { motion, useAnimation } from "framer-motion";
|
import { motion, useAnimation } from "framer-motion";
|
||||||
import { useWindowSize } from "react-use";
|
import { useWindowSize } from "react-use";
|
||||||
import { IconContext, Warning } from "phosphor-react";
|
import { IconContext } from "phosphor-react";
|
||||||
|
|
||||||
import {
|
import { iconStyleAtom, iconSizeAtom, iconColorAtom } from "../../state/atoms";
|
||||||
iconStyleAtom,
|
|
||||||
iconSizeAtom,
|
|
||||||
iconColorAtom,
|
|
||||||
searchQueryAtom,
|
|
||||||
} from "../../state/atoms";
|
|
||||||
import {
|
import {
|
||||||
filteredQueryResultsSelector,
|
filteredQueryResultsSelector,
|
||||||
isDarkThemeSelector,
|
isDarkThemeSelector,
|
||||||
} from "../../state/selectors";
|
} from "../../state/selectors";
|
||||||
import GridItem from "./IconGridItem";
|
import GridItem from "./IconGridItem";
|
||||||
|
import Warn from "../Warn/Warn";
|
||||||
import "./IconGrid.css";
|
import "./IconGrid.css";
|
||||||
|
|
||||||
type IconGridProps = {};
|
type IconGridProps = {};
|
||||||
@@ -23,7 +19,6 @@ const IconGrid: React.FC<IconGridProps> = () => {
|
|||||||
const weight = useRecoilValue(iconStyleAtom);
|
const weight = useRecoilValue(iconStyleAtom);
|
||||||
const size = useRecoilValue(iconSizeAtom);
|
const size = useRecoilValue(iconSizeAtom);
|
||||||
const color = useRecoilValue(iconColorAtom);
|
const color = useRecoilValue(iconColorAtom);
|
||||||
const query = useRecoilValue(searchQueryAtom);
|
|
||||||
const isDark = useRecoilValue(isDarkThemeSelector);
|
const isDark = useRecoilValue(isDarkThemeSelector);
|
||||||
|
|
||||||
const { width } = useWindowSize();
|
const { width } = useWindowSize();
|
||||||
@@ -38,22 +33,7 @@ const IconGrid: React.FC<IconGridProps> = () => {
|
|||||||
controls.start("visible");
|
controls.start("visible");
|
||||||
}, [controls, filteredQueryResults]);
|
}, [controls, filteredQueryResults]);
|
||||||
|
|
||||||
if (!filteredQueryResults.length)
|
if (!filteredQueryResults.length) return <Warn />;
|
||||||
return (
|
|
||||||
<div style={isDark ? { backgroundColor: "#35313D", color: "white" } : {}}>
|
|
||||||
<motion.div
|
|
||||||
className="empty-list"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
<Warning size={128} color="currentColor" weight="fill" />
|
|
||||||
<p>
|
|
||||||
No results for '<code>{query}</code>'
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IconContext.Provider value={{ weight, size, color, mirrored: false }}>
|
<IconContext.Provider value={{ weight, size, color, mirrored: false }}>
|
||||||
|
|||||||
36
src/components/Warn/Warn.tsx
Normal file
36
src/components/Warn/Warn.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useRecoilValue } from "recoil";
|
||||||
|
|
||||||
|
import { isDarkThemeSelector } from "../../state/selectors";
|
||||||
|
import { searchQueryAtom } from "../../state/atoms";
|
||||||
|
import { Warning } from "phosphor-react";
|
||||||
|
|
||||||
|
interface WarnProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Warn: React.FC<WarnProps> = ({ message }) => {
|
||||||
|
const isDark = useRecoilValue(isDarkThemeSelector);
|
||||||
|
const query = useRecoilValue(searchQueryAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={isDark ? { backgroundColor: "#35313D", color: "white" } : {}}>
|
||||||
|
<motion.div
|
||||||
|
className="empty-list"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<Warning size={128} color="currentColor" weight="fill" />
|
||||||
|
{message ?? (
|
||||||
|
<p>
|
||||||
|
No results for '<code>{query}</code>'
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Warn;
|
||||||
@@ -3086,13 +3086,13 @@ export const icons: Readonly<IconEntry[]> = [
|
|||||||
{
|
{
|
||||||
name: "trash",
|
name: "trash",
|
||||||
categories: [IconCategory.DOCUMENT, IconCategory.SYSTEM],
|
categories: [IconCategory.DOCUMENT, IconCategory.SYSTEM],
|
||||||
tags: ["garbage", "delete", "destroy", "recycle", "recycling"],
|
tags: ["garbage", "remove", "delete", "destroy", "recycle", "recycling"],
|
||||||
Icon: Icon.Trash,
|
Icon: Icon.Trash,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trash-handle",
|
name: "trash-handle",
|
||||||
categories: [IconCategory.DOCUMENT, IconCategory.SYSTEM],
|
categories: [IconCategory.DOCUMENT, IconCategory.SYSTEM],
|
||||||
tags: ["garbage", "delete", "destroy", "recycle", "recycling"],
|
tags: ["garbage", "remove", "delete", "destroy", "recycle", "recycling"],
|
||||||
Icon: Icon.TrashHandle,
|
Icon: Icon.TrashHandle,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
import { selector, selectorFamily } from "recoil";
|
import { selector, selectorFamily } from "recoil";
|
||||||
import TinyColor from "tinycolor2";
|
import TinyColor from "tinycolor2";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
|
||||||
import { searchQueryAtom, iconStyleAtom, iconColorAtom } from "./atoms";
|
import { searchQueryAtom, iconColorAtom } from "./atoms";
|
||||||
import { IconEntry, IconCategory } from "../lib";
|
import { IconEntry, IconCategory } from "../lib";
|
||||||
import { icons } from "../lib/icons";
|
import { icons } from "../lib/icons";
|
||||||
|
|
||||||
const isQueryMatch = (icon: IconEntry, query: string): boolean => {
|
const fuse = new Fuse(icons, {
|
||||||
return (
|
keys: [{ name: "name", weight: 2 }, "tags", "categories"],
|
||||||
icon.name.includes(query) ||
|
threshold: 0.2, // Tweak this to what feels like the right number of results
|
||||||
icon.tags.some((tag) => tag.toLowerCase().includes(query)) ||
|
// shouldSort: false, // We may want to sort if we find too many results?
|
||||||
icon.categories.some((category) => category.toLowerCase().includes(query))
|
});
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const filteredQueryResultsSelector = selector<Readonly<IconEntry[]>>({
|
export const filteredQueryResultsSelector = selector<Readonly<IconEntry[]>>({
|
||||||
key: "filteredQueryResultsSelector",
|
key: "filteredQueryResultsSelector",
|
||||||
get: async ({ get }) => {
|
get: ({ get }) => {
|
||||||
const query = get(searchQueryAtom).trim().toLowerCase();
|
const query = get(searchQueryAtom).trim().toLowerCase();
|
||||||
const style = get(iconStyleAtom);
|
if (!query) return icons;
|
||||||
|
|
||||||
if (!query && !style) return icons;
|
|
||||||
|
|
||||||
return new Promise((resolve) =>
|
return new Promise((resolve) =>
|
||||||
resolve(icons.filter((icon) => isQueryMatch(icon, query)))
|
resolve(fuse.search(query).map((value) => value.item))
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -33,7 +30,7 @@ export const categorizedQueryResultsSelector = selector<
|
|||||||
Readonly<CategorizedIcons>
|
Readonly<CategorizedIcons>
|
||||||
>({
|
>({
|
||||||
key: "categorizedQueryResultsSelector",
|
key: "categorizedQueryResultsSelector",
|
||||||
get: async ({ get }) => {
|
get: ({ get }) => {
|
||||||
const filteredResults = get(filteredQueryResultsSelector);
|
const filteredResults = get(filteredQueryResultsSelector);
|
||||||
return new Promise((resolve) =>
|
return new Promise((resolve) =>
|
||||||
resolve(
|
resolve(
|
||||||
@@ -54,7 +51,7 @@ export const singleCategoryQueryResultsSelector = selectorFamily<
|
|||||||
IconCategory
|
IconCategory
|
||||||
>({
|
>({
|
||||||
key: "singleCategoryQueryResultsSelector",
|
key: "singleCategoryQueryResultsSelector",
|
||||||
get: (category: IconCategory) => async ({ get }) => {
|
get: (category: IconCategory) => ({ get }) => {
|
||||||
const filteredResults = get(filteredQueryResultsSelector);
|
const filteredResults = get(filteredQueryResultsSelector);
|
||||||
return new Promise((resolve) =>
|
return new Promise((resolve) =>
|
||||||
resolve(
|
resolve(
|
||||||
|
|||||||
Reference in New Issue
Block a user