Icon picker component (#11310)

* icon picker component

* keep box the same size when filtering icons
This commit is contained in:
Josh Hawkins 2024-05-09 08:22:48 -05:00 committed by GitHub
parent 50ee447e52
commit f8523d9ddf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 233 additions and 85 deletions

View File

@ -1,8 +1,4 @@
import { import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig";
CameraGroupConfig,
FrigateConfig,
GROUP_ICONS,
} from "@/types/frigateConfig";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import useSWR from "swr"; import useSWR from "swr";
import { MdHome } from "react-icons/md"; import { MdHome } from "react-icons/md";
@ -10,7 +6,6 @@ import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { getIconForGroup } from "@/utils/iconUtil";
import { LuPencil, LuPlus } from "react-icons/lu"; import { LuPencil, LuPlus } from "react-icons/lu";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
import { Drawer, DrawerContent } from "../ui/drawer"; import { Drawer, DrawerContent } from "../ui/drawer";
@ -31,13 +26,6 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "../ui/dropdown-menu"; } from "../ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -62,6 +50,9 @@ import { ScrollArea, ScrollBar } from "../ui/scroll-area";
import { usePersistence } from "@/hooks/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils"; 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 = { type CameraGroupSelectorProps = {
className?: string; className?: string;
@ -168,7 +159,12 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
isDesktop ? showTooltip(undefined) : null isDesktop ? showTooltip(undefined) : null
} }
> >
{getIconForGroup(config.icon)} {config && config.icon && isValidIconName(config.icon) && (
<IconRenderer
icon={LuIcons[config.icon]}
className="size-4"
/>
)}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipPortal> <TooltipPortal>
@ -503,7 +499,12 @@ export function CameraGroupEdit({
cameras: z.array(z.string()).min(2, { cameras: z.array(z.string()).min(2, {
message: "You must select at least two cameras.", 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( const onSubmit = useCallback(
@ -559,10 +560,10 @@ export function CameraGroupEdit({
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
mode: "onChange", mode: "onSubmit",
defaultValues: { defaultValues: {
name: (editingGroup && editingGroup[0]) ?? "", name: (editingGroup && editingGroup[0]) ?? "",
icon: editingGroup && editingGroup[1].icon, icon: editingGroup && (editingGroup[1].icon as IconName),
cameras: editingGroup && editingGroup[1].cameras, cameras: editingGroup && editingGroup[1].cameras,
}, },
}); });
@ -571,7 +572,7 @@ export function CameraGroupEdit({
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="mt-2 space-y-6 overflow-y-hidden" className="mt-2 space-y-6 overflow-y-auto"
> >
<FormField <FormField
control={form.control} control={form.control}
@ -631,29 +632,20 @@ export function CameraGroupEdit({
control={form.control} control={form.control}
name="icon" name="icon"
render={({ field }) => ( render={({ field }) => (
<FormItem className="space-y-3"> <FormItem className="flex flex-col space-y-2">
<FormLabel>Icon</FormLabel> <FormLabel>Icon</FormLabel>
<FormControl> <FormControl>
<Select <IconPicker
onValueChange={field.onChange} selectedIcon={{
defaultValue={field.value} name: field.value,
> Icon: field.value
<FormControl> ? LuIcons[field.value as IconName]
<SelectTrigger> : undefined,
<SelectValue placeholder="Select an icon" /> }}
</SelectTrigger> setSelectedIcon={(newIcon) => {
</FormControl> field.onChange(newIcon?.name ?? undefined);
<SelectContent> }}
{GROUP_ICONS.map((gIcon) => ( />
<SelectItem key={gIcon} value={gIcon}>
<div className="flex flex-row justify-start items-center gap-2">
<div className="size-4">{getIconForGroup(gIcon)}</div>
{gIcon}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -662,7 +654,7 @@ export function CameraGroupEdit({
<Separator className="flex my-2 bg-secondary" /> <Separator className="flex my-2 bg-secondary" />
<div className="flex flex-row gap-2 pt-5"> <div className="flex flex-row gap-2 py-5 md:pb-0">
<Button className="flex flex-1" onClick={onCancel}> <Button className="flex flex-1" onClick={onCancel}>
Cancel Cancel
</Button> </Button>

View File

@ -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<IconElement | undefined>
>;
};
export default function IconPicker({
selectedIcon,
setSelectedIcon,
}: IconPickerProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef}>
<Popover
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
>
<PopoverTrigger asChild>
{!selectedIcon?.name || !selectedIcon?.Icon ? (
<Button className="text-muted-foreground w-full mt-2">
Select an icon
</Button>
) : (
<div className="hover:cursor-pointer">
<div className="flex flex-row w-full justify-between items-center gap-2 my-3">
<div className="flex flex-row items-center gap-2">
<selectedIcon.Icon size={15} />
<div className="text-sm">
{selectedIcon.name
.replace(/^Lu/, "")
.replace(/([A-Z])/g, " $1")}
</div>
</div>
<IoClose
className="mx-2 hover:cursor-pointer"
onClick={() => {
handleIconSelect({ name: undefined, Icon: undefined });
}}
/>
</div>
</div>
)}
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
container={containerRef.current}
className="max-h-[50dvh]"
>
<div className="flex flex-row justify-between items-center mb-3">
<Heading as="h4">Select an icon</Heading>
<IoClose
size={15}
className="hover:cursor-pointer"
onClick={() => {
setOpen(false);
}}
/>
</div>
<Input
type="text"
placeholder="Search for an icon..."
className="mb-3"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="flex flex-col flex-1 h-[20dvh]">
<div className="grid grid-cols-6 my-2 gap-2 max-h-[20dvh] overflow-y-auto pr-1">
{icons.map(([name, Icon]) => (
<div
key={name}
className={cn(
"flex flex-row justify-center items-start hover:cursor-pointer p-1 rounded-lg",
selectedIcon?.name === name
? "bg-selected text-white"
: "hover:bg-secondary-foreground",
)}
>
<Icon
size={20}
onClick={() => {
handleIconSelect({ name, Icon });
setOpen(false);
}}
/>
</div>
))}
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}
type IconRendererProps = {
icon: IconType;
size?: number;
className?: string;
};
export function IconRenderer({ icon, size, className }: IconRendererProps) {
return <>{React.createElement(icon, { size, className })}</>;
}

View File

@ -1,29 +1,36 @@
import * as React from "react" import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover" 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< const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>, React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( container?: HTMLElement | null;
<PopoverPrimitive.Portal> }
<PopoverPrimitive.Content >(
ref={ref} (
align={align} { className, container, align = "center", sideOffset = 4, ...props },
sideOffset={sideOffset} ref,
className={cn( ) => (
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", <PopoverPrimitive.Portal container={container}>
className <PopoverPrimitive.Content
)} ref={ref}
{...props} align={align}
/> sideOffset={sideOffset}
</PopoverPrimitive.Portal> className={cn(
)) "z-50 w-72 rounded-lg border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
PopoverContent.displayName = PopoverPrimitive.Content.displayName className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
),
);
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent } export { Popover, PopoverTrigger, PopoverContent };

View File

@ -28,6 +28,7 @@ import { Label } from "@/components/ui/label";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import SummaryTimeline from "@/components/timeline/SummaryTimeline"; import SummaryTimeline from "@/components/timeline/SummaryTimeline";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import IconPicker, { IconElement } from "@/components/icons/IconPicker";
// Color data // Color data
const colors = [ const colors = [
@ -207,6 +208,8 @@ function UIPlayground() {
const [isEventsReviewTimeline, setIsEventsReviewTimeline] = useState(true); const [isEventsReviewTimeline, setIsEventsReviewTimeline] = useState(true);
const birdseyeConfig = config?.birdseye; const birdseyeConfig = config?.birdseye;
const [selectedIcon, setSelectedIcon] = useState<IconElement>();
return ( return (
<> <>
<div className="w-full h-full"> <div className="w-full h-full">
@ -214,6 +217,15 @@ function UIPlayground() {
<div className="flex-1 content-start gap-2 overflow-y-auto no-scrollbar mt-4 mr-5"> <div className="flex-1 content-start gap-2 overflow-y-auto no-scrollbar mt-4 mr-5">
<Heading as="h2">UI Playground</Heading> <Heading as="h2">UI Playground</Heading>
<IconPicker
selectedIcon={selectedIcon}
setSelectedIcon={setSelectedIcon}
/>
{selectedIcon?.name && (
<p>Selected icon name: {selectedIcon.name}</p>
)}
<Heading as="h4" className="my-5"> <Heading as="h4" className="my-5">
Scrubber Scrubber
</Heading> </Heading>

View File

@ -1,3 +1,4 @@
import { IconName } from "@/components/icons/IconPicker";
import { LivePlayerMode } from "./live"; import { LivePlayerMode } from "./live";
export interface UiConfig { export interface UiConfig {
@ -222,11 +223,9 @@ export interface CameraConfig {
}; };
} }
export const GROUP_ICONS = ["car", "cat", "dog", "leaf"] as const;
export type CameraGroupConfig = { export type CameraGroupConfig = {
cameras: string[]; cameras: string[];
icon: (typeof GROUP_ICONS)[number]; icon: IconName;
order: number; order: number;
}; };

View File

@ -1,3 +1,4 @@
import { IconName } from "@/components/icons/IconPicker";
import { BsPersonWalking } from "react-icons/bs"; import { BsPersonWalking } from "react-icons/bs";
import { import {
FaAmazon, FaAmazon,
@ -6,35 +7,18 @@ import {
FaCarSide, FaCarSide,
FaCat, FaCat,
FaCheckCircle, FaCheckCircle,
FaCircle,
FaDog, FaDog,
FaFedex, FaFedex,
FaFire, FaFire,
FaLeaf,
FaUps, FaUps,
} from "react-icons/fa"; } from "react-icons/fa";
import { GiHummingbird } from "react-icons/gi"; import { GiHummingbird } from "react-icons/gi";
import { LuBox, LuLassoSelect } from "react-icons/lu"; import { LuBox, LuLassoSelect } from "react-icons/lu";
import * as LuIcons from "react-icons/lu";
import { MdRecordVoiceOver } from "react-icons/md"; import { MdRecordVoiceOver } from "react-icons/md";
export function getIconTypeForGroup(icon: string) { export function isValidIconName(value: string): value is IconName {
switch (icon) { return Object.keys(LuIcons).includes(value as IconName);
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 <GroupIcon className={className} />;
} }
export function getIconForLabel(label: string, className?: string) { export function getIconForLabel(label: string, className?: string) {