mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-03 10:31:14 +03:00
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* remove redundant per-view toasters in settings * add variants to standardize dialog footer button layouts * remove text-md this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html * make wizard footers consistent with dialog footers * consistent destructive button style remove text-white from individual buttons and add it to the variant
467 lines
15 KiB
TypeScript
467 lines
15 KiB
TypeScript
import { useEffect, useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useForm } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { z } from "zod";
|
|
import useSWR from "swr";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from "@/components/ui/form";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Button } from "@/components/ui/button";
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
|
import ImagePicker from "@/components/overlay/ImagePicker";
|
|
import { Trigger, TriggerAction, TriggerType } from "@/types/trigger";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Textarea } from "../ui/textarea";
|
|
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
|
import { isDesktop, isMobile } from "react-device-detect";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
MobilePage,
|
|
MobilePageContent,
|
|
MobilePageDescription,
|
|
MobilePageHeader,
|
|
MobilePageTitle,
|
|
} from "../mobile/MobilePage";
|
|
import NameAndIdFields from "@/components/input/NameAndIdFields";
|
|
|
|
type CreateTriggerDialogProps = {
|
|
show: boolean;
|
|
trigger: Trigger | null;
|
|
selectedCamera: string;
|
|
isLoading: boolean;
|
|
onCreate: (
|
|
enabled: boolean,
|
|
name: string,
|
|
type: TriggerType,
|
|
data: string,
|
|
threshold: number,
|
|
actions: TriggerAction[],
|
|
friendly_name: string,
|
|
) => void;
|
|
onEdit: (trigger: Trigger) => void;
|
|
onCancel: () => void;
|
|
};
|
|
|
|
export default function CreateTriggerDialog({
|
|
show,
|
|
trigger,
|
|
selectedCamera,
|
|
isLoading,
|
|
onCreate,
|
|
onEdit,
|
|
onCancel,
|
|
}: CreateTriggerDialogProps) {
|
|
const { t } = useTranslation("views/settings");
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
|
|
const availableActions = useMemo(() => {
|
|
if (!config) return [];
|
|
|
|
if (config.cameras[selectedCamera].notifications.enabled_in_config) {
|
|
return ["notification", "sub_label", "attribute"];
|
|
}
|
|
return ["sub_label", "attribute"];
|
|
}, [config, selectedCamera]);
|
|
|
|
const existingTriggerNames = useMemo(() => {
|
|
if (
|
|
!config ||
|
|
!selectedCamera ||
|
|
!config.cameras[selectedCamera]?.semantic_search?.triggers
|
|
) {
|
|
return [];
|
|
}
|
|
return Object.keys(config.cameras[selectedCamera].semantic_search.triggers);
|
|
}, [config, selectedCamera]);
|
|
|
|
const existingTriggerFriendlyNames = useMemo(() => {
|
|
if (
|
|
!config ||
|
|
!selectedCamera ||
|
|
!config.cameras[selectedCamera]?.semantic_search?.triggers
|
|
) {
|
|
return [];
|
|
}
|
|
return Object.values(
|
|
config.cameras[selectedCamera].semantic_search.triggers,
|
|
).map((trigger) => trigger.friendly_name);
|
|
}, [config, selectedCamera]);
|
|
|
|
const formSchema = z.object({
|
|
enabled: z.boolean(),
|
|
name: z
|
|
.string()
|
|
.min(2, t("triggers.dialog.form.name.error.minLength"))
|
|
.regex(
|
|
/^[a-zA-Z0-9_-]+$/,
|
|
t("triggers.dialog.form.name.error.invalidCharacters"),
|
|
)
|
|
.refine(
|
|
(value) =>
|
|
!existingTriggerNames.includes(value) || value === trigger?.name,
|
|
t("triggers.dialog.form.name.error.alreadyExists"),
|
|
),
|
|
friendly_name: z
|
|
.string()
|
|
.min(2, t("triggers.dialog.form.name.error.minLength"))
|
|
.refine(
|
|
(value) =>
|
|
!existingTriggerFriendlyNames.includes(value) ||
|
|
value === trigger?.friendly_name,
|
|
t("triggers.dialog.form.name.error.alreadyExists"),
|
|
),
|
|
type: z.enum(["thumbnail", "description"]),
|
|
data: z.string().min(1, t("triggers.dialog.form.content.error.required")),
|
|
threshold: z
|
|
.number()
|
|
.min(0, t("triggers.dialog.form.threshold.error.min"))
|
|
.max(1, t("triggers.dialog.form.threshold.error.max")),
|
|
actions: z.array(z.enum(["notification", "sub_label", "attribute"])),
|
|
});
|
|
|
|
const form = useForm<z.infer<typeof formSchema>>({
|
|
resolver: zodResolver(formSchema),
|
|
mode: "onChange",
|
|
defaultValues: {
|
|
enabled: trigger?.enabled ?? true,
|
|
name: trigger?.name ?? "",
|
|
friendly_name: trigger?.friendly_name ?? "",
|
|
type: trigger?.type ?? "description",
|
|
data: trigger?.data ?? "",
|
|
threshold: trigger?.threshold ?? 0.5,
|
|
actions: trigger?.actions ?? [],
|
|
},
|
|
});
|
|
|
|
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
|
if (trigger && existingTriggerNames.includes(trigger.name)) {
|
|
onEdit({ ...values });
|
|
} else {
|
|
onCreate(
|
|
values.enabled,
|
|
values.name,
|
|
values.type,
|
|
values.data,
|
|
values.threshold,
|
|
values.actions,
|
|
values.friendly_name,
|
|
);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!show) {
|
|
form.reset({
|
|
enabled: true,
|
|
name: "",
|
|
friendly_name: "",
|
|
type: "description",
|
|
data: "",
|
|
threshold: 0.5,
|
|
actions: [],
|
|
});
|
|
} else if (trigger) {
|
|
form.reset(
|
|
{
|
|
enabled: trigger.enabled,
|
|
name: trigger.name,
|
|
friendly_name: trigger.friendly_name ?? trigger.name,
|
|
type: trigger.type,
|
|
data: trigger.data,
|
|
threshold: trigger.threshold,
|
|
actions: trigger.actions,
|
|
},
|
|
{ keepDirty: false, keepTouched: false }, // Reset validation state
|
|
);
|
|
// Trigger validation to ensure isValid updates
|
|
// form.trigger();
|
|
}
|
|
}, [show, trigger, form]);
|
|
|
|
const handleCancel = () => {
|
|
form.reset();
|
|
onCancel();
|
|
};
|
|
|
|
const cameraName = useCameraFriendlyName(selectedCamera);
|
|
|
|
const Overlay = isDesktop ? Dialog : MobilePage;
|
|
const Content = isDesktop ? DialogContent : MobilePageContent;
|
|
const Header = isDesktop ? DialogHeader : MobilePageHeader;
|
|
const Description = isDesktop ? DialogDescription : MobilePageDescription;
|
|
const Title = isDesktop ? DialogTitle : MobilePageTitle;
|
|
|
|
return (
|
|
<Overlay open={show} onOpenChange={onCancel}>
|
|
<Content
|
|
className={cn(
|
|
"scrollbar-container overflow-y-auto",
|
|
isDesktop && "my-4 flex max-h-dvh flex-col",
|
|
isMobile && "px-4",
|
|
)}
|
|
>
|
|
<Header className="mt-2" onClose={onCancel}>
|
|
<Title>
|
|
{t(
|
|
trigger
|
|
? "triggers.dialog.editTrigger.title"
|
|
: "triggers.dialog.createTrigger.title",
|
|
)}
|
|
</Title>
|
|
<Description className={cn(!isDesktop && "sr-only")}>
|
|
{t(
|
|
trigger
|
|
? "triggers.dialog.editTrigger.desc"
|
|
: "triggers.dialog.createTrigger.desc",
|
|
{
|
|
camera: cameraName,
|
|
},
|
|
)}
|
|
</Description>
|
|
</Header>
|
|
|
|
<Form {...form}>
|
|
<form
|
|
onSubmit={form.handleSubmit(onSubmit)}
|
|
className="space-y-5 pt-4"
|
|
>
|
|
<NameAndIdFields
|
|
type="trigger"
|
|
control={form.control}
|
|
nameField="friendly_name"
|
|
idField="name"
|
|
nameLabel={t("triggers.dialog.form.name.title")}
|
|
nameDescription={t("triggers.dialog.form.name.description")}
|
|
placeholderName={t("triggers.dialog.form.name.placeholder")}
|
|
idVisible={!!trigger}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="enabled"
|
|
render={({ field }) => (
|
|
<FormItem className="flex flex-row items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<FormLabel className="text-base">
|
|
{t("button.enabled", { ns: "common" })}
|
|
</FormLabel>
|
|
<div className="text-sm text-muted-foreground">
|
|
{t("triggers.dialog.form.enabled.description")}
|
|
</div>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="type"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("triggers.dialog.form.type.title")}</FormLabel>
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
defaultValue={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger className="h-10">
|
|
<SelectValue
|
|
placeholder={t(
|
|
"triggers.dialog.form.type.placeholder",
|
|
)}
|
|
/>
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value="thumbnail">
|
|
{t("triggers.type.thumbnail")}
|
|
</SelectItem>
|
|
<SelectItem value="description">
|
|
{t("triggers.type.description")}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="data"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
{t("triggers.dialog.form.content.title")}
|
|
</FormLabel>
|
|
{form.watch("type") === "thumbnail" ? (
|
|
<>
|
|
<FormControl>
|
|
<ImagePicker
|
|
selectedImageId={field.value}
|
|
setSelectedImageId={field.onChange}
|
|
camera={selectedCamera}
|
|
/>
|
|
</FormControl>
|
|
</>
|
|
) : (
|
|
<>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder={t(
|
|
"triggers.dialog.form.content.textPlaceholder",
|
|
)}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t("triggers.dialog.form.content.textDesc")}
|
|
</FormDescription>
|
|
</>
|
|
)}
|
|
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="threshold"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
{t("triggers.dialog.form.threshold.title")}
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
max="1"
|
|
placeholder="0.50"
|
|
className="h-10"
|
|
{...field}
|
|
onChange={(e) => {
|
|
const value = parseFloat(e.target.value);
|
|
field.onChange(isNaN(value) ? 0 : value);
|
|
}}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="actions"
|
|
render={() => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
{t("triggers.dialog.form.actions.title")}
|
|
</FormLabel>
|
|
<div className="space-y-2">
|
|
{availableActions.map((action) => (
|
|
<label
|
|
key={action}
|
|
className="flex cursor-pointer items-center space-x-2"
|
|
>
|
|
<FormControl>
|
|
<Checkbox
|
|
checked={form
|
|
.watch("actions")
|
|
.includes(action as TriggerAction)}
|
|
onCheckedChange={(checked) => {
|
|
const currentActions = form.getValues("actions");
|
|
if (checked) {
|
|
form.setValue("actions", [
|
|
...currentActions,
|
|
action as TriggerAction,
|
|
]);
|
|
} else {
|
|
form.setValue(
|
|
"actions",
|
|
currentActions.filter((a) => a !== action),
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
</FormControl>
|
|
<span className="text-sm font-normal">
|
|
{t(`triggers.actions.${action}`)}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<FormDescription>
|
|
{t("triggers.dialog.form.actions.desc")}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<DialogFooter className="pt-2">
|
|
<Button
|
|
aria-label={t("button.cancel", { ns: "common" })}
|
|
disabled={isLoading}
|
|
onClick={handleCancel}
|
|
type="button"
|
|
>
|
|
{t("button.cancel", { ns: "common" })}
|
|
</Button>
|
|
<Button
|
|
variant="select"
|
|
aria-label={t("button.save", { ns: "common" })}
|
|
disabled={isLoading || !form.formState.isValid}
|
|
type="submit"
|
|
>
|
|
{isLoading ? (
|
|
<div className="flex flex-row items-center gap-2">
|
|
<ActivityIndicator className="size-4" />
|
|
<span>{t("button.saving", { ns: "common" })}</span>
|
|
</div>
|
|
) : (
|
|
t("button.save", { ns: "common" })
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</Form>
|
|
</Content>
|
|
</Overlay>
|
|
);
|
|
}
|