Skip to content

Commit b4b2162

Browse files
authored
Camera groups (#10223)
* Add camera group config * Add saving of camera group selection * Implement camera groups in config and live view * Fix warnings * Add tooltips to camera group items on desktop * Add camera groups to the filters for events * Fix tooltips and group selection * Cleanup
1 parent 38e7666 commit b4b2162

File tree

11 files changed

+247
-40
lines changed

11 files changed

+247
-40
lines changed

frigate/config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,16 @@ class LoggerConfig(FrigateBaseModel):
10031003
)
10041004

10051005

1006+
class CameraGroupConfig(FrigateBaseModel):
1007+
"""Represents a group of cameras."""
1008+
1009+
cameras: list[str] = Field(
1010+
default_factory=list, title="List of cameras in this group."
1011+
)
1012+
icon: str = Field(default="generic", title="Icon that represents camera group.")
1013+
order: int = Field(default=0, title="Sort order for group.")
1014+
1015+
10061016
def verify_config_roles(camera_config: CameraConfig) -> None:
10071017
"""Verify that roles are setup in the config correctly."""
10081018
assigned_roles = list(
@@ -1157,6 +1167,9 @@ class FrigateConfig(FrigateBaseModel):
11571167
default_factory=DetectConfig, title="Global object tracking configuration."
11581168
)
11591169
cameras: Dict[str, CameraConfig] = Field(title="Camera configuration.")
1170+
camera_groups: Dict[str, CameraGroupConfig] = Field(
1171+
default_factory=CameraGroupConfig, title="Camera group configuration"
1172+
)
11601173
timestamp_style: TimestampStyleConfig = Field(
11611174
default_factory=TimestampStyleConfig,
11621175
title="Global timestamp style configuration.",
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { FrigateConfig } from "@/types/frigateConfig";
2+
import { isDesktop } from "react-device-detect";
3+
import useSWR from "swr";
4+
import { MdHome } from "react-icons/md";
5+
import useOverlayState from "@/hooks/use-overlay-state";
6+
import { Button } from "../ui/button";
7+
import { useNavigate } from "react-router-dom";
8+
import { useCallback, useMemo, useState } from "react";
9+
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
10+
import { getIconForGroup } from "@/utils/iconUtil";
11+
12+
type CameraGroupSelectorProps = {
13+
className?: string;
14+
};
15+
export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
16+
const { data: config } = useSWR<FrigateConfig>("config");
17+
const navigate = useNavigate();
18+
19+
// tooltip
20+
21+
const [tooltip, setTooltip] = useState<string>();
22+
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout>();
23+
const showTooltip = useCallback(
24+
(newTooltip: string | undefined) => {
25+
if (!newTooltip) {
26+
setTooltip(newTooltip);
27+
28+
if (timeoutId) {
29+
clearTimeout(timeoutId);
30+
}
31+
} else {
32+
setTimeoutId(setTimeout(() => setTooltip(newTooltip), 500));
33+
}
34+
},
35+
[timeoutId],
36+
);
37+
38+
// groups
39+
40+
const [group, setGroup] = useOverlayState("cameraGroup");
41+
42+
const groups = useMemo(() => {
43+
if (!config) {
44+
return [];
45+
}
46+
47+
return Object.entries(config.camera_groups).sort(
48+
(a, b) => a[1].order - b[1].order,
49+
);
50+
}, [config]);
51+
52+
return (
53+
<div
54+
className={`flex items-center justify-start gap-2 ${className ?? ""} ${isDesktop ? "flex-col" : ""}`}
55+
>
56+
<Tooltip open={tooltip == "home"}>
57+
<TooltipTrigger asChild>
58+
<Button
59+
className={
60+
group == undefined
61+
? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
62+
: "text-muted-foreground bg-secondary focus:text-muted-foreground focus:bg-secondary"
63+
}
64+
size="xs"
65+
onClick={() => navigate(-1)}
66+
onMouseEnter={() => (isDesktop ? showTooltip("home") : null)}
67+
onMouseLeave={() => (isDesktop ? showTooltip(undefined) : null)}
68+
>
69+
<MdHome className="size-4" />
70+
</Button>
71+
</TooltipTrigger>
72+
<TooltipContent className="capitalize" side="right">
73+
Home
74+
</TooltipContent>
75+
</Tooltip>
76+
{groups.map(([name, config]) => {
77+
return (
78+
<Tooltip key={name} open={tooltip == name}>
79+
<TooltipTrigger asChild>
80+
<Button
81+
className={
82+
group == name
83+
? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
84+
: "text-muted-foreground bg-secondary"
85+
}
86+
size="xs"
87+
onClick={() => setGroup(name, group != undefined)}
88+
onMouseEnter={() => (isDesktop ? showTooltip(name) : null)}
89+
onMouseLeave={() => (isDesktop ? showTooltip(undefined) : null)}
90+
>
91+
{getIconForGroup(config.icon)}
92+
</Button>
93+
</TooltipTrigger>
94+
<TooltipContent className="capitalize" side="right">
95+
{name}
96+
</TooltipContent>
97+
</Tooltip>
98+
);
99+
})}
100+
</div>
101+
);
102+
}

web/src/components/filter/ReviewFilterGroup.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { LuCheck, LuVideo } from "react-icons/lu";
22
import { Button } from "../ui/button";
33
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
44
import useSWR from "swr";
5-
import { FrigateConfig } from "@/types/frigateConfig";
5+
import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig";
66
import { useCallback, useMemo, useState } from "react";
77
import {
88
DropdownMenu,
@@ -16,6 +16,8 @@ import { ReviewFilter } from "@/types/review";
1616
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
1717
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
1818
import { FaCalendarAlt, FaFilter, FaVideo } from "react-icons/fa";
19+
import { getIconTypeForGroup } from "@/utils/iconUtil";
20+
import { IconType } from "react-icons";
1921

2022
const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"];
2123

@@ -57,6 +59,16 @@ export default function ReviewFilterGroup({
5759
[config, allLabels],
5860
);
5961

62+
const groups = useMemo(() => {
63+
if (!config) {
64+
return [];
65+
}
66+
67+
return Object.entries(config.camera_groups).sort(
68+
(a, b) => a[1].order - b[1].order,
69+
);
70+
}, [config]);
71+
6072
// handle updating filters
6173

6274
const onUpdateSelectedDay = useCallback(
@@ -74,6 +86,7 @@ export default function ReviewFilterGroup({
7486
<div>
7587
<CamerasFilterButton
7688
allCameras={filterValues.cameras}
89+
groups={groups}
7790
selectedCameras={filter?.cameras}
7891
updateCameraFilter={(newCameras) => {
7992
onUpdateFilter({ ...filter, cameras: newCameras });
@@ -102,11 +115,13 @@ export default function ReviewFilterGroup({
102115

103116
type CameraFilterButtonProps = {
104117
allCameras: string[];
118+
groups: [string, CameraGroupConfig][];
105119
selectedCameras: string[] | undefined;
106120
updateCameraFilter: (cameras: string[] | undefined) => void;
107121
};
108122
function CamerasFilterButton({
109123
allCameras,
124+
groups,
110125
selectedCameras,
111126
updateCameraFilter,
112127
}: CameraFilterButtonProps) {
@@ -144,6 +159,24 @@ function CamerasFilterButton({
144159
}
145160
}}
146161
/>
162+
{groups.length > 0 && (
163+
<>
164+
<DropdownMenuSeparator />
165+
{groups.map(([name, conf]) => {
166+
return (
167+
<FilterCheckBox
168+
key={name}
169+
label={name}
170+
CheckIcon={getIconTypeForGroup(conf.icon)}
171+
isChecked
172+
onCheckedChange={() => {
173+
setCurrentCameras([...conf.cameras]);
174+
}}
175+
/>
176+
);
177+
})}
178+
</>
179+
)}
147180
<DropdownMenuSeparator />
148181
{allCameras.map((item) => (
149182
<FilterCheckBox
@@ -350,12 +383,14 @@ function LabelsFilterButton({
350383

351384
type FilterCheckBoxProps = {
352385
label: string;
386+
CheckIcon?: IconType;
353387
isChecked: boolean;
354388
onCheckedChange: (isChecked: boolean) => void;
355389
};
356390

357391
function FilterCheckBox({
358392
label,
393+
CheckIcon = LuCheck,
359394
isChecked,
360395
onCheckedChange,
361396
}: FilterCheckBoxProps) {
@@ -366,7 +401,7 @@ function FilterCheckBox({
366401
onClick={() => onCheckedChange(!isChecked)}
367402
>
368403
{isChecked ? (
369-
<LuCheck className="w-6 h-6" />
404+
<CheckIcon className="w-6 h-6" />
370405
) : (
371406
<div className="w-6 h-6" />
372407
)}

web/src/components/navigation/NavItem.tsx

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
TooltipContent,
77
TooltipTrigger,
88
} from "@/components/ui/tooltip";
9-
import { useState } from "react";
109
import { isDesktop } from "react-device-detect";
1110
import { TooltipPortal } from "@radix-ui/react-tooltip";
1211

@@ -42,32 +41,36 @@ export default function NavItem({
4241
}: NavItemProps) {
4342
const shouldRender = dev ? ENV !== "production" : true;
4443

45-
const [showTooltip, setShowTooltip] = useState(false);
44+
if (!shouldRender) {
45+
return;
46+
}
4647

47-
return (
48-
shouldRender && (
49-
<Tooltip open={isDesktop && showTooltip}>
50-
<NavLink
51-
to={url}
52-
onClick={onClick}
53-
className={({ isActive }) =>
54-
`${className} flex flex-col justify-center items-center rounded-lg ${
55-
variants[variant][isActive ? "active" : "inactive"]
56-
}`
57-
}
58-
onMouseEnter={() => (isDesktop ? setShowTooltip(true) : null)}
59-
onMouseLeave={() => (isDesktop ? setShowTooltip(false) : null)}
60-
>
61-
<TooltipTrigger>
62-
<Icon className="size-5 md:m-[6px]" />
63-
</TooltipTrigger>
64-
</NavLink>
48+
const content = (
49+
<NavLink
50+
to={url}
51+
onClick={onClick}
52+
className={({ isActive }) =>
53+
`${className} flex flex-col justify-center items-center rounded-lg ${
54+
variants[variant][isActive ? "active" : "inactive"]
55+
}`
56+
}
57+
>
58+
<Icon className="size-5 md:m-[6px]" />
59+
</NavLink>
60+
);
61+
62+
if (isDesktop) {
63+
return (
64+
<Tooltip>
65+
<TooltipTrigger>{content}</TooltipTrigger>
6566
<TooltipPortal>
6667
<TooltipContent side="right">
6768
<p>{title}</p>
6869
</TooltipContent>
6970
</TooltipPortal>
7071
</Tooltip>
71-
)
72-
);
72+
);
73+
}
74+
75+
return content;
7376
}

web/src/components/navigation/Sidebar.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,30 @@ import Logo from "../Logo";
22
import { navbarLinks } from "@/pages/site-navigation";
33
import SettingsNavItems from "../settings/SettingsNavItems";
44
import NavItem from "./NavItem";
5+
import { CameraGroupSelector } from "../filter/CameraGroupSelector";
6+
import { useLocation } from "react-router-dom";
57

68
function Sidebar() {
9+
const location = useLocation();
10+
711
return (
812
<aside className="absolute w-[52px] z-10 left-o inset-y-0 overflow-y-auto scrollbar-hidden py-4 flex flex-col justify-between bg-primary border-r border-secondary-highlight">
913
<span tabIndex={0} className="sr-only" />
1014
<div className="w-full flex flex-col gap-0 items-center">
1115
<Logo className="w-8 h-8 mb-6" />
1216
{navbarLinks.map((item) => (
13-
<NavItem
14-
className="mx-[10px] mb-6"
15-
key={item.id}
16-
Icon={item.icon}
17-
title={item.title}
18-
url={item.url}
19-
dev={item.dev}
20-
/>
17+
<div key={item.id}>
18+
<NavItem
19+
className={`mx-[10px] ${item.id == 1 ? "mb-2" : "mb-4"}`}
20+
Icon={item.icon}
21+
title={item.title}
22+
url={item.url}
23+
dev={item.dev}
24+
/>
25+
{item.id == 1 && item.url == location.pathname && (
26+
<CameraGroupSelector className="mb-4" />
27+
)}
28+
</div>
2129
))}
2230
</div>
2331
<SettingsNavItems className="hidden md:flex flex-col items-center mb-8" />

web/src/components/ui/button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const buttonVariants = cva(
2222
},
2323
size: {
2424
default: "h-10 px-4 py-2",
25-
xs: "h-6 rounded-md",
25+
xs: "size-6 rounded-md",
2626
sm: "h-9 rounded-md px-3",
2727
lg: "h-11 rounded-md px-8",
2828
icon: "h-10 w-10",

web/src/hooks/use-overlay-state.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
1-
import { useCallback } from "react";
1+
import { useCallback, useMemo } from "react";
22
import { useLocation, useNavigate } from "react-router-dom";
33

4-
export default function useOverlayState(key: string) {
4+
export default function useOverlayState(
5+
key: string,
6+
): [string | undefined, (value: string, replace?: boolean) => void] {
57
const location = useLocation();
68
const navigate = useNavigate();
79
const currentLocationState = location.state;
810

911
const setOverlayStateValue = useCallback(
10-
(value: string) => {
12+
(value: string, replace: boolean = false) => {
1113
const newLocationState = { ...currentLocationState };
1214
newLocationState[key] = value;
13-
navigate(location.pathname, { state: newLocationState });
15+
navigate(location.pathname, { state: newLocationState, replace });
1416
},
1517
// we know that these deps are correct
1618
// eslint-disable-next-line react-hooks/exhaustive-deps
1719
[key, navigate],
1820
);
1921

20-
const overlayStateValue = location.state && location.state[key];
22+
const overlayStateValue = useMemo<string | undefined>(
23+
() => location.state && location.state[key],
24+
[location, key],
25+
);
26+
2127
return [overlayStateValue, setOverlayStateValue];
2228
}

0 commit comments

Comments
 (0)