From fdf7e221307fcd2df0823829e91c2ac625168534 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 13 Feb 2026 09:14:40 -0600 Subject: [PATCH] refactor frigate+ view and make tweaks to sections --- frigate/config/camera/live.py | 4 +- frigate/config/camera/motion.py | 2 +- web/public/locales/en/config/cameras.json | 6 +- web/public/locales/en/config/global.json | 6 +- web/public/locales/en/config/validation.json | 13 +- web/public/locales/en/views/settings.json | 6 + web/src/components/card/SettingsGroupCard.tsx | 56 ++ .../config-form/section-configs/live.ts | 2 +- .../config-form/section-configs/objects.ts | 2 +- .../config-form/section-configs/snapshots.ts | 2 +- .../config-form/section-validations/ffmpeg.ts | 13 + .../NotificationsSettingsExtras.tsx | 372 +++++---- web/src/pages/Logs.tsx | 2 +- web/src/pages/Settings.tsx | 2 - .../settings/FrigatePlusSettingsView.tsx | 594 ++++++------- .../settings/NotificationsSettingsView.tsx | 785 ------------------ web/src/views/settings/UiSettingsView.tsx | 29 +- 17 files changed, 594 insertions(+), 1302 deletions(-) create mode 100644 web/src/components/card/SettingsGroupCard.tsx delete mode 100644 web/src/views/settings/NotificationsSettingsView.tsx diff --git a/frigate/config/camera/live.py b/frigate/config/camera/live.py index 8302a784e..54b5a2bfd 100644 --- a/frigate/config/camera/live.py +++ b/frigate/config/camera/live.py @@ -16,12 +16,12 @@ class CameraLiveConfig(FrigateBaseModel): height: int = Field( default=720, title="Live height", - description="Height (pixels) to render the live stream in the Web UI; must be <= detect stream height.", + description="Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height.", ) quality: int = Field( default=8, ge=1, le=31, title="Live quality", - description="Encoding quality for the live jsmpeg stream (1 highest, 31 lowest).", + description="Encoding quality for the jsmpeg stream (1 highest, 31 lowest).", ) diff --git a/frigate/config/camera/motion.py b/frigate/config/camera/motion.py index 86599aa2c..d39130108 100644 --- a/frigate/config/camera/motion.py +++ b/frigate/config/camera/motion.py @@ -50,7 +50,7 @@ class MotionConfig(FrigateBaseModel): frame_height: Optional[int] = Field( default=100, title="Frame height", - description="Height in pixels to scale frames to when computing motion (useful for performance).", + description="Height in pixels to scale frames to when computing motion.", ) mask: Union[str, list[str]] = Field( default="", diff --git a/web/public/locales/en/config/cameras.json b/web/public/locales/en/config/cameras.json index ca60fa362..7fbbed4a5 100644 --- a/web/public/locales/en/config/cameras.json +++ b/web/public/locales/en/config/cameras.json @@ -224,11 +224,11 @@ }, "height": { "label": "Live height", - "description": "Height (pixels) to render the live stream in the Web UI; must be <= detect stream height." + "description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height." }, "quality": { "label": "Live quality", - "description": "Encoding quality for the live jsmpeg stream (1 highest, 31 lowest)." + "description": "Encoding quality for the jsmpeg stream (1 highest, 31 lowest)." } }, "lpr": { @@ -284,7 +284,7 @@ }, "frame_height": { "label": "Frame height", - "description": "Height in pixels to scale frames to when computing motion (useful for performance)." + "description": "Height in pixels to scale frames to when computing motion." }, "mask": { "label": "Mask coordinates", diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index b67ec7434..f4c6db617 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -1367,11 +1367,11 @@ }, "height": { "label": "Live height", - "description": "Height (pixels) to render the live stream in the Web UI; must be <= detect stream height." + "description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height." }, "quality": { "label": "Live quality", - "description": "Encoding quality for the live jsmpeg stream (1 highest, 31 lowest)." + "description": "Encoding quality for the jsmpeg stream (1 highest, 31 lowest)." } }, "motion": { @@ -1407,7 +1407,7 @@ }, "frame_height": { "label": "Frame height", - "description": "Height in pixels to scale frames to when computing motion (useful for performance)." + "description": "Height in pixels to scale frames to when computing motion." }, "mask": { "label": "Mask coordinates", diff --git a/web/public/locales/en/config/validation.json b/web/public/locales/en/config/validation.json index 2824f6147..638725a56 100644 --- a/web/public/locales/en/config/validation.json +++ b/web/public/locales/en/config/validation.json @@ -17,6 +17,15 @@ "additionalProperties": "Unknown property is not allowed", "oneOf": "Must match exactly one of the allowed schemas", "anyOf": "Must match at least one of the allowed schemas", - "proxy.header_map.roleHeaderRequired": "Role header is required when role mappings are configured.", - "ffmpeg.inputs.rolesUnique": "Each role can only be assigned to one input stream." + "proxy": { + "header_map": { + "roleHeaderRequired": "Role header is required when role mappings are configured." + } + }, + "ffmpeg": { + "inputs": { + "rolesUnique": "Each role can only be assigned to one input stream.", + "detectRequired": "At least one input stream must be assigned the 'detect' role." + } + } } diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index a5fe096ae..a0f8f1630 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -982,6 +982,12 @@ }, "frigatePlus": { "title": "Frigate+ Settings", + "cardTitles": { + "api": "API", + "currentModel": "Current model", + "otherModels": "Other models", + "configuration": "Configuration" + }, "apiKey": { "title": "Frigate+ API Key", "validated": "Frigate+ API key is detected and validated", diff --git a/web/src/components/card/SettingsGroupCard.tsx b/web/src/components/card/SettingsGroupCard.tsx new file mode 100644 index 000000000..3bc53fb36 --- /dev/null +++ b/web/src/components/card/SettingsGroupCard.tsx @@ -0,0 +1,56 @@ +import { ReactNode } from "react"; +import { Label } from "../ui/label"; + +export const SPLIT_ROW_CLASS_NAME = + "space-y-2 md:grid md:grid-cols-[minmax(14rem,22rem)_minmax(0,1fr)] md:items-start md:gap-x-6 md:space-y-0"; +export const DESCRIPTION_CLASS_NAME = "text-sm text-muted-foreground"; +export const CONTROL_COLUMN_CLASS_NAME = "w-full md:max-w-2xl"; + +type SettingsGroupCardProps = { + title: string; + children: ReactNode; +}; + +export function SettingsGroupCard({ title, children }: SettingsGroupCardProps) { + return ( +
+
+ {title} +
+ {children} +
+ ); +} + +type SplitCardRowProps = { + label: ReactNode; + description?: ReactNode; + content: ReactNode; +}; + +export function SplitCardRow({ + label, + description, + content, +}: SplitCardRowProps) { + return ( +
+
+ + {description && ( +
+ {description} +
+ )} +
+
+ {content} + {description && ( +
+ {description} +
+ )} +
+
+ ); +} diff --git a/web/src/components/config-form/section-configs/live.ts b/web/src/components/config-form/section-configs/live.ts index 5bdf34c8b..56ec386da 100644 --- a/web/src/components/config-form/section-configs/live.ts +++ b/web/src/components/config-form/section-configs/live.ts @@ -7,7 +7,7 @@ const live: SectionConfigOverrides = { fieldOrder: ["stream_name", "height", "quality"], fieldGroups: {}, hiddenFields: ["enabled_in_config"], - advancedFields: ["quality"], + advancedFields: ["height", "quality"], }, global: { hiddenFields: ["streams"], diff --git a/web/src/components/config-form/section-configs/objects.ts b/web/src/components/config-form/section-configs/objects.ts index a90e2e9e1..ccf117b3d 100644 --- a/web/src/components/config-form/section-configs/objects.ts +++ b/web/src/components/config-form/section-configs/objects.ts @@ -27,7 +27,7 @@ const objects: SectionConfigOverrides = { "filters.mask", "filters.raw_mask", ], - advancedFields: ["filters"], + advancedFields: ["genai"], uiSchema: { "filters.*.min_area": { "ui:options": { diff --git a/web/src/components/config-form/section-configs/snapshots.ts b/web/src/components/config-form/section-configs/snapshots.ts index 793f6f896..a46ef5aea 100644 --- a/web/src/components/config-form/section-configs/snapshots.ts +++ b/web/src/components/config-form/section-configs/snapshots.ts @@ -16,7 +16,7 @@ const snapshots: SectionConfigOverrides = { display: ["enabled", "bounding_box", "crop", "quality", "timestamp"], }, hiddenFields: ["enabled_in_config"], - advancedFields: ["quality", "retain"], + advancedFields: ["height", "quality", "retain"], uiSchema: { required_zones: { "ui:widget": "zoneNames", diff --git a/web/src/components/config-form/section-validations/ffmpeg.ts b/web/src/components/config-form/section-validations/ffmpeg.ts index 4fa28700a..6143eb7dd 100644 --- a/web/src/components/config-form/section-validations/ffmpeg.ts +++ b/web/src/components/config-form/section-validations/ffmpeg.ts @@ -18,6 +18,7 @@ export function validateFfmpegInputRoles( } const roleCounts = new Map(); + let hasDetect = false; inputs.forEach((input) => { if (!isJsonObject(input) || !Array.isArray(input.roles)) { return; @@ -28,6 +29,9 @@ export function validateFfmpegInputRoles( } roleCounts.set(role, (roleCounts.get(role) || 0) + 1); }); + if (input.roles.includes("detect")) { + hasDetect = true; + } }); const hasDuplicates = Array.from(roleCounts.values()).some( @@ -43,5 +47,14 @@ export function validateFfmpegInputRoles( ); } + if (!hasDetect) { + const inputsErrors = errors.inputs as { + addError?: (message: string) => void; + }; + inputsErrors?.addError?.( + t("ffmpeg.inputs.detectRequired", { ns: "config/validation" }), + ); + } + return errors; } diff --git a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx index 13e7f99cb..f9fb6addf 100644 --- a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx +++ b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx @@ -9,9 +9,7 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; -import Heading from "@/components/ui/heading"; import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; import { Toaster } from "@/components/ui/sonner"; import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import { FrigateConfig } from "@/types/frigateConfig"; @@ -60,7 +58,12 @@ import type { ConfigSectionData, JsonObject } from "@/types/configForm"; import { sanitizeSectionData } from "@/utils/configUtil"; import type { SectionRendererProps } from "./registry"; -const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js"; +const NOTIFICATION_SERVICE_WORKER = "/notification-worker.js"; +import { + SettingsGroupCard, + SPLIT_ROW_CLASS_NAME, + CONTROL_COLUMN_CLASS_NAME, +} from "@/components/card/SettingsGroupCard"; export default function NotificationsSettingsExtras({ formContext, @@ -431,13 +434,12 @@ export default function NotificationsSettingsExtras({ if (!("Notification" in window) || !window.isSecureContext) { return (
-
-
- - {t("notification.notificationSettings.title")} - -
-
+
+ +
+

{t("notification.notificationSettings.desc")}

+ + + + + {t("notification.notificationUnavailable.title")} + + + + notification.notificationUnavailable.desc + +
+ + {t("readTheDocumentation", { ns: "common" })}{" "} + + +
+
+
- - - - {t("notification.notificationUnavailable.title")} - - - - notification.notificationUnavailable.desc - -
- - {t("readTheDocumentation", { ns: "common" })}{" "} - - -
-
-
-
+
); @@ -484,136 +487,146 @@ export default function NotificationsSettingsExtras({
-
-
- {isAdmin && ( -
-
- ( - - {t("notification.email.title")} - - - - - {t("notification.email.desc")} - - - - )} - /> - - ( - - {allCameras && allCameras?.length > 0 ? ( - <> -
- - {t("notification.cameras.title")} - -
-
- ( - { - setCameraSelectionTouched(true); - if (checked) { - form.setValue("cameras", []); - } - field.onChange(checked); - }} - /> - )} - /> - {allCameras?.map((camera) => { - const currentCameras = Array.isArray( - field.value, - ) - ? field.value - : []; - return ( - { - setCameraSelectionTouched(true); - const newCameras = checked - ? Array.from( - new Set([ - ...currentCameras, - camera.name, - ]), - ) - : currentCameras.filter( - (value) => value !== camera.name, - ); - field.onChange(newCameras); - form.setValue("allEnabled", false); - }} - /> - ); - })} -
- - ) : ( -
- {t("notification.cameras.noCameras")} +
+ {isAdmin && ( + +
+ +
+ ( + +
+ + {t("notification.email.title")} + + + {t("notification.email.desc")} +
- )} - - - {t("notification.cameras.desc")} - -
- )} - /> -
- - )} -
+
+ + + + + {t("notification.email.desc")} + + +
+ + )} + /> -
-
-
- - - {t("notification.deviceSpecific")} - + ( + +
+ + {t("notification.cameras.title")} + + + {t("notification.cameras.desc")} + +
+ +
+ {allCameras.length > 0 ? ( +
+ ( + { + setCameraSelectionTouched(true); + if (checked) { + form.setValue("cameras", []); + } + allEnabledField.onChange(checked); + }} + /> + )} + /> + {allCameras.map((camera) => { + const currentCameras = Array.isArray( + field.value, + ) + ? field.value + : []; + return ( + { + setCameraSelectionTouched(true); + const newCameras = checked + ? Array.from( + new Set([ + ...currentCameras, + camera.name, + ]), + ) + : currentCameras.filter( + (value) => value !== camera.name, + ); + field.onChange(newCameras); + form.setValue("allEnabled", false); + }} + /> + ); + })} +
+ ) : ( +
+ {t("notification.cameras.noCameras")} +
+ )} + + {t("notification.cameras.desc")} + + +
+
+ )} + /> +
+ +
+ + )} + +
+ +
{isAdmin && registration != null && registration.active && ( )}
-
- {isAdmin && notificationCameras.length > 0 && ( -
-
- - - {t("notification.globalSettings.title")} - -
-
-

{t("notification.globalSettings.desc")}

-
-
+ -
-
-
- {notificationCameras.map((item) => ( - - ))} -
+ {isAdmin && notificationCameras.length > 0 && ( + +
+
+

{t("notification.globalSettings.desc")}

+
+
+
+ {notificationCameras.map((item) => ( + + ))}
-
+ )}
diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index 97aa3bc5f..03b52acc9 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -533,7 +533,7 @@ function Logs() {
-
+
diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 95629ef90..2070afbca 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -477,8 +477,6 @@ const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"]; const LARGE_BOTTOM_MARGIN_PAGES = [ "masksAndZones", "motionTuner", - "notifications", - "frigateplus", "maintenance", ]; diff --git a/web/src/views/settings/FrigatePlusSettingsView.tsx b/web/src/views/settings/FrigatePlusSettingsView.tsx index ba578bdc3..64d73c324 100644 --- a/web/src/views/settings/FrigatePlusSettingsView.tsx +++ b/web/src/views/settings/FrigatePlusSettingsView.tsx @@ -1,8 +1,6 @@ import Heading from "@/components/ui/heading"; -import { Label } from "@/components/ui/label"; import { useCallback, useContext, useEffect, useState } from "react"; import { Toaster } from "@/components/ui/sonner"; -import { Separator } from "../../components/ui/separator"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { toast } from "sonner"; import useSWR from "swr"; @@ -24,6 +22,10 @@ import { } from "@/components/ui/select"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; +import { + SettingsGroupCard, + SplitCardRow, +} from "@/components/card/SettingsGroupCard"; type FrigatePlusModel = { id: string; @@ -208,78 +210,73 @@ export default function FrigatePlusSettingsView({ } return ( - <> -
- -
- - {t("frigatePlus.title")} - +
+ +
+
+
+ + {t("frigatePlus.title")} + - - - - {t("frigatePlus.apiKey.title")} - - -
-
-
- {config?.plus?.enabled ? ( - - ) : ( - - )} - -
-
-

{t("frigatePlus.apiKey.desc")}

- {!config?.model.plus && ( - <> -
- - {t("frigatePlus.apiKey.plusLink")} - - +
+ + +

{t("frigatePlus.apiKey.desc")}

+ {!config?.model.plus && ( +
+ + {t("frigatePlus.apiKey.plusLink")} + + +
+ )} + + } + content={ +
+ {config?.plus?.enabled ? ( + + ) : ( + + )} + + {config?.plus?.enabled + ? t("frigatePlus.apiKey.validated") + : t("frigatePlus.apiKey.notValidated")} +
- - )} -
-
+ } + /> + - {config?.model.plus && ( - <> - -
- - {t("frigatePlus.modelInfo.title")} - -
- {!config?.model?.plus && ( -

- {t("frigatePlus.modelInfo.loading")} -

- )} - {config?.model?.plus === null && ( -

- {t("frigatePlus.modelInfo.error")} -

- )} - {config?.model?.plus && ( -
-
- + {config?.model.plus && ( + + {!config?.model?.plus && ( +

+ {t("frigatePlus.modelInfo.loading")} +

+ )} + {config?.model?.plus === null && ( +

+ {t("frigatePlus.modelInfo.error")} +

+ )} + {config?.model?.plus && ( +
+ {config.model.plus.baseModel} ( {config.model.plus.isBaseModel @@ -291,21 +288,21 @@ export default function FrigatePlusSettingsView({ )} )

-
-
- + } + /> + {new Date( config.model.plus.trainDate, ).toLocaleString()}

-
-
- + } + /> + {config.model.plus.name} ( {config.model.plus.width + @@ -313,226 +310,231 @@ export default function FrigatePlusSettingsView({ config.model.plus.height} )

-
-
- + } + /> + {config.model.plus.supportedDetectors.join(", ")}

-
-
-
-
- {t("frigatePlus.modelInfo.availableModels")} -
-
-

+ } + /> +

+ )} + + )} + + {config?.model.plus && ( + + + frigatePlus.modelInfo.modelSelect + + } + content={ + + } + /> + + )} + + + +

+ + frigatePlus.snapshotConfig.desc + +

+
+ + {t("readTheDocumentation", { ns: "common" })} + + +
+ + } + content={ +
+
+ + + + + + + + + + {Object.entries(config.cameras).map( + ([name, camera]) => ( + + + + + + ), + )} + +
+ {t("frigatePlus.snapshotConfig.table.camera")} + + {t( + "frigatePlus.snapshotConfig.table.snapshots", + )} + - frigatePlus.modelInfo.modelSelect + frigatePlus.snapshotConfig.table.cleanCopySnapshots -

+
+ + + {camera.snapshots.enabled ? ( + + ) : ( + + )} + + {camera.snapshots?.enabled && + camera.snapshots?.clean_copy ? ( + + ) : ( + + )} +
+
+ {needCleanSnapshots() && ( +
+
+ +
+ + frigatePlus.snapshotConfig.cleanCopyWarning +
-
-
- )} -
-
- - )} - - - -
- - {t("frigatePlus.snapshotConfig.title")} - -
-
-

- - frigatePlus.snapshotConfig.desc - -

-
- - {t("readTheDocumentation", { ns: "common" })} - - -
-
- {config && ( -
- - - - - - - - - - {Object.entries(config.cameras).map( - ([name, camera]) => ( - - - - - - ), - )} - -
- {t("frigatePlus.snapshotConfig.table.camera")} - - {t("frigatePlus.snapshotConfig.table.snapshots")} - - - frigatePlus.snapshotConfig.table.cleanCopySnapshots - -
- - - {camera.snapshots.enabled ? ( - - ) : ( - - )} - - {camera.snapshots?.enabled && - camera.snapshots?.clean_copy ? ( - - ) : ( - - )} -
-
- )} - {needCleanSnapshots() && ( -
-
- -
- - frigatePlus.snapshotConfig.cleanCopyWarning - -
+ )}
-
- )} -
+ } + /> +
+
+
- - -
+
+
+
- +
); } diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx deleted file mode 100644 index 77da16386..000000000 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ /dev/null @@ -1,785 +0,0 @@ -import ActivityIndicator from "@/components/indicators/activity-indicator"; -import { Button } from "@/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import Heading from "@/components/ui/heading"; -import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; -import { Toaster } from "@/components/ui/sonner"; -import { StatusBarMessagesContext } from "@/context/statusbar-provider"; -import { FrigateConfig } from "@/types/frigateConfig"; -import { zodResolver } from "@hookform/resolvers/zod"; -import axios from "axios"; - -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; -import { useForm } from "react-hook-form"; - -import { LuCheck, LuExternalLink, LuX } from "react-icons/lu"; -import { CiCircleAlert } from "react-icons/ci"; -import { Link } from "react-router-dom"; -import { toast } from "sonner"; -import useSWR from "swr"; -import { z } from "zod"; -import { - useNotifications, - useNotificationSuspend, - useNotificationTest, -} from "@/api/ws"; -import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem, -} from "@/components/ui/select"; -import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; -import FilterSwitch from "@/components/filter/FilterSwitch"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Trans, useTranslation } from "react-i18next"; -import { useDateLocale } from "@/hooks/use-date-locale"; -import { useDocDomain } from "@/hooks/use-doc-domain"; -import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; -import { useIsAdmin } from "@/hooks/use-is-admin"; -import { cn } from "@/lib/utils"; - -const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js"; - -type NotificationSettingsValueType = { - allEnabled: boolean; - email?: string; - cameras: string[]; -}; - -type NotificationsSettingsViewProps = { - setUnsavedChanges: React.Dispatch>; -}; -export default function NotificationView({ - setUnsavedChanges, -}: NotificationsSettingsViewProps) { - const { t } = useTranslation(["views/settings"]); - const { getLocaleDocUrl } = useDocDomain(); - - // roles - - const isAdmin = useIsAdmin(); - - const { data: config, mutate: updateConfig } = useSWR( - "config", - { - revalidateOnFocus: false, - }, - ); - - const allCameras = useMemo(() => { - if (!config) { - return []; - } - - return Object.values(config.cameras).sort( - (aConf, bConf) => aConf.ui.order - bConf.ui.order, - ); - }, [config]); - - const notificationCameras = useMemo(() => { - if (!config) { - return []; - } - - return Object.values(config.cameras) - .filter( - (conf) => - conf.enabled_in_config && - conf.notifications && - conf.notifications.enabled_in_config, - ) - .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); - }, [config]); - - const { send: sendTestNotification } = useNotificationTest(); - - // status bar - - const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; - const [changedValue, setChangedValue] = useState(false); - - useEffect(() => { - if (changedValue) { - addMessage( - "notification_settings", - t("notification.unsavedChanges"), - undefined, - `notification_settings`, - ); - } else { - removeMessage("notification_settings", `notification_settings`); - } - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [changedValue]); - - // notification state - - const [registration, setRegistration] = - useState(); - - useEffect(() => { - if (!("Notification" in window) || !window.isSecureContext) { - return; - } - navigator.serviceWorker - .getRegistration(NOTIFICATION_SERVICE_WORKER) - .then((worker) => { - if (worker) { - setRegistration(worker); - } else { - setRegistration(null); - } - }) - .catch(() => { - setRegistration(null); - }); - }, []); - - // form - - const [isLoading, setIsLoading] = useState(false); - const formSchema = z.object({ - allEnabled: z.boolean(), - email: z.string(), - cameras: z.array(z.string()), - }); - - const form = useForm>({ - resolver: zodResolver(formSchema), - mode: "onChange", - defaultValues: { - allEnabled: config?.notifications.enabled, - email: config?.notifications.email, - cameras: config?.notifications.enabled - ? [] - : notificationCameras.map((c) => c.name), - }, - }); - - const watchAllEnabled = form.watch("allEnabled"); - const watchCameras = form.watch("cameras"); - - const anyCameraNotificationsEnabled = useMemo( - () => - config && - Object.values(config.cameras).some( - (c) => - c.enabled_in_config && - c.notifications && - c.notifications.enabled_in_config, - ), - [config], - ); - - const shouldFetchPubKey = Boolean( - config && - (config.notifications?.enabled || anyCameraNotificationsEnabled) && - (watchAllEnabled || - (Array.isArray(watchCameras) && watchCameras.length > 0)), - ); - - const { data: publicKey } = useSWR( - shouldFetchPubKey ? "notifications/pubkey" : null, - { revalidateOnFocus: false }, - ); - - const subscribeToNotifications = useCallback( - (registration: ServiceWorkerRegistration) => { - if (registration) { - addMessage( - "notification_settings", - t("notification.unsavedRegistrations"), - undefined, - "registration", - ); - - registration.pushManager - .subscribe({ - userVisibleOnly: true, - applicationServerKey: publicKey, - }) - .then((pushSubscription) => { - axios - .post("notifications/register", { - sub: pushSubscription, - }) - .catch(() => { - toast.error(t("notification.toast.error.registerFailed"), { - position: "top-center", - }); - pushSubscription.unsubscribe(); - registration.unregister(); - setRegistration(null); - }); - toast.success(t("notification.toast.success.registered"), { - position: "top-center", - }); - }); - } - }, - [publicKey, addMessage, t], - ); - - useEffect(() => { - if (watchCameras.length > 0) { - form.setValue("allEnabled", false); - } - }, [watchCameras, allCameras, form]); - - const onCancel = useCallback(() => { - if (!config) { - return; - } - - setUnsavedChanges(false); - setChangedValue(false); - form.reset({ - allEnabled: config.notifications.enabled, - email: config.notifications.email || "", - cameras: config?.notifications.enabled - ? [] - : notificationCameras.map((c) => c.name), - }); - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [config, removeMessage, setUnsavedChanges]); - - const saveToConfig = useCallback( - async ( - { allEnabled, email, cameras }: NotificationSettingsValueType, // values submitted via the form - ) => { - const allCameraNames = allCameras.map((cam) => cam.name); - - const enabledCameraQueries = cameras - .map((cam) => `&cameras.${cam}.notifications.enabled=True`) - .join(""); - - const disabledCameraQueries = allCameraNames - .filter((cam) => !cameras.includes(cam)) - .map( - (cam) => - `&cameras.${cam}.notifications.enabled=${allEnabled ? "True" : "False"}`, - ) - .join(""); - - const allCameraQueries = enabledCameraQueries + disabledCameraQueries; - - axios - .put( - `config/set?notifications.enabled=${allEnabled ? "True" : "False"}¬ifications.email=${email}${allCameraQueries}`, - { - requires_restart: 0, - }, - ) - .then((res) => { - if (res.status === 200) { - toast.success(t("notification.toast.success.settingSaved"), { - position: "top-center", - }); - updateConfig(); - } else { - toast.error( - t("toast.save.error.title", { - errorMessage: res.statusText, - ns: "common", - }), - { - position: "top-center", - }, - ); - } - }) - .catch((error) => { - const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || - "Unknown error"; - toast.error( - t("toast.save.error.title", { errorMessage, ns: "common" }), - { - position: "top-center", - }, - ); - }) - .finally(() => { - setIsLoading(false); - }); - }, - [updateConfig, setIsLoading, allCameras, t], - ); - - function onSubmit(values: z.infer) { - setIsLoading(true); - saveToConfig(values as NotificationSettingsValueType); - } - - useEffect(() => { - document.title = t("documentTitle.notifications"); - }, [t]); - - if (!("Notification" in window) || !window.isSecureContext) { - return ( -
-
-
- - {t("notification.notificationSettings.title")} - -
-
-

{t("notification.notificationSettings.desc")}

-
- - {t("readTheDocumentation", { ns: "common" })} - - -
-
-
- - - - {t("notification.notificationUnavailable.title")} - - - - notification.notificationUnavailable.desc - -
- - {t("readTheDocumentation", { ns: "common" })}{" "} - - -
-
-
-
-
-
- ); - } - - return ( - <> -
- -
-
-
- - {t("notification.notificationSettings.title")} - - -
-
-

{t("notification.notificationSettings.desc")}

-
- - {t("readTheDocumentation", { ns: "common" })}{" "} - - -
-
-
- - {isAdmin && ( -
- - ( - - {t("notification.email.title")} - - - - - {t("notification.email.desc")} - - - - )} - /> - - ( - - {allCameras && allCameras?.length > 0 ? ( - <> -
- - {t("notification.cameras.title")} - -
-
- ( - { - setChangedValue(true); - if (checked) { - form.setValue("cameras", []); - } - field.onChange(checked); - }} - /> - )} - /> - {allCameras?.map((camera) => ( - { - setChangedValue(true); - let newCameras; - if (checked) { - newCameras = [ - ...field.value, - camera.name, - ]; - } else { - newCameras = field.value?.filter( - (value) => value !== camera.name, - ); - } - field.onChange(newCameras); - form.setValue("allEnabled", false); - }} - /> - ))} -
- - ) : ( -
- {t("notification.cameras.noCameras")} -
- )} - - - - {t("notification.cameras.desc")} - -
- )} - /> - -
- - -
- - - )} -
- -
-
-
- - - {t("notification.deviceSpecific")} - - - {isAdmin && registration != null && registration.active && ( - - )} -
-
- {isAdmin && notificationCameras.length > 0 && ( -
-
- - - {t("notification.globalSettings.title")} - -
-
-

{t("notification.globalSettings.desc")}

-
-
- -
-
-
- {notificationCameras.map((item) => ( - - ))} -
-
-
-
-
- )} -
-
-
-
- - ); -} - -type CameraNotificationSwitchProps = { - config?: FrigateConfig; - camera: string; -}; - -export function CameraNotificationSwitch({ - config, - camera, -}: CameraNotificationSwitchProps) { - const { t } = useTranslation(["views/settings"]); - const { payload: notificationState, send: sendNotification } = - useNotifications(camera); - const { payload: notificationSuspendUntil, send: sendNotificationSuspend } = - useNotificationSuspend(camera); - const [isSuspended, setIsSuspended] = useState(false); - - useEffect(() => { - if (notificationSuspendUntil) { - setIsSuspended( - notificationSuspendUntil !== "0" || notificationState === "OFF", - ); - } - }, [notificationSuspendUntil, notificationState]); - - const handleSuspend = (duration: string) => { - setIsSuspended(true); - if (duration == "off") { - sendNotification("OFF"); - } else { - sendNotificationSuspend(parseInt(duration)); - } - }; - - const handleCancelSuspension = () => { - sendNotification("ON"); - sendNotificationSuspend(0); - }; - - const locale = useDateLocale(); - - const formatSuspendedUntil = (timestamp: string) => { - // Some languages require a change in word order - if (timestamp === "0") return t("time.untilForRestart", { ns: "common" }); - - const time = formatUnixTimestampToDateTime(parseInt(timestamp), { - time_style: "medium", - date_style: "medium", - timezone: config?.ui.timezone, - date_format: - config?.ui.time_format == "24hour" - ? t("time.formattedTimestampMonthDayHourMinute.24hour", { - ns: "common", - }) - : t("time.formattedTimestampMonthDayHourMinute.12hour", { - ns: "common", - }), - locale: locale, - }); - return t("time.untilForTime", { ns: "common", time }); - }; - - return ( -
-
-
- {!isSuspended ? ( - - ) : ( - - )} -
- - - {!isSuspended ? ( -
- {t("notification.active")} -
- ) : ( -
- {t("notification.suspended", { - time: formatSuspendedUntil(notificationSuspendUntil), - })} -
- )} -
-
-
- - {!isSuspended ? ( - - ) : ( - - )} -
- ); -} diff --git a/web/src/views/settings/UiSettingsView.tsx b/web/src/views/settings/UiSettingsView.tsx index 2bff797f4..a1dd73e86 100644 --- a/web/src/views/settings/UiSettingsView.tsx +++ b/web/src/views/settings/UiSettingsView.tsx @@ -19,29 +19,14 @@ import { } from "../../components/ui/select"; import { useTranslation } from "react-i18next"; import { AuthContext } from "@/context/auth-context"; +import { + SettingsGroupCard, + SPLIT_ROW_CLASS_NAME, + DESCRIPTION_CLASS_NAME, + CONTROL_COLUMN_CLASS_NAME, +} from "@/components/card/SettingsGroupCard"; -const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16]; const WEEK_STARTS_ON = ["Sunday", "Monday"]; -const SPLIT_ROW_CLASS_NAME = - "space-y-2 md:grid md:grid-cols-[minmax(14rem,22rem)_minmax(0,1fr)] md:items-start md:gap-x-6 md:space-y-0"; -const DESCRIPTION_CLASS_NAME = "text-sm text-muted-foreground"; -const CONTROL_COLUMN_CLASS_NAME = "w-full md:max-w-2xl"; - -type SettingsGroupCardProps = { - title: string; - children: ReactNode; -}; - -function SettingsGroupCard({ title, children }: SettingsGroupCardProps) { - return ( -
-
- {title} -
- {children} -
- ); -} type SwitchSettingRowProps = { id: string; @@ -123,6 +108,8 @@ export default function UiSettingsView() { const { auth } = useContext(AuthContext); const username = auth?.user?.username; + const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16]; + const clearStoredLayouts = useCallback(() => { if (!config) { return [];