mirror of
https://github.com/alexhopeoconnor/frigate.git
synced 2026-10-19 01:41:31 +10:00
* Fix api filter hook cameras, labels, sub labels, plates, and zones could be parsed as numeric values rather than strings, which would break the explore filter. This change adds an optional param to the useApiFilterArgs hook to always parse keys as string[] * fix notifications register button from being incorrectly disabled
751 lines
26 KiB
TypeScript
751 lines
26 KiB
TypeScript
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 { Label } from "@/components/ui/label";
|
|
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";
|
|
|
|
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
|
|
|
|
type NotificationSettingsValueType = {
|
|
allEnabled: boolean;
|
|
email?: string;
|
|
cameras: string[];
|
|
};
|
|
|
|
type NotificationsSettingsViewProps = {
|
|
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
|
};
|
|
export default function NotificationView({
|
|
setUnsavedChanges,
|
|
}: NotificationsSettingsViewProps) {
|
|
const { t } = useTranslation(["views/settings"]);
|
|
const { getLocaleDocUrl } = useDocDomain();
|
|
|
|
const { data: config, mutate: updateConfig } = 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,
|
|
);
|
|
}, [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 key handling
|
|
|
|
const { data: publicKey } = useSWR(
|
|
config?.notifications?.enabled ? "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],
|
|
);
|
|
|
|
// notification state
|
|
|
|
const [registration, setRegistration] =
|
|
useState<ServiceWorkerRegistration | null>();
|
|
|
|
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<z.infer<typeof formSchema>>({
|
|
resolver: zodResolver(formSchema),
|
|
mode: "onChange",
|
|
defaultValues: {
|
|
allEnabled: config?.notifications.enabled,
|
|
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(() => {
|
|
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<typeof formSchema>) {
|
|
setIsLoading(true);
|
|
saveToConfig(values as NotificationSettingsValueType);
|
|
}
|
|
|
|
useEffect(() => {
|
|
document.title = t("documentTitle.notifications");
|
|
}, [t]);
|
|
|
|
if (!("Notification" in window) || !window.isSecureContext) {
|
|
return (
|
|
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
|
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div className="col-span-1">
|
|
<Heading as="h3" className="my-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("notification.notificationSettings.documentation")}{" "}
|
|
<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("notification.notificationUnavailable.documentation")}{" "}
|
|
<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-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
|
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div className="col-span-1">
|
|
<Heading as="h3" className="my-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("notification.notificationSettings.documentation")}{" "}
|
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Form {...form}>
|
|
<form
|
|
onSubmit={form.handleSubmit(onSubmit)}
|
|
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) => {
|
|
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">
|
|
{t("notification.cameras.noCameras")}
|
|
</div>
|
|
)}
|
|
|
|
<FormMessage />
|
|
<FormDescription>
|
|
{t("notification.cameras.desc")}
|
|
</FormDescription>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[50%]">
|
|
<Button
|
|
className="flex flex-1"
|
|
aria-label={t("button.cancel", { ns: "common" })}
|
|
onClick={onCancel}
|
|
type="button"
|
|
>
|
|
{t("button.cancel", { ns: "common" })}
|
|
</Button>
|
|
<Button
|
|
variant="select"
|
|
disabled={isLoading}
|
|
className="flex flex-1"
|
|
aria-label={t("button.save", { ns: "common" })}
|
|
type="submit"
|
|
>
|
|
{isLoading ? (
|
|
<div className="flex flex-row items-center gap-2">
|
|
<ActivityIndicator />
|
|
<span>{t("button.saving", { ns: "common" })}</span>
|
|
</div>
|
|
) : (
|
|
t("button.save", { ns: "common" })
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
</div>
|
|
|
|
<div className="col-span-1">
|
|
<div className="mt-4 gap-2 space-y-6">
|
|
<div className="flex flex-col gap-2 md:max-w-[50%]">
|
|
<Separator className="my-2 flex bg-secondary md:hidden" />
|
|
<Heading as="h4" className="my-2">
|
|
{t("notification.deviceSpecific")}
|
|
</Heading>
|
|
<Button
|
|
aria-label={t("notification.registerDevice")}
|
|
disabled={
|
|
(!config?.notifications.enabled &&
|
|
notificationCameras.length === 0 &&
|
|
!form.watch("allEnabled") &&
|
|
form.watch("cameras").length === 0) ||
|
|
publicKey == undefined
|
|
}
|
|
onClick={() => {
|
|
if (registration == null) {
|
|
Notification.requestPermission().then((permission) => {
|
|
if (permission === "granted") {
|
|
navigator.serviceWorker
|
|
.register(NOTIFICATION_SERVICE_WORKER)
|
|
.then((registration) => {
|
|
setRegistration(registration);
|
|
|
|
if (registration.active) {
|
|
subscribeToNotifications(registration);
|
|
} else {
|
|
setTimeout(
|
|
() =>
|
|
subscribeToNotifications(registration),
|
|
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>
|
|
{registration != null && registration.active && (
|
|
<Button
|
|
aria-label={t("notification.sendTestNotification")}
|
|
onClick={() => sendTestNotification("notification_test")}
|
|
>
|
|
{t("notification.sendTestNotification")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{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
|
|
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) => {
|
|
// 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 (
|
|
<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">
|
|
<Label
|
|
className="text-md cursor-pointer text-primary smart-capitalize"
|
|
htmlFor="camera"
|
|
>
|
|
{camera.replaceAll("_", " ")}
|
|
</Label>
|
|
|
|
{!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>
|
|
);
|
|
}
|