mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-27 17:17:40 +03:00
frontend and i18n keys
This commit is contained in:
parent
fbc9c2c101
commit
e759557a4f
@ -109,5 +109,12 @@
|
|||||||
"markAsReviewed": "Mark as reviewed",
|
"markAsReviewed": "Mark as reviewed",
|
||||||
"deleteNow": "Delete Now"
|
"deleteNow": "Delete Now"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"imagePicker": {
|
||||||
|
"selectImage": "Select a tracked object's thumbnail or snapshot",
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Search by label..."
|
||||||
|
},
|
||||||
|
"noImages": "No thumbnails found for this camera"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -644,5 +644,94 @@
|
|||||||
"success": "Frigate+ settings have been saved. Restart Frigate to apply changes.",
|
"success": "Frigate+ settings have been saved. Restart Frigate to apply changes.",
|
||||||
"error": "Failed to save config changes: {{errorMessage}}"
|
"error": "Failed to save config changes: {{errorMessage}}"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"triggers": {
|
||||||
|
"documentTitle": "Triggers",
|
||||||
|
"management": {
|
||||||
|
"title": "Trigger Management",
|
||||||
|
"desc": "Manage triggers for camera '{{camera}}' to detect specific events."
|
||||||
|
},
|
||||||
|
"addTrigger": "Add Trigger",
|
||||||
|
"table": {
|
||||||
|
"name": "Name",
|
||||||
|
"type": "Type",
|
||||||
|
"content": "Content",
|
||||||
|
"threshold": "Threshold",
|
||||||
|
"actions": "Actions",
|
||||||
|
"noTriggers": "No triggers configured for this camera.",
|
||||||
|
"edit": "Edit",
|
||||||
|
"deleteTrigger": "Delete Trigger"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"image": "Image",
|
||||||
|
"text": "Text",
|
||||||
|
"both": "Both"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"alert": "Mark as Alert",
|
||||||
|
"notification": "Send Notification"
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"createTrigger": {
|
||||||
|
"title": "Create Trigger",
|
||||||
|
"desc": "Create a new trigger for camera '{{camera}}'."
|
||||||
|
},
|
||||||
|
"editTrigger": {
|
||||||
|
"title": "Edit Trigger",
|
||||||
|
"desc": "Edit the settings for an existing trigger on camera '{{camera}}'."
|
||||||
|
},
|
||||||
|
"deleteTrigger": {
|
||||||
|
"title": "Delete Trigger",
|
||||||
|
"desc": "Are you sure you want to delete the trigger <strong>{{triggerName}}</strong> from camera '{{camera}}'? This action cannot be undone."
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": {
|
||||||
|
"title": "Trigger Name",
|
||||||
|
"placeholder": "Enter trigger name",
|
||||||
|
"error": {
|
||||||
|
"minLength": "Name must be at least 2 characters long.",
|
||||||
|
"invalidCharacters": "Name can only contain letters, numbers, underscores, and hyphens.",
|
||||||
|
"alreadyExists": "A trigger with this name already exists for this camera."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"title": "Trigger Type",
|
||||||
|
"placeholder": "Select trigger type"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"title": "Content",
|
||||||
|
"imagePlaceholder": "Select an image",
|
||||||
|
"textPlaceholder": "Enter text content",
|
||||||
|
"error": {
|
||||||
|
"required": "Content is required."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"threshold": {
|
||||||
|
"title": "Threshold",
|
||||||
|
"error": {
|
||||||
|
"min": "Threshold must be at least 0",
|
||||||
|
"max": "Threshold must be at most 1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"title": "Actions",
|
||||||
|
"error": {
|
||||||
|
"min": "At least one action must be selected."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"success": {
|
||||||
|
"createTrigger": "Trigger '{{name}}' created successfully.",
|
||||||
|
"updateTrigger": "Trigger '{{name}}' updated successfully.",
|
||||||
|
"deleteTrigger": "Trigger '{{name}}' deleted successfully."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"createTriggerFailed": "Failed to create trigger: {{errorMessage}}",
|
||||||
|
"updateTriggerFailed": "Failed to update trigger: {{errorMessage}}",
|
||||||
|
"deleteTriggerFailed": "Failed to delete trigger: {{errorMessage}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
372
web/src/components/overlay/CreateTriggerDialog.tsx
Normal file
372
web/src/components/overlay/CreateTriggerDialog.tsx
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
import { useEffect, useState, 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,
|
||||||
|
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";
|
||||||
|
|
||||||
|
type CreateTriggerDialogProps = {
|
||||||
|
show: boolean;
|
||||||
|
trigger: Trigger | null;
|
||||||
|
selectedCamera: string;
|
||||||
|
onCreate: (
|
||||||
|
name: string,
|
||||||
|
type: TriggerType,
|
||||||
|
data: string,
|
||||||
|
threshold: number,
|
||||||
|
actions: TriggerAction[],
|
||||||
|
) => void;
|
||||||
|
onEdit: (trigger: Trigger) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CreateTriggerDialog({
|
||||||
|
show,
|
||||||
|
trigger,
|
||||||
|
selectedCamera,
|
||||||
|
onCreate,
|
||||||
|
onEdit,
|
||||||
|
onCancel,
|
||||||
|
}: CreateTriggerDialogProps) {
|
||||||
|
const { t } = useTranslation("views/settings");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
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 formSchema = z.object({
|
||||||
|
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"),
|
||||||
|
),
|
||||||
|
type: z.enum(["image", "text", "both"]),
|
||||||
|
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(["alert", "notification"]))
|
||||||
|
.min(1, t("triggers.dialog.form.actions.error.min")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: {
|
||||||
|
name: trigger?.name ?? "",
|
||||||
|
type: trigger?.type ?? "text",
|
||||||
|
data: trigger?.data ?? "",
|
||||||
|
threshold: trigger?.threshold ?? 0.5,
|
||||||
|
actions: trigger?.actions ?? ["alert"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
if (trigger) {
|
||||||
|
onEdit({ ...values });
|
||||||
|
} else {
|
||||||
|
onCreate(
|
||||||
|
values.name,
|
||||||
|
values.type,
|
||||||
|
values.data,
|
||||||
|
values.threshold,
|
||||||
|
values.actions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!show) {
|
||||||
|
form.reset({
|
||||||
|
name: "",
|
||||||
|
type: "text",
|
||||||
|
data: "",
|
||||||
|
threshold: 0.5,
|
||||||
|
actions: ["alert"],
|
||||||
|
});
|
||||||
|
} else if (trigger) {
|
||||||
|
form.reset({
|
||||||
|
name: trigger.name,
|
||||||
|
type: trigger.type,
|
||||||
|
data: trigger.data,
|
||||||
|
threshold: trigger.threshold,
|
||||||
|
actions: trigger.actions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [show, trigger, form]);
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
form.reset();
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={show} onOpenChange={onCancel}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t(
|
||||||
|
trigger
|
||||||
|
? "triggers.dialog.editTrigger.title"
|
||||||
|
: "triggers.dialog.createTrigger.title",
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t(
|
||||||
|
trigger
|
||||||
|
? "triggers.dialog.editTrigger.desc"
|
||||||
|
: "triggers.dialog.createTrigger.desc",
|
||||||
|
{ camera: selectedCamera },
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-5 py-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("triggers.dialog.form.name.title")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={t("triggers.dialog.form.name.placeholder")}
|
||||||
|
className="h-10"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</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="image">
|
||||||
|
{t("triggers.type.image")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="text">
|
||||||
|
{t("triggers.type.text")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="both">
|
||||||
|
{t("triggers.type.both")}
|
||||||
|
</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") === "image" ? (
|
||||||
|
<FormControl>
|
||||||
|
<ImagePicker
|
||||||
|
selectedImageId={field.value}
|
||||||
|
setSelectedImageId={field.onChange}
|
||||||
|
camera={selectedCamera}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
) : (
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={t(
|
||||||
|
"triggers.dialog.form.content.textPlaceholder",
|
||||||
|
)}
|
||||||
|
className="h-10"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
<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">
|
||||||
|
{["alert", "notification"].map((action) => (
|
||||||
|
<div key={action} className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={form
|
||||||
|
.watch("actions")
|
||||||
|
.includes(action as "alert" | "notification")}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const currentActions = form.getValues("actions");
|
||||||
|
if (checked) {
|
||||||
|
form.setValue("actions", [
|
||||||
|
...currentActions,
|
||||||
|
action as "alert" | "notification",
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
form.setValue(
|
||||||
|
"actions",
|
||||||
|
currentActions.filter((a) => a !== action),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="text-sm font-normal">
|
||||||
|
{t(`triggers.actions.${action}`)}
|
||||||
|
</FormLabel>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||||
|
<div className="flex flex-1 flex-col justify-end">
|
||||||
|
<div className="flex flex-row gap-2 pt-5">
|
||||||
|
<Button
|
||||||
|
className="flex flex-1"
|
||||||
|
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}
|
||||||
|
className="flex flex-1"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
web/src/components/overlay/DeleteTriggerDialog.tsx
Normal file
68
web/src/components/overlay/DeleteTriggerDialog.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Trans } from "react-i18next";
|
||||||
|
|
||||||
|
type DeleteTriggerDialogProps = {
|
||||||
|
show: boolean;
|
||||||
|
triggerName: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DeleteTriggerDialog({
|
||||||
|
show,
|
||||||
|
triggerName,
|
||||||
|
onCancel,
|
||||||
|
onDelete,
|
||||||
|
}: DeleteTriggerDialogProps) {
|
||||||
|
const { t } = useTranslation("views/settings");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={show} onOpenChange={onCancel}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("triggers.dialog.deleteTrigger.title")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans
|
||||||
|
ns={"views/settings"}
|
||||||
|
values={{ triggerName }}
|
||||||
|
components={{ strong: <span className="font-medium" /> }}
|
||||||
|
>
|
||||||
|
triggers.dialog.deleteTrigger.desc
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||||
|
<div className="flex flex-1 flex-col justify-end">
|
||||||
|
<div className="flex flex-row gap-2 pt-5">
|
||||||
|
<Button
|
||||||
|
className="flex flex-1"
|
||||||
|
aria-label={t("button.cancel", { ns: "common" })}
|
||||||
|
onClick={onCancel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t("button.cancel", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
aria-label={t("button.delete", { ns: "common" })}
|
||||||
|
className="flex flex-1"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
{t("button.delete", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
web/src/components/overlay/ImagePicker.tsx
Normal file
175
web/src/components/overlay/ImagePicker.tsx
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { IoClose } from "react-icons/io5";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Heading from "@/components/ui/heading";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Event } from "@/types/event";
|
||||||
|
import { useApiHost } from "@/api";
|
||||||
|
|
||||||
|
type ImagePickerProps = {
|
||||||
|
selectedImageId?: string;
|
||||||
|
setSelectedImageId?: (id: string) => void;
|
||||||
|
camera: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ImagePicker({
|
||||||
|
selectedImageId,
|
||||||
|
setSelectedImageId,
|
||||||
|
camera,
|
||||||
|
}: ImagePickerProps) {
|
||||||
|
const { t } = useTranslation(["components/dialog"]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
const { data: events } = useSWR<Event[]>(`events?camera=${camera}&limit=50`, {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
const apiHost = useApiHost();
|
||||||
|
|
||||||
|
const images = useMemo(() => {
|
||||||
|
if (!events) return [];
|
||||||
|
return events.filter(
|
||||||
|
(event) =>
|
||||||
|
(event.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(event.sub_label &&
|
||||||
|
event.sub_label.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
|
searchTerm === "") &&
|
||||||
|
event.camera === camera,
|
||||||
|
);
|
||||||
|
}, [events, searchTerm, camera]);
|
||||||
|
|
||||||
|
const selectedImage = useMemo(
|
||||||
|
() => images.find((img) => img.id === selectedImageId),
|
||||||
|
[images, selectedImageId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleImageSelect = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
if (setSelectedImageId) {
|
||||||
|
setSelectedImageId(id);
|
||||||
|
}
|
||||||
|
setSearchTerm("");
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
[setSelectedImageId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
{!selectedImageId || !selectedImage ? (
|
||||||
|
<Button
|
||||||
|
className="mt-2 w-full text-muted-foreground"
|
||||||
|
aria-label={t("imagePicker.selectImage")}
|
||||||
|
>
|
||||||
|
{t("imagePicker.selectImage")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="hover:cursor-pointer">
|
||||||
|
<div className="my-3 flex w-full flex-row items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
selectedImage.has_snapshot
|
||||||
|
? `${apiHost}api/events/${selectedImage.id}/snapshot.jpg`
|
||||||
|
: `${apiHost}api/events/${selectedImage.id}/thumbnail.webp`
|
||||||
|
}
|
||||||
|
alt={selectedImage.label}
|
||||||
|
className="h-8 w-8 rounded object-cover"
|
||||||
|
/>
|
||||||
|
<div className="text-sm">
|
||||||
|
{selectedImage.label}
|
||||||
|
{selectedImage.sub_label
|
||||||
|
? ` (${selectedImage.sub_label})`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IoClose
|
||||||
|
className="mx-2 hover:cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
if (setSelectedImageId) {
|
||||||
|
setSelectedImageId("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
side="top"
|
||||||
|
container={containerRef.current}
|
||||||
|
className="flex max-h-[50dvh] w-full flex-col overflow-y-hidden md:max-h-[30dvh]"
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex flex-row items-center justify-between">
|
||||||
|
<Heading as="h4">{t("imagePicker.selectImage")}</Heading>
|
||||||
|
<span tabIndex={0} className="sr-only" />
|
||||||
|
<IoClose
|
||||||
|
size={15}
|
||||||
|
className="hover:cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("imagePicker.search.placeholder")}
|
||||||
|
className="text-md mb-3 md:text-sm"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="scrollbar-container flex h-full flex-col overflow-y-auto">
|
||||||
|
<div className="grid grid-cols-3 gap-2 pr-1">
|
||||||
|
{images.length === 0 ? (
|
||||||
|
<div className="col-span-3 text-center text-sm text-muted-foreground">
|
||||||
|
{t("imagePicker.noImages")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
images.map((image) => (
|
||||||
|
<div
|
||||||
|
key={image.id}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-row items-center justify-center rounded-lg p-1 hover:cursor-pointer",
|
||||||
|
selectedImageId === image.id
|
||||||
|
? "bg-selected text-white"
|
||||||
|
: "hover:bg-secondary-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
// image.has_snapshot
|
||||||
|
// ? `${apiHost}api/events/${image.id}/snapshot.jpg`
|
||||||
|
// :
|
||||||
|
`${apiHost}api/events/${image.id}/thumbnail.webp`
|
||||||
|
}
|
||||||
|
alt={image.label}
|
||||||
|
className="h-32 w-32 rounded object-cover"
|
||||||
|
onClick={() => handleImageSelect(image.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -45,6 +45,7 @@ import { isInIframe } from "@/utils/isIFrame";
|
|||||||
import { isPWA } from "@/utils/isPWA";
|
import { isPWA } from "@/utils/isPWA";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import TriggerView from "@/views/settings/TriggerView";
|
||||||
|
|
||||||
const allSettingsViews = [
|
const allSettingsViews = [
|
||||||
"ui",
|
"ui",
|
||||||
@ -52,6 +53,7 @@ const allSettingsViews = [
|
|||||||
"cameras",
|
"cameras",
|
||||||
"masksAndZones",
|
"masksAndZones",
|
||||||
"motionTuner",
|
"motionTuner",
|
||||||
|
"triggers",
|
||||||
"debug",
|
"debug",
|
||||||
"users",
|
"users",
|
||||||
"notifications",
|
"notifications",
|
||||||
@ -229,7 +231,8 @@ export default function Settings() {
|
|||||||
{(page == "debug" ||
|
{(page == "debug" ||
|
||||||
page == "cameras" ||
|
page == "cameras" ||
|
||||||
page == "masksAndZones" ||
|
page == "masksAndZones" ||
|
||||||
page == "motionTuner") && (
|
page == "motionTuner" ||
|
||||||
|
page == "triggers") && (
|
||||||
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
||||||
{page == "masksAndZones" && (
|
{page == "masksAndZones" && (
|
||||||
<ZoneMaskFilterButton
|
<ZoneMaskFilterButton
|
||||||
@ -274,6 +277,12 @@ export default function Settings() {
|
|||||||
setUnsavedChanges={setUnsavedChanges}
|
setUnsavedChanges={setUnsavedChanges}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{page === "triggers" && (
|
||||||
|
<TriggerView
|
||||||
|
selectedCamera={selectedCamera}
|
||||||
|
setUnsavedChanges={setUnsavedChanges}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{page == "users" && <AuthenticationView />}
|
{page == "users" && <AuthenticationView />}
|
||||||
{page == "notifications" && (
|
{page == "notifications" && (
|
||||||
<NotificationView setUnsavedChanges={setUnsavedChanges} />
|
<NotificationView setUnsavedChanges={setUnsavedChanges} />
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { IconName } from "@/components/icons/IconPicker";
|
import { IconName } from "@/components/icons/IconPicker";
|
||||||
|
import { TriggerAction, TriggerType } from "./trigger";
|
||||||
|
|
||||||
export interface UiConfig {
|
export interface UiConfig {
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
@ -220,6 +221,16 @@ export interface CameraConfig {
|
|||||||
rtmp: {
|
rtmp: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
semantic_search: {
|
||||||
|
triggers: {
|
||||||
|
[triggerName: string]: {
|
||||||
|
type: TriggerType;
|
||||||
|
data: string;
|
||||||
|
threshold: number;
|
||||||
|
actions: TriggerAction[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
snapshots: {
|
snapshots: {
|
||||||
bounding_box: boolean;
|
bounding_box: boolean;
|
||||||
clean_copy: boolean;
|
clean_copy: boolean;
|
||||||
|
|||||||
10
web/src/types/trigger.ts
Normal file
10
web/src/types/trigger.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export type TriggerType = "image" | "text" | "both";
|
||||||
|
export type TriggerAction = "alert" | "notification";
|
||||||
|
|
||||||
|
export type Trigger = {
|
||||||
|
name: string;
|
||||||
|
type: TriggerType;
|
||||||
|
data: string;
|
||||||
|
threshold: number;
|
||||||
|
actions: TriggerAction[];
|
||||||
|
};
|
||||||
465
web/src/views/settings/TriggerView.tsx
Normal file
465
web/src/views/settings/TriggerView.tsx
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Toaster, toast } from "sonner";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import axios from "axios";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Heading from "@/components/ui/heading";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { LuPlus, LuTrash, LuPencil } from "react-icons/lu";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import CreateTriggerDialog from "@/components/overlay/CreateTriggerDialog";
|
||||||
|
import DeleteTriggerDialog from "@/components/overlay/DeleteTriggerDialog";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { Trigger, TriggerAction, TriggerType } from "@/types/trigger";
|
||||||
|
|
||||||
|
type ConfigSetBody = {
|
||||||
|
requires_restart: number;
|
||||||
|
config_data: {
|
||||||
|
cameras: {
|
||||||
|
[key: string]: {
|
||||||
|
semantic_search?: {
|
||||||
|
triggers?: {
|
||||||
|
[key: string]:
|
||||||
|
| {
|
||||||
|
type: string;
|
||||||
|
data: string;
|
||||||
|
threshold: number;
|
||||||
|
actions: string[];
|
||||||
|
}
|
||||||
|
| "";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
update_topic?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TriggerEmbeddingBody = {
|
||||||
|
type: TriggerType;
|
||||||
|
data: string;
|
||||||
|
threshold: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TriggerViewProps = {
|
||||||
|
selectedCamera: string;
|
||||||
|
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TriggerView({
|
||||||
|
selectedCamera,
|
||||||
|
setUnsavedChanges,
|
||||||
|
}: TriggerViewProps) {
|
||||||
|
const { t } = useTranslation("views/settings");
|
||||||
|
const { data: config, mutate: updateConfig } =
|
||||||
|
useSWR<FrigateConfig>("config");
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
|
const [selectedTrigger, setSelectedTrigger] = useState<Trigger | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const triggers = useMemo(() => {
|
||||||
|
if (
|
||||||
|
!config ||
|
||||||
|
!selectedCamera ||
|
||||||
|
!config.cameras[selectedCamera]?.semantic_search?.triggers
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.entries(
|
||||||
|
config.cameras[selectedCamera].semantic_search.triggers,
|
||||||
|
).map(([name, trigger]) => ({
|
||||||
|
name,
|
||||||
|
type: trigger.type,
|
||||||
|
data: trigger.data,
|
||||||
|
threshold: trigger.threshold,
|
||||||
|
actions: trigger.actions,
|
||||||
|
}));
|
||||||
|
}, [config, selectedCamera]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = t("triggers.documentTitle");
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const saveToConfig = useCallback(
|
||||||
|
(trigger: Trigger, isEdit: boolean) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const { name, type, data, threshold, actions } = trigger;
|
||||||
|
const embeddingBody: TriggerEmbeddingBody = { type, data, threshold };
|
||||||
|
const embeddingUrl = isEdit
|
||||||
|
? `/trigger/embedding/${selectedCamera}/${name}`
|
||||||
|
: `/trigger/embedding?camera=${selectedCamera}&name=${name}`;
|
||||||
|
const embeddingMethod = isEdit ? axios.put : axios.post;
|
||||||
|
|
||||||
|
embeddingMethod(embeddingUrl, embeddingBody)
|
||||||
|
.then((embeddingResponse) => {
|
||||||
|
if (embeddingResponse.data.success) {
|
||||||
|
const configBody: ConfigSetBody = {
|
||||||
|
requires_restart: 0,
|
||||||
|
config_data: {
|
||||||
|
cameras: {
|
||||||
|
[selectedCamera]: {
|
||||||
|
semantic_search: {
|
||||||
|
triggers: {
|
||||||
|
[name]: {
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
threshold,
|
||||||
|
actions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update_topic: `config/cameras/${selectedCamera}/semantic_search`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return axios
|
||||||
|
.put("config/set", configBody)
|
||||||
|
.then((configResponse) => {
|
||||||
|
if (configResponse.status === 200) {
|
||||||
|
updateConfig();
|
||||||
|
toast.success(
|
||||||
|
t(
|
||||||
|
isEdit
|
||||||
|
? "triggers.toast.success.updateTrigger"
|
||||||
|
: "triggers.toast.success.createTrigger",
|
||||||
|
{ name },
|
||||||
|
),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
setUnsavedChanges(false);
|
||||||
|
} else {
|
||||||
|
throw new Error(configResponse.statusText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(embeddingResponse.data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[t, updateConfig, selectedCamera, setUnsavedChanges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onCreate = useCallback(
|
||||||
|
(
|
||||||
|
name: string,
|
||||||
|
type: TriggerType,
|
||||||
|
data: string,
|
||||||
|
threshold: number,
|
||||||
|
actions: TriggerAction[],
|
||||||
|
) => {
|
||||||
|
setUnsavedChanges(true);
|
||||||
|
saveToConfig({ name, type, data, threshold, actions }, false);
|
||||||
|
setShowCreate(false);
|
||||||
|
},
|
||||||
|
[saveToConfig, setUnsavedChanges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onEdit = useCallback(
|
||||||
|
(trigger: Trigger) => {
|
||||||
|
setUnsavedChanges(true);
|
||||||
|
if (selectedTrigger?.name && selectedTrigger.name !== trigger.name) {
|
||||||
|
// Handle rename by deleting old trigger
|
||||||
|
axios
|
||||||
|
.delete(
|
||||||
|
`/trigger/embedding/${selectedCamera}/${selectedTrigger.name}`,
|
||||||
|
)
|
||||||
|
.then((embeddingResponse) => {
|
||||||
|
if (embeddingResponse.data.success) {
|
||||||
|
return saveToConfig(trigger, true);
|
||||||
|
} else {
|
||||||
|
throw new Error(embeddingResponse.data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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" },
|
||||||
|
);
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
saveToConfig(trigger, true);
|
||||||
|
}
|
||||||
|
setShowCreate(false);
|
||||||
|
setSelectedTrigger(null);
|
||||||
|
},
|
||||||
|
[t, saveToConfig, selectedCamera, selectedTrigger, setUnsavedChanges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDelete = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
setUnsavedChanges(true);
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.delete(`/trigger/embedding/${selectedCamera}/${name}`)
|
||||||
|
.then((embeddingResponse) => {
|
||||||
|
if (embeddingResponse.data.success) {
|
||||||
|
const configBody: ConfigSetBody = {
|
||||||
|
requires_restart: 0,
|
||||||
|
config_data: {
|
||||||
|
cameras: {
|
||||||
|
[selectedCamera]: {
|
||||||
|
semantic_search: {
|
||||||
|
triggers: {
|
||||||
|
[name]: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update_topic: `config/cameras/${selectedCamera}/semantic_search`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return axios
|
||||||
|
.put("config/set", configBody)
|
||||||
|
.then((configResponse) => {
|
||||||
|
if (configResponse.status === 200) {
|
||||||
|
setShowDelete(false);
|
||||||
|
updateConfig();
|
||||||
|
toast.success(
|
||||||
|
t("triggers.toast.success.deleteTrigger", { name }),
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setUnsavedChanges(false);
|
||||||
|
} else {
|
||||||
|
throw new Error(configResponse.statusText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(embeddingResponse.data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(
|
||||||
|
t("triggers.toast.error.deleteTriggerFailed", { errorMessage }),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[t, updateConfig, selectedCamera, setUnsavedChanges],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCamera) {
|
||||||
|
setSelectedTrigger(null);
|
||||||
|
setShowCreate(false);
|
||||||
|
setShowDelete(false);
|
||||||
|
setUnsavedChanges(false);
|
||||||
|
}
|
||||||
|
}, [selectedCamera, setUnsavedChanges]);
|
||||||
|
|
||||||
|
if (!config || !selectedCamera) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col md:flex-row">
|
||||||
|
<Toaster position="top-center" closeButton={true} />
|
||||||
|
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||||
|
<div className="mb-5 flex flex-row items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<Heading as="h3" className="my-2">
|
||||||
|
{t("triggers.management.title")}
|
||||||
|
</Heading>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("triggers.management.desc", { camera: selectedCamera })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2 self-start sm:self-auto"
|
||||||
|
aria-label={t("triggers.addTrigger")}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTrigger(null);
|
||||||
|
setShowCreate(true);
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<LuPlus className="size-4" />
|
||||||
|
{t("triggers.addTrigger")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt">
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-muted/50">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[200px]">
|
||||||
|
{t("triggers.table.name")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>{t("triggers.table.type")}</TableHead>
|
||||||
|
<TableHead>{t("triggers.table.content")}</TableHead>
|
||||||
|
<TableHead>{t("triggers.table.threshold")}</TableHead>
|
||||||
|
<TableHead>{t("triggers.table.actions")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{triggers.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="h-24 text-center">
|
||||||
|
{t("triggers.table.noTriggers")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
triggers.map((trigger) => (
|
||||||
|
<TableRow key={trigger.name} className="group">
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{trigger.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
trigger.type === "image" ? "default" : "outline"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(`triggers.type.${trigger.type}`)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{trigger.type === "image"
|
||||||
|
? trigger.data
|
||||||
|
: trigger.data.length > 30
|
||||||
|
? `${trigger.data.substring(0, 30)}...`
|
||||||
|
: trigger.data}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{trigger.threshold.toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{trigger.actions
|
||||||
|
.map((action) => t(`triggers.actions.${action}`))
|
||||||
|
.join(", ")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 px-2"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTrigger(trigger);
|
||||||
|
setShowCreate(true);
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<LuPencil className="size-3.5" />
|
||||||
|
<span className="ml-1.5 hidden sm:inline-block">
|
||||||
|
{t("triggers.table.edit")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("triggers.table.edit")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="h-8 px-2"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTrigger(trigger);
|
||||||
|
setShowDelete(true);
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<LuTrash className="size-3.5" />
|
||||||
|
<span className="ml-1.5 hidden sm:inline-block">
|
||||||
|
{t("button.delete", { ns: "common" })}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("triggers.table.deleteTrigger")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CreateTriggerDialog
|
||||||
|
show={showCreate}
|
||||||
|
trigger={selectedTrigger}
|
||||||
|
selectedCamera={selectedCamera}
|
||||||
|
onCreate={onCreate}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowCreate(false);
|
||||||
|
setSelectedTrigger(null);
|
||||||
|
setUnsavedChanges(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DeleteTriggerDialog
|
||||||
|
show={showDelete}
|
||||||
|
triggerName={selectedTrigger?.name ?? ""}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowDelete(false);
|
||||||
|
setSelectedTrigger(null);
|
||||||
|
setUnsavedChanges(false);
|
||||||
|
}}
|
||||||
|
onDelete={() => onDelete(selectedTrigger?.name ?? "")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user