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.url} +
+ {icon.description} +
+
+
+ ))} +
+
+ + ); +}; + +export default IconsPage;