diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 27e2f01ce9..edfbb19360 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1690,7 +1690,9 @@ "cameraOrder": { "label": "Camera order", "description": "Drag cameras to set their order in the Birdseye layout.", - "reorderHandle": "Drag to reorder" + "reorderHandle": "Drag to reorder", + "saving": "Saving…", + "saved": "Saved" } }, "retainMode": { diff --git a/web/src/components/config-form/section-configs/birdseye.ts b/web/src/components/config-form/section-configs/birdseye.ts index 26e3d2ec8c..c22e006554 100644 --- a/web/src/components/config-form/section-configs/birdseye.ts +++ b/web/src/components/config-form/section-configs/birdseye.ts @@ -56,6 +56,7 @@ const birdseye: SectionConfigOverrides = { uiSchema: { mode: { "ui:size": "xs", + "ui:after": { render: "BirdseyeCameraReorder" }, }, }, }, diff --git a/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx b/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx new file mode 100644 index 0000000000..b481e9a86f --- /dev/null +++ b/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx @@ -0,0 +1,213 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import axios from "axios"; +import { toast } from "sonner"; +import useSWR from "swr"; +import { Reorder, useDragControls } from "framer-motion"; +import { LuCheck, LuGripVertical } from "react-icons/lu"; +import { SplitCardRow } from "@/components/card/SettingsGroupCard"; +import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { cn } from "@/lib/utils"; +import type { SectionRendererProps } from "./registry"; + +const SAVED_INDICATOR_MS = 1500; + +type SaveStatus = "idle" | "saving" | "saved"; + +export default function BirdseyeCameraReorder({ + formContext, +}: SectionRendererProps) { + const { t } = useTranslation(["views/settings", "common"]); + const { data: config, mutate: updateConfig } = + useSWR("config"); + + const birdseyeCameras = useMemo(() => { + if (!config) return []; + return Object.keys(config.cameras) + .filter( + (name) => + config.cameras[name].enabled_in_config && + config.cameras[name].birdseye?.enabled !== false, + ) + .sort((a, b) => { + const orderA = config.cameras[a].birdseye?.order ?? 0; + const orderB = config.cameras[b].birdseye?.order ?? 0; + if (orderA !== orderB) return orderA - orderB; + return a.localeCompare(b); + }); + }, [config]); + + const [orderedCameras, setOrderedCameras] = + useState(birdseyeCameras); + const orderedCamerasRef = useRef(orderedCameras); + useEffect(() => { + orderedCamerasRef.current = orderedCameras; + }, [orderedCameras]); + + useEffect(() => { + setOrderedCameras((prev) => { + if ( + prev.length === birdseyeCameras.length && + prev.every((cam, i) => cam === birdseyeCameras[i]) + ) { + return prev; + } + return birdseyeCameras; + }); + }, [birdseyeCameras]); + + const [saveStatus, setSaveStatus] = useState("idle"); + const savedResetTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (savedResetTimerRef.current) { + clearTimeout(savedResetTimerRef.current); + } + }; + }, []); + + const handleDragEnd = useCallback(async () => { + const current = orderedCamerasRef.current; + if ( + current.length === birdseyeCameras.length && + current.every((cam, i) => cam === birdseyeCameras[i]) + ) { + return; + } + + const cameraUpdates: Record = {}; + current.forEach((cam, i) => { + cameraUpdates[cam] = { birdseye: { order: i * 10 } }; + }); + + if (savedResetTimerRef.current) { + clearTimeout(savedResetTimerRef.current); + savedResetTimerRef.current = null; + } + setSaveStatus("saving"); + + try { + await axios.put("config/set", { + requires_restart: 0, + config_data: { cameras: cameraUpdates }, + }); + await updateConfig(); + setSaveStatus("saved"); + savedResetTimerRef.current = setTimeout(() => { + setSaveStatus("idle"); + savedResetTimerRef.current = null; + }, SAVED_INDICATOR_MS); + } catch (error) { + setOrderedCameras(birdseyeCameras); + setSaveStatus("idle"); + 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", + }); + } + }, [birdseyeCameras, updateConfig, t]); + + if (formContext?.level && formContext.level !== "global") { + return null; + } + + if (!config || birdseyeCameras.length < 2) { + return null; + } + + return ( + + + {orderedCameras.map((camera) => ( + + ))} + + + + } + /> + ); +} + +type SaveStatusIndicatorProps = { + status: SaveStatus; +}; + +function SaveStatusIndicator({ status }: SaveStatusIndicatorProps) { + const { t } = useTranslation(["views/settings"]); + return ( +
+ {status === "saving" && ( + + {t("birdseye.cameraOrder.saving")} + + )} + {status === "saved" && ( + + + {t("birdseye.cameraOrder.saved")} + + )} +
+ ); +} + +type BirdseyeCameraRowProps = { + camera: string; + onDragEnd: () => void; +}; + +function BirdseyeCameraRow({ camera, onDragEnd }: BirdseyeCameraRowProps) { + const { t } = useTranslation(["views/settings"]); + const controls = useDragControls(); + + return ( + + + + + ); +} diff --git a/web/src/components/config-form/sectionExtras/registry.ts b/web/src/components/config-form/sectionExtras/registry.ts index 08e3dd86a8..26cd48dbdb 100644 --- a/web/src/components/config-form/sectionExtras/registry.ts +++ b/web/src/components/config-form/sectionExtras/registry.ts @@ -3,6 +3,7 @@ import SemanticSearchReindex from "./SemanticSearchReindex.tsx"; import CameraReviewStatusToggles from "./CameraReviewStatusToggles"; import ProxyRoleMap from "./ProxyRoleMap"; import NotificationsSettingsExtras from "./NotificationsSettingsExtras"; +import BirdseyeCameraReorder from "./BirdseyeCameraReorder"; import type { ConfigFormContext } from "@/types/configForm"; // Props that will be injected into all section renderers @@ -52,6 +53,9 @@ export const sectionRenderers: SectionRenderers = { notifications: { NotificationsSettingsExtras, }, + birdseye: { + BirdseyeCameraReorder, + }, }; export default sectionRenderers;