mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
Icon picker component (#11310)
* icon picker component * keep box the same size when filtering icons
This commit is contained in:
parent
50ee447e52
commit
f8523d9ddf
@ -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) && (
|
||||
<IconRenderer
|
||||
icon={LuIcons[config.icon]}
|
||||
className="size-4"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
@ -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<z.infer<typeof formSchema>>({
|
||||
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({
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="mt-2 space-y-6 overflow-y-hidden"
|
||||
className="mt-2 space-y-6 overflow-y-auto"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
@ -631,29 +632,20 @@ export function CameraGroupEdit({
|
||||
control={form.control}
|
||||
name="icon"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormItem className="flex flex-col space-y-2">
|
||||
<FormLabel>Icon</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an icon" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<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>
|
||||
<IconPicker
|
||||
selectedIcon={{
|
||||
name: field.value,
|
||||
Icon: field.value
|
||||
? LuIcons[field.value as IconName]
|
||||
: undefined,
|
||||
}}
|
||||
setSelectedIcon={(newIcon) => {
|
||||
field.onChange(newIcon?.name ?? undefined);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -662,7 +654,7 @@ export function CameraGroupEdit({
|
||||
|
||||
<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}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
154
web/src/components/icons/IconPicker.tsx
Normal file
154
web/src/components/icons/IconPicker.tsx
Normal 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 })}</>;
|
||||
}
|
@ -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<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
|
||||
container?: HTMLElement | null;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, container, align = "center", sideOffset = 4, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<PopoverPrimitive.Portal container={container}>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
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",
|
||||
className
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
),
|
||||
);
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
|
@ -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<IconElement>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<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">
|
||||
Scrubber
|
||||
</Heading>
|
||||
|
@ -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;
|
||||
};
|
||||
|
||||
|
@ -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 <GroupIcon className={className} />;
|
||||
export function isValidIconName(value: string): value is IconName {
|
||||
return Object.keys(LuIcons).includes(value as IconName);
|
||||
}
|
||||
|
||||
export function getIconForLabel(label: string, className?: string) {
|
||||
|
Loading…
Reference in New Issue
Block a user