mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
refactor frigate+ view and make tweaks to sections
This commit is contained in:
parent
23c6e5231f
commit
fdf7e22130
@ -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).",
|
||||
)
|
||||
|
||||
@ -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="",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
56
web/src/components/card/SettingsGroupCard.tsx
Normal file
56
web/src/components/card/SettingsGroupCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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"],
|
||||
|
||||
@ -27,7 +27,7 @@ const objects: SectionConfigOverrides = {
|
||||
"filters.mask",
|
||||
"filters.raw_mask",
|
||||
],
|
||||
advancedFields: ["filters"],
|
||||
advancedFields: ["genai"],
|
||||
uiSchema: {
|
||||
"filters.*.min_area": {
|
||||
"ui:options": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -477,8 +477,6 @@ const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"];
|
||||
const LARGE_BOTTOM_MARGIN_PAGES = [
|
||||
"masksAndZones",
|
||||
"motionTuner",
|
||||
"notifications",
|
||||
"frigateplus",
|
||||
"maintenance",
|
||||
];
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"}¬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-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>
|
||||
);
|
||||
}
|
||||
@ -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 [];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user