diff --git a/web/src/views/settings/DetectorsAndModelSettingsView.tsx b/web/src/views/settings/DetectorsAndModelSettingsView.tsx new file mode 100644 index 0000000000..6a51fb26d2 --- /dev/null +++ b/web/src/views/settings/DetectorsAndModelSettingsView.tsx @@ -0,0 +1,202 @@ +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import { LuExternalLink } from "react-icons/lu"; +import useSWR from "swr"; +import { cn } from "@/lib/utils"; +import { useDocDomain } from "@/hooks/use-doc-domain"; +import { StatusBarMessagesContext } from "@/context/statusbar-provider"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import Heading from "@/components/ui/heading"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Toaster } from "@/components/ui/sonner"; +import type { FrigateConfig } from "@/types/frigateConfig"; +import type { SettingsPageProps } from "@/views/settings/SingleSectionPage"; +import type { ConfigSectionData } from "@/types/configForm"; + +type ModelTab = "plus" | "custom"; + +type PageState = { + detectors: ConfigSectionData; + modelTab: ModelTab; + plusModelId: string | undefined; + customModel: ConfigSectionData; +}; + +const STATUS_BAR_KEY = "detectors_and_model"; + +const deriveInitialState = (config: FrigateConfig): PageState => { + const modelPath = config.model?.path; + const plusEnabled = Boolean(config.plus?.enabled); + let modelTab: ModelTab; + if (typeof modelPath === "string" && modelPath.startsWith("plus://")) { + modelTab = "plus"; + } else if (typeof modelPath === "string" && modelPath.length > 0) { + modelTab = "custom"; + } else if (plusEnabled) { + modelTab = "plus"; + } else { + modelTab = "custom"; + } + + const plusModelId = config.model?.plus?.id; + const { plus: _plus, ...modelWithoutPlus } = (config.model ?? {}) as Record< + string, + unknown + >; + + return { + detectors: (config.detectors ?? {}) as ConfigSectionData, + modelTab, + plusModelId: plusModelId ?? undefined, + customModel: modelWithoutPlus as ConfigSectionData, + }; +}; + +export default function DetectorsAndModelSettingsView( + _props: SettingsPageProps, +) { + const { t } = useTranslation(["views/settings", "common"]); + const { getLocaleDocUrl } = useDocDomain(); + const { data: config } = useSWR("config"); + const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; + + const [snapshot, setSnapshot] = useState(null); + const [state, setState] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (!config || snapshot !== null) return; + const initial = deriveInitialState(config); + setSnapshot(initial); + setState(initial); + }, [config, snapshot]); + + const isDirty = useMemo(() => { + if (!state || !snapshot) return false; + return JSON.stringify(state) !== JSON.stringify(snapshot); + }, [state, snapshot]); + + useEffect(() => { + if (isDirty) { + addMessage( + STATUS_BAR_KEY, + t("detectorsAndModel.unsavedChanges"), + undefined, + STATUS_BAR_KEY, + ); + } else { + removeMessage(STATUS_BAR_KEY, STATUS_BAR_KEY); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDirty]); + + useEffect(() => { + document.title = `${t("detectorsAndModel.title")} - Frigate`; + }, [t]); + + const onSave = useCallback(async () => { + // implemented in Task 9 + setIsSaving(true); + setIsSaving(false); + }, []); + + const onUndo = useCallback(() => { + if (snapshot) setState(snapshot); + }, [snapshot]); + + if (!config || !state) { + return ; + } + + const saveDisabled = !isDirty || isSaving; + + return ( +
+ +
+
+
+ {t("detectorsAndModel.title")} +
+ {t("detectorsAndModel.description")} +
+
+ + {t("readTheDocumentation", { ns: "common" })} + + +
+
+ {isDirty && ( + + {t("button.modified", { ns: "common", defaultValue: "Modified" })} + + )} +
+ +
+
+ {t("detectorsAndModel.cardTitles.detector")} — placeholder, filled + in Task 5. +
+
+ {t("detectorsAndModel.cardTitles.model")} — placeholder, filled in + Tasks 6–8. +
+
+
+ +
+
+ {isDirty && ( + + {t("unsavedChanges", { ns: "views/settings" })} + + )} +
+ {isDirty && ( + + )} + +
+
+
+
+ ); +}