From f8523d9ddf4127c8baa3232ad00fc15f5bcd62aa Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 9 May 2024 08:22:48 -0500 Subject: [PATCH] Icon picker component (#11310) * icon picker component * keep box the same size when filtering icons --- .../components/filter/CameraGroupSelector.tsx | 72 ++++---- web/src/components/icons/IconPicker.tsx | 154 ++++++++++++++++++ web/src/components/ui/popover.tsx | 51 +++--- web/src/pages/UIPlayground.tsx | 12 ++ web/src/types/frigateConfig.ts | 5 +- web/src/utils/iconUtil.tsx | 24 +-- 6 files changed, 233 insertions(+), 85 deletions(-) create mode 100644 web/src/components/icons/IconPicker.tsx diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 84d1a36b5..38a246c00 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -1,8 +1,4 @@ -import { - CameraGroupConfig, - FrigateConfig, - GROUP_ICONS, -} from "@/types/frigateConfig"; +import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig"; import { isDesktop, isMobile } from "react-device-detect"; import useSWR from "swr"; import { MdHome } from "react-icons/md"; @@ -10,7 +6,6 @@ import { usePersistedOverlayState } from "@/hooks/use-overlay-state"; import { Button } from "../ui/button"; import { useCallback, useMemo, useState } from "react"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; -import { getIconForGroup } from "@/utils/iconUtil"; import { LuPencil, LuPlus } from "react-icons/lu"; import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; import { Drawer, DrawerContent } from "../ui/drawer"; @@ -31,13 +26,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "../ui/dropdown-menu"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { AlertDialog, AlertDialogAction, @@ -62,6 +50,9 @@ import { ScrollArea, ScrollBar } from "../ui/scroll-area"; import { usePersistence } from "@/hooks/use-persistence"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { cn } from "@/lib/utils"; +import * as LuIcons from "react-icons/lu"; +import IconPicker, { IconName, IconRenderer } from "../icons/IconPicker"; +import { isValidIconName } from "@/utils/iconUtil"; type CameraGroupSelectorProps = { className?: string; @@ -168,7 +159,12 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { isDesktop ? showTooltip(undefined) : null } > - {getIconForGroup(config.icon)} + {config && config.icon && isValidIconName(config.icon) && ( + + )} @@ -503,7 +499,12 @@ export function CameraGroupEdit({ cameras: z.array(z.string()).min(2, { message: "You must select at least two cameras.", }), - icon: z.string(), + icon: z + .string() + .min(1, { message: "You must select an icon." }) + .refine((value) => Object.keys(LuIcons).includes(value), { + message: "Invalid icon", + }), }); const onSubmit = useCallback( @@ -559,10 +560,10 @@ export function CameraGroupEdit({ const form = useForm>({ resolver: zodResolver(formSchema), - mode: "onChange", + mode: "onSubmit", defaultValues: { name: (editingGroup && editingGroup[0]) ?? "", - icon: editingGroup && editingGroup[1].icon, + icon: editingGroup && (editingGroup[1].icon as IconName), cameras: editingGroup && editingGroup[1].cameras, }, }); @@ -571,7 +572,7 @@ export function CameraGroupEdit({
( - + Icon - + { + field.onChange(newIcon?.name ?? undefined); + }} + /> @@ -662,7 +654,7 @@ export function CameraGroupEdit({ -
+
diff --git a/web/src/components/icons/IconPicker.tsx b/web/src/components/icons/IconPicker.tsx new file mode 100644 index 000000000..21883ccdc --- /dev/null +++ b/web/src/components/icons/IconPicker.tsx @@ -0,0 +1,154 @@ +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { IconType } from "react-icons"; +import * as LuIcons from "react-icons/lu"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { IoClose } from "react-icons/io5"; +import Heading from "../ui/heading"; +import { cn } from "@/lib/utils"; +import { Button } from "../ui/button"; + +export type IconName = keyof typeof LuIcons; + +export type IconElement = { + name?: string; + Icon?: IconType; +}; + +type IconPickerProps = { + selectedIcon?: IconElement; + setSelectedIcon?: React.Dispatch< + React.SetStateAction + >; +}; + +export default function IconPicker({ + selectedIcon, + setSelectedIcon, +}: IconPickerProps) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const [searchTerm, setSearchTerm] = useState(""); + + const iconSets = useMemo(() => [...Object.entries(LuIcons)], []); + + const icons = useMemo( + () => + iconSets.filter( + ([name]) => + name.toLowerCase().includes(searchTerm.toLowerCase()) || + searchTerm === "", + ), + [iconSets, searchTerm], + ); + + const handleIconSelect = useCallback( + ({ name, Icon }: IconElement) => { + if (setSelectedIcon) { + setSelectedIcon({ name, Icon }); + } + setSearchTerm(""); + }, + [setSelectedIcon], + ); + + return ( +
+ { + setOpen(open); + }} + > + + {!selectedIcon?.name || !selectedIcon?.Icon ? ( + + ) : ( +
+
+
+ +
+ {selectedIcon.name + .replace(/^Lu/, "") + .replace(/([A-Z])/g, " $1")} +
+
+ + { + handleIconSelect({ name: undefined, Icon: undefined }); + }} + /> +
+
+ )} +
+ +
+ Select an icon + { + setOpen(false); + }} + /> +
+ setSearchTerm(e.target.value)} + /> +
+
+ {icons.map(([name, Icon]) => ( +
+ { + handleIconSelect({ name, Icon }); + setOpen(false); + }} + /> +
+ ))} +
+
+
+
+
+ ); +} + +type IconRendererProps = { + icon: IconType; + size?: number; + className?: string; +}; + +export function IconRenderer({ icon, size, className }: IconRendererProps) { + return <>{React.createElement(icon, { size, className })}; +} diff --git a/web/src/components/ui/popover.tsx b/web/src/components/ui/popover.tsx index bbba7e0eb..bba83f977 100644 --- a/web/src/components/ui/popover.tsx +++ b/web/src/components/ui/popover.tsx @@ -1,29 +1,36 @@ -import * as React from "react" -import * as PopoverPrimitive from "@radix-ui/react-popover" +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const Popover = PopoverPrimitive.Root +const Popover = PopoverPrimitive.Root; -const PopoverTrigger = PopoverPrimitive.Trigger +const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - - - -)) -PopoverContent.displayName = PopoverPrimitive.Content.displayName + React.ComponentPropsWithoutRef & { + container?: HTMLElement | null; + } +>( + ( + { className, container, align = "center", sideOffset = 4, ...props }, + ref, + ) => ( + + + + ), +); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent } +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index 3cad4696b..67602a858 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -28,6 +28,7 @@ import { Label } from "@/components/ui/label"; import { useNavigate } from "react-router-dom"; import SummaryTimeline from "@/components/timeline/SummaryTimeline"; import { isMobile } from "react-device-detect"; +import IconPicker, { IconElement } from "@/components/icons/IconPicker"; // Color data const colors = [ @@ -207,6 +208,8 @@ function UIPlayground() { const [isEventsReviewTimeline, setIsEventsReviewTimeline] = useState(true); const birdseyeConfig = config?.birdseye; + const [selectedIcon, setSelectedIcon] = useState(); + return ( <>
@@ -214,6 +217,15 @@ function UIPlayground() {
UI Playground + + + {selectedIcon?.name && ( +

Selected icon name: {selectedIcon.name}

+ )} + Scrubber diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 517a00761..b24841151 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -1,3 +1,4 @@ +import { IconName } from "@/components/icons/IconPicker"; import { LivePlayerMode } from "./live"; export interface UiConfig { @@ -222,11 +223,9 @@ export interface CameraConfig { }; } -export const GROUP_ICONS = ["car", "cat", "dog", "leaf"] as const; - export type CameraGroupConfig = { cameras: string[]; - icon: (typeof GROUP_ICONS)[number]; + icon: IconName; order: number; }; diff --git a/web/src/utils/iconUtil.tsx b/web/src/utils/iconUtil.tsx index d2ba08715..3e8b8cca0 100644 --- a/web/src/utils/iconUtil.tsx +++ b/web/src/utils/iconUtil.tsx @@ -1,3 +1,4 @@ +import { IconName } from "@/components/icons/IconPicker"; import { BsPersonWalking } from "react-icons/bs"; import { FaAmazon, @@ -6,35 +7,18 @@ import { FaCarSide, FaCat, FaCheckCircle, - FaCircle, FaDog, FaFedex, FaFire, - FaLeaf, FaUps, } from "react-icons/fa"; import { GiHummingbird } from "react-icons/gi"; import { LuBox, LuLassoSelect } from "react-icons/lu"; +import * as LuIcons from "react-icons/lu"; import { MdRecordVoiceOver } from "react-icons/md"; -export function getIconTypeForGroup(icon: string) { - switch (icon) { - case "car": - return FaCarSide; - case "cat": - return FaCat; - case "dog": - return FaDog; - case "leaf": - return FaLeaf; - default: - return FaCircle; - } -} - -export function getIconForGroup(icon: string, className: string = "size-4") { - const GroupIcon = getIconTypeForGroup(icon); - return ; +export function isValidIconName(value: string): value is IconName { + return Object.keys(LuIcons).includes(value as IconName); } export function getIconForLabel(label: string, className?: string) {