mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-17 16:44:29 +03:00
frontend tweaks
This commit is contained in:
parent
1540434e4c
commit
a2a5fbc672
@ -440,7 +440,7 @@ export function useNotifications(camera: string): {
|
|||||||
|
|
||||||
export function useNotificationSuspend(camera: string): {
|
export function useNotificationSuspend(camera: string): {
|
||||||
payload: string;
|
payload: string;
|
||||||
send: (payload: string, retain?: boolean) => void;
|
send: (payload: number, retain?: boolean) => void;
|
||||||
} {
|
} {
|
||||||
const {
|
const {
|
||||||
value: { payload },
|
value: { payload },
|
||||||
|
|||||||
@ -14,14 +14,13 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { LuExternalLink } from "react-icons/lu";
|
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -39,12 +38,14 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
|
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||||
|
|
||||||
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
|
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
|
||||||
|
|
||||||
type NotificationSettingsValueType = {
|
type NotificationSettingsValueType = {
|
||||||
enabled: boolean;
|
allEnabled: boolean;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
cameras: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type NotificationsSettingsViewProps = {
|
type NotificationsSettingsViewProps = {
|
||||||
@ -60,13 +61,28 @@ export default function NotificationView({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const cameras = useMemo(() => {
|
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) {
|
if (!config) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.values(config.cameras)
|
return Object.values(config.cameras)
|
||||||
.filter((conf) => conf.enabled && conf.notifications.enabled_in_config)
|
.filter(
|
||||||
|
(conf) =>
|
||||||
|
conf.enabled &&
|
||||||
|
conf.notifications &&
|
||||||
|
conf.notifications.enabled_in_config,
|
||||||
|
)
|
||||||
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
@ -75,6 +91,22 @@ export default function NotificationView({
|
|||||||
// status bar
|
// status bar
|
||||||
|
|
||||||
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
||||||
|
const [changedValue, setChangedValue] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (changedValue) {
|
||||||
|
addMessage(
|
||||||
|
"notification_settings",
|
||||||
|
`Unsaved notification settings`,
|
||||||
|
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 key handling
|
// notification key handling
|
||||||
|
|
||||||
@ -147,28 +179,44 @@ export default function NotificationView({
|
|||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
enabled: z.boolean(),
|
allEnabled: z.boolean(),
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
|
cameras: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
enabled: config?.notifications.enabled,
|
allEnabled: config?.notifications.enabled,
|
||||||
email: config?.notifications.email,
|
email: config?.notifications.email,
|
||||||
|
cameras: config?.notifications.enabled
|
||||||
|
? []
|
||||||
|
: notificationCameras.map((c) => c.name),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const watchCameras = form.watch("cameras");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchCameras.length > 0) {
|
||||||
|
form.setValue("allEnabled", false);
|
||||||
|
}
|
||||||
|
}, [watchCameras, allCameras, form]);
|
||||||
|
|
||||||
const onCancel = useCallback(() => {
|
const onCancel = useCallback(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUnsavedChanges(false);
|
setUnsavedChanges(false);
|
||||||
|
setChangedValue(false);
|
||||||
form.reset({
|
form.reset({
|
||||||
enabled: config.notifications.enabled,
|
allEnabled: config.notifications.enabled,
|
||||||
email: config.notifications.email || "",
|
email: config.notifications.email || "",
|
||||||
|
cameras: config?.notifications.enabled
|
||||||
|
? []
|
||||||
|
: notificationCameras.map((c) => c.name),
|
||||||
});
|
});
|
||||||
// we know that these deps are correct
|
// we know that these deps are correct
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -176,11 +224,27 @@ export default function NotificationView({
|
|||||||
|
|
||||||
const saveToConfig = useCallback(
|
const saveToConfig = useCallback(
|
||||||
async (
|
async (
|
||||||
{ enabled, email }: NotificationSettingsValueType, // values submitted via the form
|
{ 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
|
axios
|
||||||
.put(
|
.put(
|
||||||
`config/set?notifications.enabled=${enabled}¬ifications.email=${email}`,
|
`config/set?notifications.enabled=${allEnabled ? "True" : "False"}¬ifications.email=${email}${allCameraQueries}`,
|
||||||
{
|
{
|
||||||
requires_restart: 0,
|
requires_restart: 0,
|
||||||
},
|
},
|
||||||
@ -207,7 +271,7 @@ export default function NotificationView({
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[updateConfig, setIsLoading],
|
[updateConfig, setIsLoading, allCameras],
|
||||||
);
|
);
|
||||||
|
|
||||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
@ -247,30 +311,8 @@ export default function NotificationView({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="mt-2 space-y-6"
|
className="mt-2 max-w-2xl space-y-6"
|
||||||
>
|
>
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex flex-row items-center justify-start gap-2">
|
|
||||||
<Label className="cursor-pointer" htmlFor="auto-live">
|
|
||||||
Notifications
|
|
||||||
</Label>
|
|
||||||
<Switch
|
|
||||||
id="auto-live"
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
return field.onChange(checked);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
@ -292,7 +334,74 @@ export default function NotificationView({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
|
||||||
|
<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">
|
||||||
|
Cameras
|
||||||
|
</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="All Cameras"
|
||||||
|
isChecked={field.value}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setChangedValue(true);
|
||||||
|
if (checked) {
|
||||||
|
form.setValue("cameras", []);
|
||||||
|
}
|
||||||
|
field.onChange(checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{allCameras?.map((camera) => (
|
||||||
|
<FilterSwitch
|
||||||
|
key={camera.name}
|
||||||
|
label={camera.name.replaceAll("_", " ")}
|
||||||
|
isChecked={field.value?.includes(camera.name)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="font-normal text-destructive">
|
||||||
|
No cameras available.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
Select the cameras to enable notifications for.
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-row items-center gap-2 pt-2">
|
||||||
<Button
|
<Button
|
||||||
className="flex flex-1"
|
className="flex flex-1"
|
||||||
aria-label="Cancel"
|
aria-label="Cancel"
|
||||||
@ -324,6 +433,9 @@ export default function NotificationView({
|
|||||||
<div className="mt-4 gap-2 space-y-6">
|
<div className="mt-4 gap-2 space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Separator className="my-2 flex bg-secondary" />
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
|
<Heading as="h4" className="my-2">
|
||||||
|
Register and Test
|
||||||
|
</Heading>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Register or unregister notifications for this device"
|
aria-label="Register or unregister notifications for this device"
|
||||||
disabled={
|
disabled={
|
||||||
@ -373,26 +485,33 @@ export default function NotificationView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{cameras && (
|
{notificationCameras.length > 0 && (
|
||||||
<div className="mt-4 gap-2 space-y-6">
|
<div className="mt-4 gap-2 space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Separator className="my-2 flex bg-secondary" />
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
<Heading as="h4" className="my-2">
|
<Heading as="h4" className="my-2">
|
||||||
Cameras
|
Suspend Notifications
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="max-w-6xl">
|
<div className="max-w-xl">
|
||||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
<div className="mb-5 mt-2 flex flex-col gap-2 text-sm text-primary-variant">
|
||||||
<p>Enable / disable notifications for specific cameras.</p>
|
<p>
|
||||||
|
Temporarily suspend notifications for specific cameras on
|
||||||
|
all registered devices.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex max-w-72 flex-col gap-2.5">
|
<div className="flex max-w-2xl flex-col gap-2.5">
|
||||||
{cameras.map((item) => (
|
<div className="rounded-lg bg-secondary p-5">
|
||||||
<CameraNotificationSwitch
|
<div className="grid gap-6">
|
||||||
config={config}
|
{notificationCameras.map((item) => (
|
||||||
camera={item.name}
|
<CameraNotificationSwitch
|
||||||
/>
|
config={config}
|
||||||
))}
|
camera={item.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -420,21 +539,24 @@ export function CameraNotificationSwitch({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (notificationSuspendUntil) {
|
if (notificationSuspendUntil) {
|
||||||
setIsSuspended(notificationSuspendUntil != "0");
|
setIsSuspended(
|
||||||
|
notificationSuspendUntil !== "0" || notificationState === "OFF",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [notificationSuspendUntil]);
|
}, [notificationSuspendUntil, notificationState]);
|
||||||
|
|
||||||
const handleSuspend = (duration: string) => {
|
const handleSuspend = (duration: string) => {
|
||||||
|
setIsSuspended(true);
|
||||||
if (duration == "off") {
|
if (duration == "off") {
|
||||||
sendNotification("OFF");
|
sendNotification("OFF");
|
||||||
setIsSuspended(true);
|
|
||||||
} else {
|
} else {
|
||||||
sendNotificationSuspend(duration);
|
sendNotificationSuspend(parseInt(duration));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelSuspension = () => {
|
const handleCancelSuspension = () => {
|
||||||
sendNotificationSuspend("0");
|
sendNotification("ON");
|
||||||
|
sendNotificationSuspend(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatSuspendedUntil = (timestamp: string) => {
|
const formatSuspendedUntil = (timestamp: string) => {
|
||||||
@ -448,48 +570,60 @@ export function CameraNotificationSwitch({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const isOn = notificationState === "ON" || isSuspended;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col items-start justify-start">
|
||||||
<Label
|
<div className="flex flex-row items-center justify-start gap-3">
|
||||||
className="mx-2 w-full cursor-pointer capitalize text-primary"
|
{!isSuspended ? (
|
||||||
htmlFor="camera"
|
<LuCheck className="size-6 text-success" />
|
||||||
>
|
) : (
|
||||||
{camera.replaceAll("_", " ")}
|
<LuX className="size-6 text-danger" />
|
||||||
</Label>
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Label
|
||||||
|
className="text-md cursor-pointer capitalize text-primary"
|
||||||
|
htmlFor="camera"
|
||||||
|
>
|
||||||
|
{camera.replaceAll("_", " ")}
|
||||||
|
</Label>
|
||||||
|
|
||||||
{!isSuspended && isOn && (
|
{!isSuspended ? (
|
||||||
<Select onValueChange={handleSuspend}>
|
<div className="flex flex-row items-center gap-2 text-sm text-success">
|
||||||
<SelectTrigger className="w-auto">
|
Notifications Active
|
||||||
<SelectValue placeholder="Suspend" />
|
</div>
|
||||||
</SelectTrigger>
|
) : (
|
||||||
<SelectContent>
|
<div className="flex flex-row items-center gap-2 text-sm text-danger">
|
||||||
<SelectItem value="30">Suspend for 30 minutes</SelectItem>
|
Notifications suspended until{" "}
|
||||||
<SelectItem value="60">Suspend for 1 hour</SelectItem>
|
{formatSuspendedUntil(notificationSuspendUntil)}
|
||||||
<SelectItem value="180">Suspend for 3 hours</SelectItem>
|
</div>
|
||||||
<SelectItem value="360">Suspend for 6 hours</SelectItem>
|
)}
|
||||||
<SelectItem value="840">Suspend for 12 hours</SelectItem>
|
</div>
|
||||||
<SelectItem value="1440">Suspend for 24 hours</SelectItem>
|
|
||||||
<SelectItem value="off">Suspend until restart</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
{isSuspended && (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCancelSuspension}
|
|
||||||
>
|
|
||||||
Cancel Suspension
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isSuspended && notificationSuspendUntil && (
|
|
||||||
<div className="mt-1 text-sm text-muted-foreground">
|
|
||||||
Suspended until {formatSuspendedUntil(notificationSuspendUntil)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isSuspended ? (
|
||||||
|
<Select onValueChange={handleSuspend}>
|
||||||
|
<SelectTrigger className="w-auto">
|
||||||
|
<SelectValue placeholder="Suspend" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">Suspend for 5 minutes</SelectItem>
|
||||||
|
<SelectItem value="10">Suspend for 10 minutes</SelectItem>
|
||||||
|
<SelectItem value="30">Suspend for 30 minutes</SelectItem>
|
||||||
|
<SelectItem value="60">Suspend for 1 hour</SelectItem>
|
||||||
|
<SelectItem value="840">Suspend for 12 hours</SelectItem>
|
||||||
|
<SelectItem value="1440">Suspend for 24 hours</SelectItem>
|
||||||
|
<SelectItem value="off">Suspend until restart</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancelSuspension}
|
||||||
|
>
|
||||||
|
Cancel Suspension
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user