Skip to content

Commit

Permalink
Drag to reorder/resize cameras in camera groups (#11279)
Browse files Browse the repository at this point in the history
* draggable/resizable cameras in camera groups on desktop/tablets

* fix edit button location on tablets

* assume 1rem is 16px
  • Loading branch information
hawkeye217 authored May 7, 2024
1 parent 08e5c79 commit ff2948a
Show file tree
Hide file tree
Showing 9 changed files with 714 additions and 59 deletions.
71 changes: 71 additions & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"react-day-picker": "^8.10.1",
"react-device-detect": "^2.2.3",
"react-dom": "^18.2.0",
"react-grid-layout": "^1.4.4",
"react-hook-form": "^7.51.3",
"react-icons": "^5.1.0",
"react-konva": "^18.2.10",
Expand Down Expand Up @@ -81,6 +82,7 @@
"@types/node": "^20.12.7",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@types/react-grid-layout": "^1.3.5",
"@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8",
Expand Down
28 changes: 24 additions & 4 deletions web/src/components/filter/CameraGroupSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import ActivityIndicator from "../indicators/activity-indicator";
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";

Expand Down Expand Up @@ -89,7 +90,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {

// groups

const [group, setGroup] = usePersistedOverlayState(
const [group, setGroup, deleteGroup] = usePersistedOverlayState(
"cameraGroup",
"default" as string,
);
Expand Down Expand Up @@ -118,6 +119,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
currentGroups={groups}
activeGroup={group}
setGroup={setGroup}
deleteGroup={deleteGroup}
/>
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
<div
Expand Down Expand Up @@ -198,13 +200,15 @@ type NewGroupDialogProps = {
currentGroups: [string, CameraGroupConfig][];
activeGroup?: string;
setGroup: (value: string | undefined, replace?: boolean | undefined) => void;
deleteGroup: () => void;
};
function NewGroupDialog({
open,
setOpen,
currentGroups,
activeGroup,
setGroup,
deleteGroup,
}: NewGroupDialogProps) {
const { mutate: updateConfig } = useSWR<FrigateConfig>("config");

Expand All @@ -225,11 +229,16 @@ function NewGroupDialog({
const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
const [isLoading, setIsLoading] = useState(false);

const [, , , deleteGridLayout] = usePersistence(
`${activeGroup}-draggable-layout`,
);

// callbacks

const onDeleteGroup = useCallback(
async (name: string) => {
// TODO: reset order on groups when deleting
deleteGridLayout();
deleteGroup();

await axios
.put(`config/set?camera_groups.${name}`, { requires_restart: 0 })
Expand Down Expand Up @@ -260,7 +269,14 @@ function NewGroupDialog({
setIsLoading(false);
});
},
[updateConfig, activeGroup, setGroup, setOpen],
[
updateConfig,
activeGroup,
setGroup,
setOpen,
deleteGroup,
deleteGridLayout,
],
);

const onSave = () => {
Expand Down Expand Up @@ -479,7 +495,11 @@ export function CameraGroupEdit({
{
message: "Camera group name already exists.",
},
),
)
.refine((value: string) => value.toLowerCase() !== "default", {
message: "Invalid camera group name.",
}),

cameras: z.array(z.string()).min(2, {
message: "You must select at least two cameras.",
}),
Expand Down
82 changes: 77 additions & 5 deletions web/src/components/settings/General.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,91 @@
import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useEffect } from "react";
import { useCallback, useEffect } from "react";
import { Toaster } from "sonner";
import { toast } from "sonner";
import { Separator } from "../ui/separator";
import { Button } from "../ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { del as delData } from "idb-keyval";

export default function General() {
const { data: config } = useSWR<FrigateConfig>("config");

const clearStoredLayouts = useCallback(() => {
if (!config) {
return [];
}

Object.entries(config.camera_groups).forEach(async (value) => {
await delData(`${value[0]}-draggable-layout`)
.then(() => {
toast.success(`Cleared stored layout for ${value[0]}`, {
position: "top-center",
});
})
.catch((error) => {
toast.error(
`Failed to clear stored layout: ${error.response.data.message}`,
{ position: "top-center" },
);
});
});
}, [config]);

useEffect(() => {
document.title = "General Settings - Frigate";
}, []);

return (
<>
<Heading as="h2">Settings</Heading>
<div className="flex items-center space-x-2 mt-5">
<Switch id="lowdata" checked={false} onCheckedChange={() => {}} />
<Label htmlFor="lowdata">Low Data Mode (this device only)</Label>
<div className="flex flex-col md:flex-row size-full">
<Toaster position="top-center" closeButton={true} />
<div className="flex flex-col h-full w-full overflow-y-auto mt-2 md:mt-0 mb-10 md:mb-0 order-last md:order-none md:mr-2 rounded-lg border-secondary-foreground border-[1px] p-2 bg-background_alt">
<Heading as="h3" className="my-2">
General Settings
</Heading>

<div className="flex flex-col w-full space-y-6">
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<div className="text-md">Stored Layouts</div>
<div className="text-sm text-muted-foreground my-2">
<p>
The layout of cameras in a camera group can be
dragged/resized. The positions are stored in your browser's
local storage.
</p>
</div>
</div>
<div className="flex flex-row justify-start items-center gap-2">
<Button onClick={clearStoredLayouts}>Clear All Layouts</Button>
</div>
</div>
<Separator className="flex my-2 bg-secondary" />
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<div className="text-md">Low Data Mode</div>
<div className="text-sm text-muted-foreground my-2">
<p>
Not yet implemented. <em>Default: disabled</em>
</p>
</div>
</div>
<div className="flex flex-row justify-start items-center gap-2">
<Switch
id="lowdata"
checked={false}
onCheckedChange={() => {}}
/>
<Label htmlFor="lowdata">
Low Data Mode (this device only)
</Label>
</div>
</div>
</div>
</div>
</div>
</>
);
Expand Down
14 changes: 8 additions & 6 deletions web/src/hooks/use-overlay-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ export function useOverlayState<S>(
export function usePersistedOverlayState<S extends string>(
key: string,
defaultValue: S | undefined = undefined,
): [S | undefined, (value: S | undefined, replace?: boolean) => void] {
const [persistedValue, setPersistedValue] = usePersistence<S>(
key,
defaultValue,
);
): [
S | undefined,
(value: S | undefined, replace?: boolean) => void,
() => void,
] {
const [persistedValue, setPersistedValue, , deletePersistedValue] =
usePersistence<S>(key, defaultValue);
const location = useLocation();
const navigate = useNavigate();

const currentLocationState = useMemo(() => location.state, [location]);

const setOverlayStateValue = useCallback(
Expand All @@ -63,6 +64,7 @@ export function usePersistedOverlayState<S extends string>(
return [
overlayStateValue ?? persistedValue ?? defaultValue,
setOverlayStateValue,
deletePersistedValue,
];
}

Expand Down
10 changes: 8 additions & 2 deletions web/src/hooks/use-persistence.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useEffect, useState, useCallback } from "react";
import { get as getData, set as setData } from "idb-keyval";
import { get as getData, set as setData, del as delData } from "idb-keyval";

type usePersistenceReturn<S> = [
value: S | undefined,
setValue: (value: S | undefined) => void,
loaded: boolean,
deleteValue: () => void,
];

export function usePersistence<S>(
Expand All @@ -26,6 +27,11 @@ export function usePersistence<S>(
[key],
);

const deleteValue = useCallback(async () => {
await delData(key);
setInternalValue(defaultValue);
}, [key, defaultValue]);

useEffect(() => {
setLoaded(false);
setInternalValue(defaultValue);
Expand All @@ -41,5 +47,5 @@ export function usePersistence<S>(
load();
}, [key, defaultValue, setValue]);

return [value, setValue, loaded];
return [value, setValue, loaded, deleteValue];
}
1 change: 1 addition & 0 deletions web/src/pages/Live.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ function Live() {
return (
<LiveDashboardView
cameras={cameras}
cameraGroup={cameraGroup}
includeBirdseye={includesBirdseye}
onSelectCamera={setSelectedCameraName}
/>
Expand Down
Loading

0 comments on commit ff2948a

Please sign in to comment.