mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-25 17:48:38 +03:00
use rjsf for notifications view
This commit is contained in:
parent
0db6e87413
commit
093a25e9ac
@ -9,6 +9,15 @@ const notifications: SectionConfigOverrides = {
|
|||||||
hiddenFields: ["enabled_in_config"],
|
hiddenFields: ["enabled_in_config"],
|
||||||
advancedFields: [],
|
advancedFields: [],
|
||||||
},
|
},
|
||||||
|
global: {
|
||||||
|
uiSchema: {
|
||||||
|
"ui:before": { render: "NotificationsSettingsExtras" },
|
||||||
|
enabled: { "ui:widget": "hidden" },
|
||||||
|
email: { "ui:widget": "hidden" },
|
||||||
|
cooldown: { "ui:widget": "hidden" },
|
||||||
|
enabled_in_config: { "ui:widget": "hidden" },
|
||||||
|
},
|
||||||
|
},
|
||||||
camera: {
|
camera: {
|
||||||
hiddenFields: ["enabled_in_config", "email"],
|
hiddenFields: ["enabled_in_config", "email"],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -0,0 +1,837 @@
|
|||||||
|
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,
|
||||||
|
useRef,
|
||||||
|
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";
|
||||||
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
|
import isEqual from "lodash/isEqual";
|
||||||
|
import set from "lodash/set";
|
||||||
|
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
|
||||||
|
import { sanitizeSectionData } from "@/utils/configUtil";
|
||||||
|
import type { SectionRendererProps } from "./registry";
|
||||||
|
|
||||||
|
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
|
||||||
|
|
||||||
|
export default function NotificationsSettingsExtras({
|
||||||
|
formContext,
|
||||||
|
}: SectionRendererProps) {
|
||||||
|
const { t } = useTranslation([
|
||||||
|
"views/settings",
|
||||||
|
"common",
|
||||||
|
"components/filter",
|
||||||
|
]);
|
||||||
|
const { getLocaleDocUrl } = useDocDomain();
|
||||||
|
|
||||||
|
// roles
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
|
// status bar
|
||||||
|
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
||||||
|
|
||||||
|
// config
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allCameras = useMemo(() => {
|
||||||
|
if (!config) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(config.cameras)
|
||||||
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order)
|
||||||
|
.filter((c) => c.enabled_in_config);
|
||||||
|
}, [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();
|
||||||
|
|
||||||
|
// notification state
|
||||||
|
const [registration, setRegistration] =
|
||||||
|
useState<ServiceWorkerRegistration | null>();
|
||||||
|
const [cameraSelectionTouched, setCameraSelectionTouched] = useState(false);
|
||||||
|
|
||||||
|
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 formSchema = z.object({
|
||||||
|
allEnabled: z.boolean(),
|
||||||
|
email: z.string(),
|
||||||
|
cameras: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingDataBySection = useMemo(
|
||||||
|
() => formContext?.pendingDataBySection ?? {},
|
||||||
|
[formContext?.pendingDataBySection],
|
||||||
|
);
|
||||||
|
const pendingCameraOverrides = useMemo(() => {
|
||||||
|
const overrides: Record<string, boolean> = {};
|
||||||
|
Object.entries(pendingDataBySection).forEach(([key, data]) => {
|
||||||
|
if (!key.endsWith("::notifications")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cameraName = key.slice(0, key.indexOf("::"));
|
||||||
|
const enabled = (data as JsonObject | undefined)?.enabled;
|
||||||
|
if (typeof enabled === "boolean") {
|
||||||
|
overrides[cameraName] = enabled;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return overrides;
|
||||||
|
}, [pendingDataBySection]);
|
||||||
|
|
||||||
|
const defaultValues = useMemo(() => {
|
||||||
|
const formData = formContext?.formData as JsonObject | undefined;
|
||||||
|
const enabledValue =
|
||||||
|
typeof formData?.enabled === "boolean"
|
||||||
|
? formData.enabled
|
||||||
|
: (config?.notifications.enabled ?? false);
|
||||||
|
const emailValue =
|
||||||
|
typeof formData?.email === "string"
|
||||||
|
? formData.email
|
||||||
|
: (config?.notifications.email ?? "");
|
||||||
|
const baseEnabledSet = new Set(
|
||||||
|
notificationCameras.map((camera) => camera.name),
|
||||||
|
);
|
||||||
|
const selectedCameras = enabledValue
|
||||||
|
? []
|
||||||
|
: allCameras
|
||||||
|
.filter((camera) => {
|
||||||
|
const pendingEnabled = pendingCameraOverrides[camera.name];
|
||||||
|
if (typeof pendingEnabled === "boolean") {
|
||||||
|
return pendingEnabled;
|
||||||
|
}
|
||||||
|
return baseEnabledSet.has(camera.name);
|
||||||
|
})
|
||||||
|
.map((camera) => camera.name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allEnabled: Boolean(enabledValue),
|
||||||
|
email: typeof emailValue === "string" ? emailValue : "",
|
||||||
|
cameras: selectedCameras,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
allCameras,
|
||||||
|
config?.notifications.email,
|
||||||
|
config?.notifications.enabled,
|
||||||
|
formContext?.formData,
|
||||||
|
notificationCameras,
|
||||||
|
pendingCameraOverrides,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchAllEnabled = form.watch("allEnabled");
|
||||||
|
const watchCameras = form.watch("cameras");
|
||||||
|
const watchEmail = form.watch("email");
|
||||||
|
const pendingCameraOverridesRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const resetFormState = useCallback(
|
||||||
|
(values: z.infer<typeof formSchema>) => {
|
||||||
|
form.reset(values);
|
||||||
|
setCameraSelectionTouched(false);
|
||||||
|
pendingCameraOverridesRef.current.clear();
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
|
// pending changes sync (Undo All / Save All)
|
||||||
|
const hasPendingNotifications = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.keys(pendingDataBySection).some(
|
||||||
|
(key) => key === "notifications" || key.endsWith("::notifications"),
|
||||||
|
),
|
||||||
|
[pendingDataBySection],
|
||||||
|
);
|
||||||
|
const hasPendingNotificationsRef = useRef(hasPendingNotifications);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config || form.formState.isDirty || hasPendingNotifications) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetFormState(defaultValues);
|
||||||
|
}, [
|
||||||
|
config,
|
||||||
|
defaultValues,
|
||||||
|
form.formState.isDirty,
|
||||||
|
hasPendingNotifications,
|
||||||
|
resetFormState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hadPending = hasPendingNotificationsRef.current;
|
||||||
|
hasPendingNotificationsRef.current = hasPendingNotifications;
|
||||||
|
|
||||||
|
if (hadPending && !hasPendingNotifications) {
|
||||||
|
resetFormState(defaultValues);
|
||||||
|
}
|
||||||
|
}, [hasPendingNotifications, defaultValues, resetFormState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!formContext?.onFormDataChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const baseData =
|
||||||
|
(formContext.formData as JsonObject | undefined) ??
|
||||||
|
(config?.notifications as JsonObject | undefined);
|
||||||
|
if (!baseData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextData = cloneDeep(baseData);
|
||||||
|
const normalizedEmail = watchEmail?.trim() ? watchEmail : null;
|
||||||
|
set(nextData, "enabled", Boolean(watchAllEnabled));
|
||||||
|
set(nextData, "email", normalizedEmail);
|
||||||
|
formContext.onFormDataChange(nextData as ConfigSectionData);
|
||||||
|
}, [config, formContext, watchAllEnabled, watchEmail]);
|
||||||
|
|
||||||
|
// camera selection overrides
|
||||||
|
const baselineCameraSelection = useMemo(() => {
|
||||||
|
if (!config) {
|
||||||
|
return [] as string[];
|
||||||
|
}
|
||||||
|
return config.notifications.enabled
|
||||||
|
? []
|
||||||
|
: notificationCameras.map((camera) => camera.name);
|
||||||
|
}, [config, notificationCameras]);
|
||||||
|
|
||||||
|
const cameraSelectionDirty = useMemo(() => {
|
||||||
|
const current = Array.isArray(watchCameras) ? watchCameras : [];
|
||||||
|
return !isEqual([...current].sort(), [...baselineCameraSelection].sort());
|
||||||
|
}, [watchCameras, baselineCameraSelection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
formContext?.setExtraHasChanges?.(cameraSelectionDirty);
|
||||||
|
}, [cameraSelectionDirty, formContext]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPendingDataChange = formContext?.onPendingDataChange;
|
||||||
|
if (!onPendingDataChange || !config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cameraSelectionTouched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cameraSelectionDirty) {
|
||||||
|
pendingCameraOverridesRef.current.forEach((cameraName) => {
|
||||||
|
onPendingDataChange("notifications", cameraName, null);
|
||||||
|
});
|
||||||
|
pendingCameraOverridesRef.current.clear();
|
||||||
|
setCameraSelectionTouched(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCameras = Array.isArray(watchCameras) ? watchCameras : [];
|
||||||
|
|
||||||
|
allCameras.forEach((camera) => {
|
||||||
|
const desiredEnabled = watchAllEnabled
|
||||||
|
? true
|
||||||
|
: selectedCameras.includes(camera.name);
|
||||||
|
const currentNotifications = config.cameras[camera.name]?.notifications;
|
||||||
|
const currentEnabled = currentNotifications?.enabled;
|
||||||
|
|
||||||
|
if (desiredEnabled === currentEnabled) {
|
||||||
|
if (pendingCameraOverridesRef.current.has(camera.name)) {
|
||||||
|
onPendingDataChange("notifications", camera.name, null);
|
||||||
|
pendingCameraOverridesRef.current.delete(camera.name);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentNotifications) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextNotifications = cloneDeep(
|
||||||
|
currentNotifications as JsonObject,
|
||||||
|
) as JsonObject;
|
||||||
|
set(nextNotifications, "enabled", desiredEnabled);
|
||||||
|
const sanitizedNotifications = sanitizeSectionData(
|
||||||
|
nextNotifications as ConfigSectionData,
|
||||||
|
["enabled_in_config", "email"],
|
||||||
|
);
|
||||||
|
onPendingDataChange("notifications", camera.name, sanitizedNotifications);
|
||||||
|
pendingCameraOverridesRef.current.add(camera.name);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
allCameras,
|
||||||
|
cameraSelectionDirty,
|
||||||
|
cameraSelectionTouched,
|
||||||
|
config,
|
||||||
|
formContext,
|
||||||
|
watchAllEnabled,
|
||||||
|
watchCameras,
|
||||||
|
]);
|
||||||
|
|
||||||
|
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(
|
||||||
|
(workerRegistration: ServiceWorkerRegistration) => {
|
||||||
|
if (!workerRegistration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessage(
|
||||||
|
"notification_settings",
|
||||||
|
t("notification.unsavedRegistrations"),
|
||||||
|
undefined,
|
||||||
|
"registration",
|
||||||
|
);
|
||||||
|
|
||||||
|
workerRegistration.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();
|
||||||
|
workerRegistration.unregister();
|
||||||
|
setRegistration(null);
|
||||||
|
});
|
||||||
|
toast.success(t("notification.toast.success.registered"), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[addMessage, publicKey, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchCameras.length > 0) {
|
||||||
|
form.setValue("allEnabled", false);
|
||||||
|
}
|
||||||
|
}, [watchCameras, allCameras, form]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = t("documentTitle.notifications");
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
if (formContext?.level && formContext.level !== "global") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return <ActivityIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!("Notification" in window) || !window.isSecureContext) {
|
||||||
|
return (
|
||||||
|
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||||
|
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="col-span-1">
|
||||||
|
<Heading as="h4" className="mb-2">
|
||||||
|
{t("notification.notificationSettings.title")}
|
||||||
|
</Heading>
|
||||||
|
<div className="max-w-6xl">
|
||||||
|
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||||
|
<p>{t("notification.notificationSettings.desc")}</p>
|
||||||
|
<div className="flex items-center text-primary">
|
||||||
|
<Link
|
||||||
|
to={getLocaleDocUrl("configuration/notifications")}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline"
|
||||||
|
>
|
||||||
|
{t("readTheDocumentation", { ns: "common" })}
|
||||||
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<CiCircleAlert className="size-5" />
|
||||||
|
<AlertTitle>
|
||||||
|
{t("notification.notificationUnavailable.title")}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
notification.notificationUnavailable.desc
|
||||||
|
</Trans>
|
||||||
|
<div className="mt-3 flex items-center">
|
||||||
|
<Link
|
||||||
|
to={getLocaleDocUrl("configuration/authentication")}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline"
|
||||||
|
>
|
||||||
|
{t("readTheDocumentation", { ns: "common" })}{" "}
|
||||||
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col md:flex-row">
|
||||||
|
<Toaster position="top-center" closeButton={true} />
|
||||||
|
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
isAdmin && "grid w-full grid-cols-1 gap-4 md:grid-cols-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="col-span-1">
|
||||||
|
{isAdmin && (
|
||||||
|
<Form {...form}>
|
||||||
|
<div className="mt-2 space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("notification.email.title")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
|
||||||
|
placeholder={t("notification.email.placeholder")}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("notification.email.desc")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="cameras"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
{allCameras && allCameras?.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-2">
|
||||||
|
<FormLabel className="flex flex-row items-center text-base">
|
||||||
|
{t("notification.cameras.title")}
|
||||||
|
</FormLabel>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="allEnabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FilterSwitch
|
||||||
|
label={t("cameras.all.title", {
|
||||||
|
ns: "components/filter",
|
||||||
|
})}
|
||||||
|
isChecked={field.value}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setCameraSelectionTouched(true);
|
||||||
|
if (checked) {
|
||||||
|
form.setValue("cameras", []);
|
||||||
|
}
|
||||||
|
field.onChange(checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{allCameras?.map((camera) => {
|
||||||
|
const currentCameras = Array.isArray(
|
||||||
|
field.value,
|
||||||
|
)
|
||||||
|
? field.value
|
||||||
|
: [];
|
||||||
|
return (
|
||||||
|
<FilterSwitch
|
||||||
|
key={camera.name}
|
||||||
|
label={camera.name}
|
||||||
|
type="camera"
|
||||||
|
isChecked={currentCameras.includes(
|
||||||
|
camera.name,
|
||||||
|
)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="font-normal text-destructive">
|
||||||
|
{t("notification.cameras.noCameras")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
{t("notification.cameras.desc")}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-1">
|
||||||
|
<div className="mt-4 gap-2 space-y-6">
|
||||||
|
<div
|
||||||
|
className={cn(isAdmin && "flex flex-col gap-2 md:max-w-[50%]")}
|
||||||
|
>
|
||||||
|
<Separator
|
||||||
|
className={cn(
|
||||||
|
"my-2 flex bg-secondary",
|
||||||
|
isAdmin && "md:hidden",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Heading as="h4" className={cn(isAdmin ? "my-2" : "my-4")}>
|
||||||
|
{t("notification.deviceSpecific")}
|
||||||
|
</Heading>
|
||||||
|
<Button
|
||||||
|
aria-label={t("notification.registerDevice")}
|
||||||
|
disabled={!shouldFetchPubKey || publicKey == undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (registration == null) {
|
||||||
|
Notification.requestPermission().then((permission) => {
|
||||||
|
if (permission === "granted") {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register(NOTIFICATION_SERVICE_WORKER)
|
||||||
|
.then((workerRegistration) => {
|
||||||
|
setRegistration(workerRegistration);
|
||||||
|
|
||||||
|
if (workerRegistration.active) {
|
||||||
|
subscribeToNotifications(workerRegistration);
|
||||||
|
} else {
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
subscribeToNotifications(
|
||||||
|
workerRegistration,
|
||||||
|
),
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
registration.pushManager
|
||||||
|
.getSubscription()
|
||||||
|
.then((pushSubscription) => {
|
||||||
|
pushSubscription?.unsubscribe();
|
||||||
|
registration.unregister();
|
||||||
|
setRegistration(null);
|
||||||
|
removeMessage(
|
||||||
|
"notification_settings",
|
||||||
|
"registration",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{registration != null
|
||||||
|
? t("notification.unregisterDevice")
|
||||||
|
: t("notification.registerDevice")}
|
||||||
|
</Button>
|
||||||
|
{isAdmin && registration != null && registration.active && (
|
||||||
|
<Button
|
||||||
|
aria-label={t("notification.sendTestNotification")}
|
||||||
|
onClick={() => sendTestNotification("notification_test")}
|
||||||
|
>
|
||||||
|
{t("notification.sendTestNotification")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isAdmin && notificationCameras.length > 0 && (
|
||||||
|
<div className="mt-4 gap-2 space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
|
<Heading as="h4" className="my-2">
|
||||||
|
{t("notification.globalSettings.title")}
|
||||||
|
</Heading>
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<div className="mb-5 mt-2 flex flex-col gap-2 text-sm text-primary-variant">
|
||||||
|
<p>{t("notification.globalSettings.desc")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex max-w-2xl flex-col gap-2.5">
|
||||||
|
<div className="rounded-lg bg-secondary p-5">
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{notificationCameras.map((item) => (
|
||||||
|
<CameraNotificationSwitch
|
||||||
|
key={item.name}
|
||||||
|
config={config}
|
||||||
|
camera={item.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<boolean>(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) => {
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-col items-start justify-start">
|
||||||
|
<div className="flex flex-row items-center justify-start gap-3">
|
||||||
|
{!isSuspended ? (
|
||||||
|
<LuCheck className="size-6 text-success" />
|
||||||
|
) : (
|
||||||
|
<LuX className="size-6 text-danger" />
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<CameraNameLabel
|
||||||
|
className="text-md cursor-pointer text-primary smart-capitalize"
|
||||||
|
htmlFor="camera"
|
||||||
|
camera={camera}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isSuspended ? (
|
||||||
|
<div className="flex flex-row items-center gap-2 text-sm text-success">
|
||||||
|
{t("notification.active")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-row items-center gap-2 text-sm text-danger">
|
||||||
|
{t("notification.suspended", {
|
||||||
|
time: formatSuspendedUntil(notificationSuspendUntil),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isSuspended ? (
|
||||||
|
<Select onValueChange={handleSuspend}>
|
||||||
|
<SelectTrigger className="w-auto">
|
||||||
|
<SelectValue placeholder={t("notification.suspendTime.suspend")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">
|
||||||
|
{t("notification.suspendTime.5minutes")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="10">
|
||||||
|
{t("notification.suspendTime.10minutes")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="30">
|
||||||
|
{t("notification.suspendTime.30minutes")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="60">
|
||||||
|
{t("notification.suspendTime.1hour")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="840">
|
||||||
|
{t("notification.suspendTime.12hours")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="1440">
|
||||||
|
{t("notification.suspendTime.24hours")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="off">
|
||||||
|
{t("notification.suspendTime.untilRestart")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancelSuspension}
|
||||||
|
>
|
||||||
|
{t("notification.cancelSuspension")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import type { ComponentType } from "react";
|
|||||||
import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
|
import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
|
||||||
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
|
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
|
||||||
import ProxyRoleMap from "./ProxyRoleMap";
|
import ProxyRoleMap from "./ProxyRoleMap";
|
||||||
|
import NotificationsSettingsExtras from "./NotificationsSettingsExtras";
|
||||||
import type { ConfigFormContext } from "@/types/configForm";
|
import type { ConfigFormContext } from "@/types/configForm";
|
||||||
|
|
||||||
// Props that will be injected into all section renderers
|
// Props that will be injected into all section renderers
|
||||||
@ -48,6 +49,9 @@ export const sectionRenderers: SectionRenderers = {
|
|||||||
proxy: {
|
proxy: {
|
||||||
ProxyRoleMap,
|
ProxyRoleMap,
|
||||||
},
|
},
|
||||||
|
notifications: {
|
||||||
|
NotificationsSettingsExtras,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default sectionRenderers;
|
export default sectionRenderers;
|
||||||
|
|||||||
@ -193,6 +193,11 @@ export function ConfigSection({
|
|||||||
pendingDataBySection !== undefined
|
pendingDataBySection !== undefined
|
||||||
? (pendingDataBySection[pendingDataKey] as ConfigSectionData | null)
|
? (pendingDataBySection[pendingDataKey] as ConfigSectionData | null)
|
||||||
: localPendingData;
|
: localPendingData;
|
||||||
|
const pendingDataRef = useRef<ConfigSectionData | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pendingDataRef.current = pendingData;
|
||||||
|
}, [pendingData]);
|
||||||
|
|
||||||
const setPendingData = useCallback(
|
const setPendingData = useCallback(
|
||||||
(data: ConfigSectionData | null) => {
|
(data: ConfigSectionData | null) => {
|
||||||
@ -205,6 +210,7 @@ export function ConfigSection({
|
|||||||
[onPendingDataChange, sectionPath, cameraName],
|
[onPendingDataChange, sectionPath, cameraName],
|
||||||
);
|
);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [extraHasChanges, setExtraHasChanges] = useState(false);
|
||||||
const [formKey, setFormKey] = useState(0);
|
const [formKey, setFormKey] = useState(0);
|
||||||
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
|
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
|
||||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||||
@ -356,9 +362,11 @@ export function ConfigSection({
|
|||||||
|
|
||||||
// Track if there are unsaved changes
|
// Track if there are unsaved changes
|
||||||
const hasChanges = useMemo(() => {
|
const hasChanges = useMemo(() => {
|
||||||
if (!pendingData) return false;
|
const pendingChanged = pendingData
|
||||||
return !isEqual(formData, pendingData);
|
? !isEqual(formData, pendingData)
|
||||||
}, [formData, pendingData]);
|
: false;
|
||||||
|
return pendingChanged || extraHasChanges;
|
||||||
|
}, [formData, pendingData, extraHasChanges]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onStatusChange?.({ hasChanges, isOverridden });
|
onStatusChange?.({ hasChanges, isOverridden });
|
||||||
@ -462,6 +470,7 @@ export function ConfigSection({
|
|||||||
setPendingData(null);
|
setPendingData(null);
|
||||||
setPendingOverrides(undefined);
|
setPendingOverrides(undefined);
|
||||||
setDirtyOverrides(undefined);
|
setDirtyOverrides(undefined);
|
||||||
|
setExtraHasChanges(false);
|
||||||
setFormKey((prev) => prev + 1);
|
setFormKey((prev) => prev + 1);
|
||||||
}, [setPendingData, setPendingOverrides, setDirtyOverrides]);
|
}, [setPendingData, setPendingOverrides, setDirtyOverrides]);
|
||||||
|
|
||||||
@ -649,6 +658,7 @@ export function ConfigSection({
|
|||||||
);
|
);
|
||||||
|
|
||||||
setPendingData(null);
|
setPendingData(null);
|
||||||
|
setExtraHasChanges(false);
|
||||||
refreshConfig();
|
refreshConfig();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(
|
toast.error(
|
||||||
@ -714,10 +724,11 @@ export function ConfigSection({
|
|||||||
selectedCamera={cameraName}
|
selectedCamera={cameraName}
|
||||||
setUnsavedChanges={(hasChanges: boolean) => {
|
setUnsavedChanges={(hasChanges: boolean) => {
|
||||||
// Translate setUnsavedChanges to pending data state
|
// Translate setUnsavedChanges to pending data state
|
||||||
if (hasChanges && !pendingData) {
|
const currentPending = pendingDataRef.current;
|
||||||
|
if (hasChanges && !currentPending) {
|
||||||
// Component signaled changes but we don't have pending data yet
|
// Component signaled changes but we don't have pending data yet
|
||||||
// This can happen when the component manages its own state
|
// This can happen when the component manages its own state
|
||||||
} else if (!hasChanges && pendingData) {
|
} else if (!hasChanges && currentPending) {
|
||||||
// Component signaled no changes, clear pending
|
// Component signaled no changes, clear pending
|
||||||
setPendingData(null);
|
setPendingData(null);
|
||||||
}
|
}
|
||||||
@ -726,13 +737,7 @@ export function ConfigSection({
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}, [
|
}, [sectionConfig?.renderers, sectionPath, cameraName, setPendingData]);
|
||||||
sectionConfig?.renderers,
|
|
||||||
sectionPath,
|
|
||||||
cameraName,
|
|
||||||
pendingData,
|
|
||||||
setPendingData,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!modifiedSchema) {
|
if (!modifiedSchema) {
|
||||||
return null;
|
return null;
|
||||||
@ -787,9 +792,13 @@ export function ConfigSection({
|
|||||||
globalValue,
|
globalValue,
|
||||||
cameraValue,
|
cameraValue,
|
||||||
hasChanges,
|
hasChanges,
|
||||||
|
extraHasChanges,
|
||||||
|
setExtraHasChanges,
|
||||||
overrides: uiOverrides as JsonValue | undefined,
|
overrides: uiOverrides as JsonValue | undefined,
|
||||||
formData: currentFormData as ConfigSectionData,
|
formData: currentFormData as ConfigSectionData,
|
||||||
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
|
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
|
||||||
|
pendingDataBySection,
|
||||||
|
onPendingDataChange,
|
||||||
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
||||||
// For widgets that need access to full camera config (e.g., zone names)
|
// For widgets that need access to full camera config (e.g., zone names)
|
||||||
fullCameraConfig:
|
fullCameraConfig:
|
||||||
@ -811,7 +820,6 @@ export function ConfigSection({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Save button */}
|
|
||||||
<div className="sticky bottom-0 z-50 w-full border-t border-secondary bg-background pb-5 pt-0">
|
<div className="sticky bottom-0 z-50 w-full border-t border-secondary bg-background pb-5 pt-0">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -874,7 +882,7 @@ export function ConfigSection({
|
|||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>{t("button.save", { ns: "common", defaultValue: "Save" })}</>
|
t("button.save", { ns: "common", defaultValue: "Save" })
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -38,7 +38,6 @@ import MotionTunerView from "@/views/settings/MotionTunerView";
|
|||||||
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
||||||
import UsersView from "@/views/settings/UsersView";
|
import UsersView from "@/views/settings/UsersView";
|
||||||
import RolesView from "@/views/settings/RolesView";
|
import RolesView from "@/views/settings/RolesView";
|
||||||
import NotificationView from "@/views/settings/NotificationsSettingsView";
|
|
||||||
import UiSettingsView from "@/views/settings/UiSettingsView";
|
import UiSettingsView from "@/views/settings/UiSettingsView";
|
||||||
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
|
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
|
||||||
import MaintenanceSettingsView from "@/views/settings/MaintenanceSettingsView";
|
import MaintenanceSettingsView from "@/views/settings/MaintenanceSettingsView";
|
||||||
@ -238,6 +237,7 @@ const SystemDetectorHardwareSettingsPage = createSectionPage(
|
|||||||
"global",
|
"global",
|
||||||
);
|
);
|
||||||
const SystemDetectionModelSettingsPage = createSectionPage("model", "global");
|
const SystemDetectionModelSettingsPage = createSectionPage("model", "global");
|
||||||
|
const NotificationsSettingsPage = createSectionPage("notifications", "global");
|
||||||
|
|
||||||
const SystemMqttSettingsPage = createSectionPage("mqtt", "global");
|
const SystemMqttSettingsPage = createSectionPage("mqtt", "global");
|
||||||
const IntegrationSemanticSearchSettingsPage = createSectionPage(
|
const IntegrationSemanticSearchSettingsPage = createSectionPage(
|
||||||
@ -432,7 +432,7 @@ const settingsGroups = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "notifications",
|
label: "notifications",
|
||||||
items: [{ key: "notifications", component: NotificationView }],
|
items: [{ key: "notifications", component: NotificationsSettingsPage }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "frigateplus",
|
label: "frigateplus",
|
||||||
@ -513,6 +513,7 @@ const GLOBAL_SECTION_MAPPING: Record<string, SettingsType> = {
|
|||||||
audio: "globalAudioEvents",
|
audio: "globalAudioEvents",
|
||||||
live: "globalLivePlayback",
|
live: "globalLivePlayback",
|
||||||
timestamp_style: "globalTimestampStyle",
|
timestamp_style: "globalTimestampStyle",
|
||||||
|
notifications: "notifications",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ENRICHMENTS_SECTION_MAPPING: Record<string, SettingsType> = {
|
const ENRICHMENTS_SECTION_MAPPING: Record<string, SettingsType> = {
|
||||||
|
|||||||
@ -20,7 +20,15 @@ export type ConfigFormContext = {
|
|||||||
cameraValue?: JsonValue;
|
cameraValue?: JsonValue;
|
||||||
overrides?: JsonValue;
|
overrides?: JsonValue;
|
||||||
hasChanges?: boolean;
|
hasChanges?: boolean;
|
||||||
|
extraHasChanges?: boolean;
|
||||||
|
setExtraHasChanges?: (hasChanges: boolean) => void;
|
||||||
formData?: JsonObject;
|
formData?: JsonObject;
|
||||||
|
pendingDataBySection?: Record<string, unknown>;
|
||||||
|
onPendingDataChange?: (
|
||||||
|
sectionKey: string,
|
||||||
|
cameraName: string | undefined,
|
||||||
|
data: ConfigSectionData | null,
|
||||||
|
) => void;
|
||||||
baselineFormData?: JsonObject;
|
baselineFormData?: JsonObject;
|
||||||
hiddenFields?: string[];
|
hiddenFields?: string[];
|
||||||
onFormDataChange?: (data: ConfigSectionData) => void;
|
onFormDataChange?: (data: ConfigSectionData) => void;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user