frontend and i18n keys

This commit is contained in:
Josh Hawkins 2025-06-29 16:41:58 -05:00
parent fbc9c2c101
commit e759557a4f
9 changed files with 1207 additions and 1 deletions

View File

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

View File

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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} />

View File

@ -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
View 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[];
};

View 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>
);
}