refactor frigate+ view and make tweaks to sections

This commit is contained in:
Josh Hawkins 2026-02-13 09:14:40 -06:00
parent 23c6e5231f
commit fdf7e22130
17 changed files with 594 additions and 1302 deletions

View File

@ -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).",
)

View File

@ -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="",

View File

@ -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",

View File

@ -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",

View File

@ -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."
}
}
}

View File

@ -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",

View File

@ -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 (
<div className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4">
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
{title}
</div>
{children}
</div>
);
}
type SplitCardRowProps = {
label: ReactNode;
description?: ReactNode;
content: ReactNode;
};
export function SplitCardRow({
label,
description,
content,
}: SplitCardRowProps) {
return (
<div className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<Label className="text-sm font-medium">{label}</Label>
{description && (
<div className={`hidden md:block ${DESCRIPTION_CLASS_NAME}`}>
{description}
</div>
)}
</div>
<div className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}>
{content}
{description && (
<div className={`md:hidden ${DESCRIPTION_CLASS_NAME}`}>
{description}
</div>
)}
</div>
</div>
);
}

View File

@ -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"],

View File

@ -27,7 +27,7 @@ const objects: SectionConfigOverrides = {
"filters.mask",
"filters.raw_mask",
],
advancedFields: ["filters"],
advancedFields: ["genai"],
uiSchema: {
"filters.*.min_area": {
"ui:options": {

View File

@ -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",

View File

@ -18,6 +18,7 @@ export function validateFfmpegInputRoles(
}
const roleCounts = new Map<string, number>();
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;
}

View File

@ -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 (
<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">
<div className="w-full max-w-5xl">
<SettingsGroupCard
title={t("notification.notificationSettings.title")}
>
<div className="space-y-4">
<div className="flex flex-col gap-2 text-sm text-primary-variant">
<p>{t("notification.notificationSettings.desc")}</p>
<div className="flex items-center text-primary">
<Link
@ -451,30 +453,31 @@ export default function NotificationsSettingsExtras({
</Link>
</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>
<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>
</SettingsGroupCard>
</div>
</div>
);
@ -484,136 +487,146 @@ export default function NotificationsSettingsExtras({
<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 className={cn("w-full max-w-5xl space-y-6")}>
{isAdmin && (
<SettingsGroupCard
title={t("notification.notificationSettings.title")}
>
<div className="space-y-6">
<Form {...form}>
<div className="space-y-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<FormLabel htmlFor="notification-email">
{t("notification.email.title")}
</FormLabel>
<FormDescription className="hidden md:block">
{t("notification.email.desc")}
</FormDescription>
</div>
)}
<FormMessage />
<FormDescription>
{t("notification.cameras.desc")}
</FormDescription>
</FormItem>
)}
/>
</div>
</Form>
)}
</div>
<div
className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}
>
<FormControl>
<Input
id="notification-email"
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 className="md:hidden">
{t("notification.email.desc")}
</FormDescription>
<FormMessage />
</div>
</FormItem>
)}
/>
<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>
<FormField
control={form.control}
name="cameras"
render={({ field }) => (
<FormItem className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<FormLabel className="text-base">
{t("notification.cameras.title")}
</FormLabel>
<FormDescription className="hidden md:block">
{t("notification.cameras.desc")}
</FormDescription>
</div>
<div
className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}
>
{allCameras.length > 0 ? (
<div className="w-full space-y-2 rounded-lg bg-secondary p-4">
<FormField
control={form.control}
name="allEnabled"
render={({ field: allEnabledField }) => (
<FilterSwitch
label={t("cameras.all.title", {
ns: "components/filter",
})}
isChecked={allEnabledField.value}
onCheckedChange={(checked) => {
setCameraSelectionTouched(true);
if (checked) {
form.setValue("cameras", []);
}
allEnabledField.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>
)}
<FormDescription className="md:hidden">
{t("notification.cameras.desc")}
</FormDescription>
<FormMessage />
</div>
</FormItem>
)}
/>
</div>
</Form>
</div>
</SettingsGroupCard>
)}
<div className="space-y-6">
<SettingsGroupCard title={t("notification.deviceSpecific")}>
<div className={cn("space-y-2", isAdmin && "md:max-w-[50%]")}>
<Button
aria-label={t("notification.registerDevice")}
className="w-full md:w-auto"
disabled={!shouldFetchPubKey || publicKey == undefined}
onClick={() => {
if (registration == null) {
@ -659,6 +672,7 @@ export default function NotificationsSettingsExtras({
</Button>
{isAdmin && registration != null && registration.active && (
<Button
className="w-full md:w-auto"
aria-label={t("notification.sendTestNotification")}
onClick={() => sendTestNotification("notification_test")}
>
@ -666,35 +680,27 @@ export default function NotificationsSettingsExtras({
</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>
</SettingsGroupCard>
<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>
{isAdmin && notificationCameras.length > 0 && (
<SettingsGroupCard title={t("notification.globalSettings.title")}>
<div className="space-y-4">
<div className="flex max-w-xl flex-col gap-2 text-sm text-primary-variant">
<p>{t("notification.globalSettings.desc")}</p>
</div>
<div className="w-full rounded-lg bg-secondary p-5 md:max-w-2xl">
<div className="grid gap-6">
{notificationCameras.map((item) => (
<CameraNotificationSwitch
key={item.name}
config={config}
camera={item.name}
/>
))}
</div>
</div>
</div>
</div>
</SettingsGroupCard>
)}
</div>
</div>

View File

@ -533,7 +533,7 @@ function Logs() {
</div>
</div>
<div className="font-mono relative my-2 flex size-full flex-col overflow-hidden whitespace-pre-wrap rounded-md border border-secondary bg-background_alt text-xs sm:p-1">
<div className="relative my-2 flex size-full flex-col overflow-hidden whitespace-pre-wrap rounded-md border border-secondary bg-background_alt font-mono text-xs sm:p-1">
<div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
<div className="col-span-3 lg:col-span-2">
<div className="flex w-full flex-row items-center">

View File

@ -477,8 +477,6 @@ const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"];
const LARGE_BOTTOM_MARGIN_PAGES = [
"masksAndZones",
"motionTuner",
"notifications",
"frigateplus",
"maintenance",
];

View File

@ -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 (
<>
<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 pb-2 md:order-none">
<Heading as="h4" className="mb-2">
{t("frigatePlus.title")}
</Heading>
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="mt-2 flex h-full w-full flex-col">
<div className="scrollbar-container flex-1 overflow-y-auto">
<div className="w-full max-w-5xl space-y-6">
<Heading as="h4" className="mb-2">
{t("frigatePlus.title")}
</Heading>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
{t("frigatePlus.apiKey.title")}
</Heading>
<div className="mt-2 space-y-6">
<div className="space-y-3">
<div className="flex items-center gap-2">
{config?.plus?.enabled ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
<Label>
{config?.plus?.enabled
? t("frigatePlus.apiKey.validated")
: t("frigatePlus.apiKey.notValidated")}
</Label>
</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>{t("frigatePlus.apiKey.desc")}</p>
{!config?.model.plus && (
<>
<div className="mt-2 flex items-center text-primary-variant">
<Link
to="https://frigate.video/plus"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("frigatePlus.apiKey.plusLink")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
<div className="space-y-6">
<SettingsGroupCard title={t("frigatePlus.cardTitles.api")}>
<SplitCardRow
label={t("frigatePlus.apiKey.title")}
description={
<>
<p>{t("frigatePlus.apiKey.desc")}</p>
{!config?.model.plus && (
<div className="mt-2 flex items-center text-primary-variant">
<Link
to="https://frigate.video/plus"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("frigatePlus.apiKey.plusLink")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
)}
</>
}
content={
<div className="flex items-center gap-2">
{config?.plus?.enabled ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
<span className="text-sm">
{config?.plus?.enabled
? t("frigatePlus.apiKey.validated")
: t("frigatePlus.apiKey.notValidated")}
</span>
</div>
</>
)}
</div>
</div>
}
/>
</SettingsGroupCard>
{config?.model.plus && (
<>
<Separator className="my-2 flex bg-secondary" />
<div className="mt-2 max-w-2xl">
<Heading as="h4" className="my-2">
{t("frigatePlus.modelInfo.title")}
</Heading>
<div className="mt-2 space-y-3">
{!config?.model?.plus && (
<p className="text-muted-foreground">
{t("frigatePlus.modelInfo.loading")}
</p>
)}
{config?.model?.plus === null && (
<p className="text-danger">
{t("frigatePlus.modelInfo.error")}
</p>
)}
{config?.model?.plus && (
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-muted-foreground">
{t("frigatePlus.modelInfo.baseModel")}
</Label>
{config?.model.plus && (
<SettingsGroupCard
title={t("frigatePlus.cardTitles.currentModel")}
>
{!config?.model?.plus && (
<p className="text-muted-foreground">
{t("frigatePlus.modelInfo.loading")}
</p>
)}
{config?.model?.plus === null && (
<p className="text-danger">
{t("frigatePlus.modelInfo.error")}
</p>
)}
{config?.model?.plus && (
<div className="space-y-6">
<SplitCardRow
label={t("frigatePlus.modelInfo.baseModel")}
content={
<p>
{config.model.plus.baseModel} (
{config.model.plus.isBaseModel
@ -291,21 +288,21 @@ export default function FrigatePlusSettingsView({
)}
)
</p>
</div>
<div>
<Label className="text-muted-foreground">
{t("frigatePlus.modelInfo.trainDate")}
</Label>
}
/>
<SplitCardRow
label={t("frigatePlus.modelInfo.trainDate")}
content={
<p>
{new Date(
config.model.plus.trainDate,
).toLocaleString()}
</p>
</div>
<div>
<Label className="text-muted-foreground">
{t("frigatePlus.modelInfo.modelType")}
</Label>
}
/>
<SplitCardRow
label={t("frigatePlus.modelInfo.modelType")}
content={
<p>
{config.model.plus.name} (
{config.model.plus.width +
@ -313,226 +310,231 @@ export default function FrigatePlusSettingsView({
config.model.plus.height}
)
</p>
</div>
<div>
<Label className="text-muted-foreground">
{t("frigatePlus.modelInfo.supportedDetectors")}
</Label>
}
/>
<SplitCardRow
label={t("frigatePlus.modelInfo.supportedDetectors")}
content={
<p>
{config.model.plus.supportedDetectors.join(", ")}
</p>
</div>
<div className="col-span-2">
<div className="space-y-2">
<div className="text-md">
{t("frigatePlus.modelInfo.availableModels")}
</div>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
}
/>
</div>
)}
</SettingsGroupCard>
)}
{config?.model.plus && (
<SettingsGroupCard
title={t("frigatePlus.cardTitles.otherModels")}
>
<SplitCardRow
label={t("frigatePlus.modelInfo.availableModels")}
description={
<Trans ns="views/settings">
frigatePlus.modelInfo.modelSelect
</Trans>
}
content={
<Select
value={frigatePlusSettings.model.id}
onValueChange={(value) =>
handleFrigatePlusConfigChange({
model: { id: value as string },
})
}
>
{frigatePlusSettings.model.id &&
availableModels?.[frigatePlusSettings.model.id] ? (
<SelectTrigger className="w-full">
{new Date(
availableModels[
frigatePlusSettings.model.id
].trainDate,
).toLocaleString() +
" " +
availableModels[frigatePlusSettings.model.id]
.baseModel +
" (" +
(availableModels[frigatePlusSettings.model.id]
.isBaseModel
? t(
"frigatePlus.modelInfo.plusModelType.baseModel",
)
: t(
"frigatePlus.modelInfo.plusModelType.userModel",
)) +
") " +
availableModels[frigatePlusSettings.model.id]
.name +
" (" +
availableModels[frigatePlusSettings.model.id]
.width +
"x" +
availableModels[frigatePlusSettings.model.id]
.height +
")"}
</SelectTrigger>
) : (
<SelectTrigger className="w-full">
{t("frigatePlus.modelInfo.loadingAvailableModels")}
</SelectTrigger>
)}
<SelectContent>
<SelectGroup>
{Object.entries(availableModels || {}).map(
([id, model]) => (
<SelectItem
key={id}
className="cursor-pointer"
value={id}
disabled={
!model.supportedDetectors.includes(
Object.values(config.detectors)[0].type,
)
}
>
{new Date(model.trainDate).toLocaleString()}{" "}
<div>
{model.baseModel} {" ("}
{model.isBaseModel
? t(
"frigatePlus.modelInfo.plusModelType.baseModel",
)
: t(
"frigatePlus.modelInfo.plusModelType.userModel",
)}
{")"}
</div>
<div>
{model.name} (
{model.width + "x" + model.height})
</div>
<div>
{t(
"frigatePlus.modelInfo.supportedDetectors",
)}
: {model.supportedDetectors.join(", ")}
</div>
<div className="text-xs text-muted-foreground">
{id}
</div>
</SelectItem>
),
)}
</SelectGroup>
</SelectContent>
</Select>
}
/>
</SettingsGroupCard>
)}
<SettingsGroupCard
title={t("frigatePlus.cardTitles.configuration")}
>
<SplitCardRow
label={t("frigatePlus.snapshotConfig.title")}
description={
<>
<p>
<Trans ns="views/settings">
frigatePlus.snapshotConfig.desc
</Trans>
</p>
<div className="mt-2 flex items-center text-primary-variant">
<Link
to={getLocaleDocUrl("plus/faq")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</>
}
content={
<div className="space-y-3">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-secondary">
<th className="px-4 py-2 text-left">
{t("frigatePlus.snapshotConfig.table.camera")}
</th>
<th className="px-4 py-2 text-center">
{t(
"frigatePlus.snapshotConfig.table.snapshots",
)}
</th>
<th className="px-4 py-2 text-center">
<Trans ns="views/settings">
frigatePlus.modelInfo.modelSelect
frigatePlus.snapshotConfig.table.cleanCopySnapshots
</Trans>
</p>
</th>
</tr>
</thead>
<tbody>
{Object.entries(config.cameras).map(
([name, camera]) => (
<tr
key={name}
className="border-b border-secondary"
>
<td className="px-4 py-2">
<CameraNameLabel camera={name} />
</td>
<td className="px-4 py-2 text-center">
{camera.snapshots.enabled ? (
<CheckCircle2 className="mx-auto size-5 text-green-500" />
) : (
<XCircle className="mx-auto size-5 text-danger" />
)}
</td>
<td className="px-4 py-2 text-center">
{camera.snapshots?.enabled &&
camera.snapshots?.clean_copy ? (
<CheckCircle2 className="mx-auto size-5 text-green-500" />
) : (
<XCircle className="mx-auto size-5 text-danger" />
)}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
{needCleanSnapshots() && (
<div className="rounded-lg border border-secondary-foreground bg-secondary p-4 text-sm text-danger">
<div className="flex items-center gap-2">
<IoIosWarning className="mr-2 size-5 text-danger" />
<div className="max-w-[85%] text-sm">
<Trans ns="views/settings">
frigatePlus.snapshotConfig.cleanCopyWarning
</Trans>
</div>
</div>
<Select
value={frigatePlusSettings.model.id}
onValueChange={(value) =>
handleFrigatePlusConfigChange({
model: { id: value as string },
})
}
>
{frigatePlusSettings.model.id &&
availableModels?.[frigatePlusSettings.model.id] ? (
<SelectTrigger>
{new Date(
availableModels[
frigatePlusSettings.model.id
].trainDate,
).toLocaleString() +
" " +
availableModels[frigatePlusSettings.model.id]
.baseModel +
" (" +
(availableModels[frigatePlusSettings.model.id]
.isBaseModel
? t(
"frigatePlus.modelInfo.plusModelType.baseModel",
)
: t(
"frigatePlus.modelInfo.plusModelType.userModel",
)) +
") " +
availableModels[frigatePlusSettings.model.id]
.name +
" (" +
availableModels[frigatePlusSettings.model.id]
.width +
"x" +
availableModels[frigatePlusSettings.model.id]
.height +
")"}
</SelectTrigger>
) : (
<SelectTrigger>
{t(
"frigatePlus.modelInfo.loadingAvailableModels",
)}
</SelectTrigger>
)}
<SelectContent>
<SelectGroup>
{Object.entries(availableModels || {}).map(
([id, model]) => (
<SelectItem
key={id}
className="cursor-pointer"
value={id}
disabled={
!model.supportedDetectors.includes(
Object.values(config.detectors)[0]
.type,
)
}
>
{new Date(
model.trainDate,
).toLocaleString()}{" "}
<div>
{model.baseModel} {" ("}
{model.isBaseModel
? t(
"frigatePlus.modelInfo.plusModelType.baseModel",
)
: t(
"frigatePlus.modelInfo.plusModelType.userModel",
)}
{")"}
</div>
<div>
{model.name} (
{model.width + "x" + model.height})
</div>
<div>
{t(
"frigatePlus.modelInfo.supportedDetectors",
)}
: {model.supportedDetectors.join(", ")}
</div>
<div className="text-xs text-muted-foreground">
{id}
</div>
</SelectItem>
),
)}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</>
)}
<Separator className="my-2 flex bg-secondary" />
<div className="mt-2 max-w-5xl">
<Heading as="h4" className="my-2">
{t("frigatePlus.snapshotConfig.title")}
</Heading>
<div className="mt-2 space-y-3">
<div className="my-2 text-sm text-muted-foreground">
<p>
<Trans ns="views/settings">
frigatePlus.snapshotConfig.desc
</Trans>
</p>
<div className="mt-2 flex items-center text-primary-variant">
<Link
to={getLocaleDocUrl("plus/faq")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
{config && (
<div className="overflow-x-auto">
<table className="max-w-2xl text-sm">
<thead>
<tr className="border-b border-secondary">
<th className="px-4 py-2 text-left">
{t("frigatePlus.snapshotConfig.table.camera")}
</th>
<th className="px-4 py-2 text-center">
{t("frigatePlus.snapshotConfig.table.snapshots")}
</th>
<th className="px-4 py-2 text-center">
<Trans ns="views/settings">
frigatePlus.snapshotConfig.table.cleanCopySnapshots
</Trans>
</th>
</tr>
</thead>
<tbody>
{Object.entries(config.cameras).map(
([name, camera]) => (
<tr
key={name}
className="border-b border-secondary"
>
<td className="px-4 py-2">
<CameraNameLabel camera={name} />
</td>
<td className="px-4 py-2 text-center">
{camera.snapshots.enabled ? (
<CheckCircle2 className="mx-auto size-5 text-green-500" />
) : (
<XCircle className="mx-auto size-5 text-danger" />
)}
</td>
<td className="px-4 py-2 text-center">
{camera.snapshots?.enabled &&
camera.snapshots?.clean_copy ? (
<CheckCircle2 className="mx-auto size-5 text-green-500" />
) : (
<XCircle className="mx-auto size-5 text-danger" />
)}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)}
{needCleanSnapshots() && (
<div className="mt-2 max-w-xl rounded-lg border border-secondary-foreground bg-secondary p-4 text-sm text-danger">
<div className="flex items-center gap-2">
<IoIosWarning className="mr-2 size-5 text-danger" />
<div className="max-w-[85%] text-sm">
<Trans ns="views/settings">
frigatePlus.snapshotConfig.cleanCopyWarning
</Trans>
</div>
)}
</div>
</div>
)}
</div>
}
/>
</SettingsGroupCard>
</div>
</div>
</div>
<Separator className="my-2 flex bg-secondary" />
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<div className="sticky bottom-0 z-50 w-full border-t border-secondary bg-background pb-5 pt-0 md:pr-2">
<div className="flex flex-col items-center gap-4 pt-2 md:flex-row md:justify-end">
<div className="flex w-full items-center gap-2 md:w-auto">
<Button
className="flex flex-1"
className="flex min-w-36 flex-1 gap-2"
variant="outline"
aria-label={t("button.reset", { ns: "common" })}
onClick={onCancel}
>
@ -541,15 +543,15 @@ export default function FrigatePlusSettingsView({
<Button
variant="select"
disabled={!changedValue || isLoading}
className="flex flex-1"
aria-label="Save"
className="flex min-w-36 flex-1 gap-2"
aria-label={t("button.save", { ns: "common" })}
onClick={saveToConfig}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
<>
<ActivityIndicator className="h-4 w-4" />
{t("button.saving", { ns: "common" })}
</>
) : (
t("button.save", { ns: "common" })
)}
@ -558,6 +560,6 @@ export default function FrigatePlusSettingsView({
</div>
</div>
</div>
</>
</div>
);
}

View File

@ -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<React.SetStateAction<boolean>>;
};
export default function NotificationView({
setUnsavedChanges,
}: NotificationsSettingsViewProps) {
const { t } = useTranslation(["views/settings"]);
const { getLocaleDocUrl } = useDocDomain();
// roles
const isAdmin = useIsAdmin();
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 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 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"}&notifications.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-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">
<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>
{isAdmin && (
<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}
type={"camera"}
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={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((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>
{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
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">
<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>
);
}

View File

@ -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 (
<div className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4">
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
{title}
</div>
{children}
</div>
);
}
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 [];