From 434c17ec7757cadc5f3ba2886d17615e901ddc0b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 18 May 2026 13:44:38 -0500 Subject: [PATCH] add ability to reorder cameras from management pane --- web/public/locales/en/views/settings.json | 8 +- .../views/settings/CameraManagementView.tsx | 147 +++++++++++++++--- 2 files changed, 134 insertions(+), 21 deletions(-) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 1391e69ede..27e2f01ce9 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -477,10 +477,11 @@ "streams": { "title": "Enable / Disable Cameras", "enableLabel": "Enabled cameras", - "enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.
Note: This does not disable go2rtc restreams.", + "enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.
Note: This does not disable go2rtc restreams.

Drag the handle to reorder the cameras as they appear in the UI. The order of enabled cameras will be reflected throughout the UI including the Live dashboard and camera selection dropdowns.", "disableLabel": "Disabled cameras", "disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.", "enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.", + "reorderHandle": "Drag to reorder", "friendlyName": { "edit": "Edit camera display name", "title": "Edit Display Name", @@ -1685,6 +1686,11 @@ "objects": "Objects", "motion": "Motion", "continuous": "Continuous" + }, + "cameraOrder": { + "label": "Camera order", + "description": "Drag cameras to set their order in the Birdseye layout.", + "reorderHandle": "Drag to reorder" } }, "retainMode": { diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index 6233b232c6..08f6e9d8e4 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -1,5 +1,5 @@ import Heading from "@/components/ui/heading"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CONTROL_COLUMN_CLASS_NAME, SettingsGroupCard, @@ -14,7 +14,14 @@ import { useTranslation } from "react-i18next"; import CameraEditForm from "@/components/settings/CameraEditForm"; import CameraWizardDialog from "@/components/settings/CameraWizardDialog"; import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog"; -import { LuExternalLink, LuPencil, LuPlus, LuTrash2 } from "react-icons/lu"; +import { + LuExternalLink, + LuGripVertical, + LuPencil, + LuPlus, + LuTrash2, +} from "react-icons/lu"; +import { Reorder, useDragControls } from "framer-motion"; import { IoMdArrowRoundBack } from "react-icons/io"; import { Link } from "react-router-dom"; import { useDocDomain } from "@/hooks/use-doc-domain"; @@ -54,7 +61,7 @@ export default function CameraManagementView({ setUnsavedChanges, profileState, }: CameraManagementViewProps) { - const { t } = useTranslation(["views/settings"]); + const { t } = useTranslation(["views/settings", "common"]); const { data: config, mutate: updateConfig } = useSWR("config"); @@ -72,16 +79,74 @@ export default function CameraManagementView({ const [restartDialogOpen, setRestartDialogOpen] = useState(false); const { send: sendRestart } = useRestart(); - // List of cameras for dropdown const enabledCameras = useMemo(() => { if (config) { return Object.keys(config.cameras) .filter((camera) => config.cameras[camera].enabled_in_config) - .sort(); + .sort((a, b) => { + const orderA = config.cameras[a].ui?.order ?? 0; + const orderB = config.cameras[b].ui?.order ?? 0; + if (orderA !== orderB) return orderA - orderB; + return a.localeCompare(b); + }); } return []; }, [config]); + // Diverges from config during a drag and while the save is in flight. + const [orderedCameras, setOrderedCameras] = + useState(enabledCameras); + const orderedCamerasRef = useRef(orderedCameras); + useEffect(() => { + orderedCamerasRef.current = orderedCameras; + }, [orderedCameras]); + + useEffect(() => { + setOrderedCameras((prev) => { + if ( + prev.length === enabledCameras.length && + prev.every((cam, i) => cam === enabledCameras[i]) + ) { + return prev; + } + return enabledCameras; + }); + }, [enabledCameras]); + + const handleReorderDragEnd = useCallback(async () => { + const current = orderedCamerasRef.current; + if ( + current.length === enabledCameras.length && + current.every((cam, i) => cam === enabledCameras[i]) + ) { + return; + } + + const cameraUpdates: Record = {}; + current.forEach((cam, i) => { + cameraUpdates[cam] = { ui: { order: i * 10 } }; + }); + + try { + await axios.put("config/set", { + requires_restart: 0, + config_data: { cameras: cameraUpdates }, + }); + await updateConfig(); + } catch (error) { + setOrderedCameras(enabledCameras); + const errorMessage = + axios.isAxiosError(error) && + (error.response?.data?.message || error.response?.data?.detail) + ? error.response?.data?.message || error.response?.data?.detail + : t("toast.save.error.noMessage", { ns: "common" }); + + toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), { + position: "top-center", + }); + } + }, [enabledCameras, updateConfig, t]); + const disabledCameras = useMemo(() => { if (config) { return Object.keys(config.cameras) @@ -173,23 +238,22 @@ export default function CameraManagementView({

-
- {enabledCameras.map((camera) => ( -
+ {orderedCameras.map((camera) => ( + -
- - -
- -
+ camera={camera} + onConfigChanged={updateConfig} + onDragEnd={handleReorderDragEnd} + /> ))} -
+

cameraManagement.streams.enableDesc @@ -309,6 +373,49 @@ export default function CameraManagementView({ ); } +type EnabledCameraRowProps = { + camera: string; + onConfigChanged: () => Promise; + onDragEnd: () => void; +}; + +function EnabledCameraRow({ + camera, + onConfigChanged, + onDragEnd, +}: EnabledCameraRowProps) { + const { t } = useTranslation(["views/settings"]); + const controls = useDragControls(); + + return ( + +

+ + + +
+ + + ); +} + type CameraEnableSwitchProps = { cameraName: string; };