diff --git a/site/package.json b/site/package.json
index f95d9b0a9acf8..4e87d302c5be4 100644
--- a/site/package.json
+++ b/site/package.json
@@ -90,6 +90,7 @@
"ts-prune": "0.10.3",
"tzdata": "1.0.30",
"ua-parser-js": "1.0.33",
+ "ufuzzy": "npm:@leeoniya/ufuzzy@1.0.10",
"unique-names-generator": "4.7.1",
"uuid": "9.0.0",
"vite": "4.4.2",
diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml
index d4b34d74f9ba6..77f1505530101 100644
--- a/site/pnpm-lock.yaml
+++ b/site/pnpm-lock.yaml
@@ -186,6 +186,9 @@ dependencies:
ua-parser-js:
specifier: 1.0.33
version: 1.0.33
+ ufuzzy:
+ specifier: npm:@leeoniya/ufuzzy@1.0.10
+ version: /@leeoniya/ufuzzy@1.0.10
unique-names-generator:
specifier: 4.7.1
version: 4.7.1
@@ -3174,6 +3177,10 @@ packages:
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==}
dev: false
+ /@leeoniya/ufuzzy@1.0.10:
+ resolution: {integrity: sha512-OR1yiyN8cKBn5UiHjKHUl0LcrTQt4vZPUpIf96qIIZVLxgd4xyASuRvTZ3tjbWvuyQAMgvKsq61Nwu131YyHnA==}
+ dev: false
+
/@mapbox/node-pre-gyp@1.0.11:
resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==}
hasBin: true
diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx
index 12e1c70f08bfe..abef366dbcbfa 100644
--- a/site/src/AppRouter.tsx
+++ b/site/src/AppRouter.tsx
@@ -193,6 +193,7 @@ const TemplateInsightsPage = lazy(
);
const HealthPage = lazy(() => import("./pages/HealthPage/HealthPage"));
const GroupsPage = lazy(() => import("./pages/GroupsPage/GroupsPage"));
+const IconsPage = lazy(() => import("./pages/IconsPage/IconsPage"));
export const AppRouter: FC = () => {
return (
@@ -207,21 +208,21 @@ export const AppRouter: FC = () => {
}>
} />
- } />
+ } />
}
/>
- } />
+ } />
-
+
} />
} />
-
+
} />
} />
@@ -261,7 +262,7 @@ export const AppRouter: FC = () => {
-
+
}>
} />
@@ -302,7 +303,7 @@ export const AppRouter: FC = () => {
/>
- }>
+ }>
} />
} />
} />
@@ -340,7 +341,8 @@ export const AppRouter: FC = () => {
path="/:username/:workspace/terminal"
element={}
/>
- } />
+ } />
+ } />
{/* Using path="*"" means "match anything", so this route
diff --git a/site/src/components/CopyableValue/CopyableValue.tsx b/site/src/components/CopyableValue/CopyableValue.tsx
index 96e49e18171ba..7d2de18bce4e2 100644
--- a/site/src/components/CopyableValue/CopyableValue.tsx
+++ b/site/src/components/CopyableValue/CopyableValue.tsx
@@ -1,20 +1,28 @@
-import Tooltip from "@mui/material/Tooltip";
+import Tooltip, { type TooltipProps } from "@mui/material/Tooltip";
import { useClickable } from "hooks/useClickable";
import { useClipboard } from "hooks/useClipboard";
-import { FC, HTMLProps } from "react";
+import { type FC, type HTMLProps } from "react";
interface CopyableValueProps extends HTMLProps {
value: string;
+ placement?: TooltipProps["placement"];
+ PopperProps?: TooltipProps["PopperProps"];
}
-export const CopyableValue: FC = ({ value, ...props }) => {
+export const CopyableValue: FC = ({
+ value,
+ placement = "bottom-start",
+ PopperProps,
+ ...props
+}) => {
const { isCopied, copy } = useClipboard(value);
const clickableProps = useClickable(copy);
return (
diff --git a/site/src/pages/IconsPage/IconsPage.tsx b/site/src/pages/IconsPage/IconsPage.tsx
new file mode 100644
index 0000000000000..6b30c1079f245
--- /dev/null
+++ b/site/src/pages/IconsPage/IconsPage.tsx
@@ -0,0 +1,196 @@
+import TextField from "@mui/material/TextField";
+import InputAdornment from "@mui/material/InputAdornment";
+import Tooltip from "@mui/material/Tooltip";
+import IconButton from "@mui/material/IconButton";
+import Box from "@mui/material/Box";
+import Link from "@mui/material/Link";
+import SearchIcon from "@mui/icons-material/SearchOutlined";
+import ClearIcon from "@mui/icons-material/CloseOutlined";
+import { useTheme } from "@emotion/react";
+import { type FC, type ReactNode, useMemo, useState } from "react";
+import { Helmet } from "react-helmet-async";
+import uFuzzy from "ufuzzy";
+import { CopyableValue } from "components/CopyableValue/CopyableValue";
+import { EmptyState } from "components/EmptyState/EmptyState";
+import { Margins } from "components/Margins/Margins";
+import {
+ PageHeader,
+ PageHeaderSubtitle,
+ PageHeaderTitle,
+} from "components/PageHeader/PageHeader";
+import { Stack } from "components/Stack/Stack";
+import icons from "theme/icons.json";
+import { pageTitle } from "utils/page";
+
+const iconsWithoutSuffix = icons.map((icon) => icon.split(".")[0]);
+const fuzzyFinder = new uFuzzy({
+ intraMode: 1,
+ intraIns: 1,
+ intraSub: 1,
+ intraTrn: 1,
+ intraDel: 1,
+});
+
+export const IconsPage: FC = () => {
+ const theme = useTheme();
+ const [searchInputText, setSearchInputText] = useState("");
+ const searchText = searchInputText.trim();
+
+ const searchedIcons = useMemo(() => {
+ if (!searchText) {
+ return icons.map((icon) => ({ url: `/icon/${icon}`, description: icon }));
+ }
+
+ const [map, info, sorted] = fuzzyFinder.search(
+ iconsWithoutSuffix,
+ searchText,
+ );
+
+ // We hit an invalid state somehow
+ if (!map || !info || !sorted) {
+ return [];
+ }
+
+ return sorted.map((i) => {
+ const iconName = icons[info.idx[i]];
+ const ranges = info.ranges[i];
+
+ const nodes: ReactNode[] = [];
+ let cursor = 0;
+ for (let j = 0; j < ranges.length; j += 2) {
+ nodes.push(iconName.slice(cursor, ranges[j]));
+ nodes.push(
+ {iconName.slice(ranges[j], ranges[j + 1])},
+ );
+ cursor = ranges[j + 1];
+ }
+ nodes.push(iconName.slice(cursor));
+ return { url: `/icon/${iconName}`, description: nodes };
+ });
+ }, [searchText]);
+
+ return (
+ <>
+
+ {pageTitle("Icons")}
+
+
+
+ You can suggest a new icon by submitting a Pull Request to our
+ public GitHub repository. Just keep in mind that it should be
+ relevant to many Coder users, and redistributable under a
+ permissive license.
+
+ }
+ >
+
+ Suggest an icon
+
+
+ }
+ >
+ Icons
+
+ All of the icons included with Coder
+
+
+ setSearchInputText(event.target.value),
+ sx: {
+ borderRadius: "6px",
+ marginLeft: "-1px",
+ "& input::placeholder": {
+ color: theme.palette.text.secondary,
+ },
+ "& .MuiInputAdornment-root": {
+ marginLeft: 0,
+ },
+ },
+ startAdornment: (
+
+
+
+ ),
+ endAdornment: searchInputText && (
+
+
+ setSearchInputText("")}
+ >
+
+
+
+
+ ),
+ }}
+ />
+
+ ({ marginTop: theme.spacing(4) })}
+ >
+ {searchedIcons.length === 0 && (
+
+ )}
+ {searchedIcons.map((icon) => (
+
+
+
+
+ {icon.description}
+
+
+
+ ))}
+
+
+ >
+ );
+};
+
+export default IconsPage;