mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 05:24:11 +03:00
Trigger Wizard (#20691)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* add reusable component for combined name / internal name form field * fix labels * refactor utilities * refactor image picker * lazy loading * don't clear text box * trigger wizard * image picker fixes * use name and ID field in trigger edit dialog * ensure wizard resets when reopening * icon size tweak * multiple triggers can trigger at once * remove scrolling * mobile tweaks * remove duplicated component * fix types * use table on desktop and keep cards on mobile * provide default
This commit is contained in:
parent
710a77679b
commit
640007e5d3
@ -93,7 +93,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"back": "Go back"
|
"back": "Go back",
|
||||||
|
"hide": "Hide {{item}}",
|
||||||
|
"show": "Show {{item}}",
|
||||||
|
"ID": "ID"
|
||||||
|
},
|
||||||
|
"field": {
|
||||||
|
"optional": "Optional",
|
||||||
|
"internalID": "The Internal ID Frigate uses in the configuration and database"
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
|
|||||||
@ -112,6 +112,7 @@
|
|||||||
},
|
},
|
||||||
"imagePicker": {
|
"imagePicker": {
|
||||||
"selectImage": "Select a tracked object's thumbnail",
|
"selectImage": "Select a tracked object's thumbnail",
|
||||||
|
"unknownLabel": "Saved Trigger Image",
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Search by label or sub label..."
|
"placeholder": "Search by label or sub label..."
|
||||||
},
|
},
|
||||||
|
|||||||
@ -883,7 +883,7 @@
|
|||||||
"desc": "Semantic Search must be enabled to use Triggers."
|
"desc": "Semantic Search must be enabled to use Triggers."
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
"title": "Trigger Management",
|
"title": "Triggers",
|
||||||
"desc": "Manage triggers for {{camera}}. Use the thumbnail type to trigger on similar thumbnails to your selected tracked object, and the description type to trigger on similar descriptions to text you specify."
|
"desc": "Manage triggers for {{camera}}. Use the thumbnail type to trigger on similar thumbnails to your selected tracked object, and the description type to trigger on similar descriptions to text you specify."
|
||||||
},
|
},
|
||||||
"addTrigger": "Add Trigger",
|
"addTrigger": "Add Trigger",
|
||||||
@ -922,10 +922,11 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"name": {
|
"name": {
|
||||||
"title": "Name",
|
"title": "Name",
|
||||||
"placeholder": "Enter trigger name",
|
"placeholder": "Name this trigger",
|
||||||
|
"description": "Enter a unique name or description to identify this trigger",
|
||||||
"error": {
|
"error": {
|
||||||
"minLength": "Name must be at least 2 characters long.",
|
"minLength": "Field must be at least 2 characters long.",
|
||||||
"invalidCharacters": "Name can only contain letters, numbers, underscores, and hyphens.",
|
"invalidCharacters": "Field can only contain letters, numbers, underscores, and hyphens.",
|
||||||
"alreadyExists": "A trigger with this name already exists for this camera."
|
"alreadyExists": "A trigger with this name already exists for this camera."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -934,18 +935,15 @@
|
|||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"title": "Type",
|
"title": "Type",
|
||||||
"placeholder": "Select trigger type"
|
"placeholder": "Select trigger type",
|
||||||
},
|
"description": "Trigger when a similar tracked object description is detected",
|
||||||
"friendly_name": {
|
"thumbnail": "Trigger when a similar tracked object thumbnail is detected"
|
||||||
"title": "Friendly Name",
|
|
||||||
"placeholder": "Name or describe this trigger",
|
|
||||||
"description": "An optional friendly name or descriptive text for this trigger."
|
|
||||||
},
|
},
|
||||||
"content": {
|
"content": {
|
||||||
"title": "Content",
|
"title": "Content",
|
||||||
"imagePlaceholder": "Select an image",
|
"imagePlaceholder": "Select a thumbnail",
|
||||||
"textPlaceholder": "Enter text content",
|
"textPlaceholder": "Enter text content",
|
||||||
"imageDesc": "Select an image to trigger this action when a similar image is detected.",
|
"imageDesc": "Only the most recent 100 thumbnails are displayed. If you can't find your desired thumbnail, please review earlier objects in Explore and set up a trigger from the menu there.",
|
||||||
"textDesc": "Enter text to trigger this action when a similar tracked object description is detected.",
|
"textDesc": "Enter text to trigger this action when a similar tracked object description is detected.",
|
||||||
"error": {
|
"error": {
|
||||||
"required": "Content is required."
|
"required": "Content is required."
|
||||||
@ -953,6 +951,7 @@
|
|||||||
},
|
},
|
||||||
"threshold": {
|
"threshold": {
|
||||||
"title": "Threshold",
|
"title": "Threshold",
|
||||||
|
"desc": "Set the similarity threshold for this trigger. A higher threshold means a closer match is required to fire the trigger.",
|
||||||
"error": {
|
"error": {
|
||||||
"min": "Threshold must be at least 0",
|
"min": "Threshold must be at least 0",
|
||||||
"max": "Threshold must be at most 1"
|
"max": "Threshold must be at most 1"
|
||||||
@ -967,6 +966,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"wizard": {
|
||||||
|
"title": "Create Trigger",
|
||||||
|
"step1": {
|
||||||
|
"description": "Configure the basic settings for your trigger."
|
||||||
|
},
|
||||||
|
"step2": {
|
||||||
|
"description": "Set up the content that will trigger this action."
|
||||||
|
},
|
||||||
|
"step3": {
|
||||||
|
"description": "Configure the threshold and actions for this trigger."
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"nameAndType": "Name and Type",
|
||||||
|
"configureData": "Configure Data",
|
||||||
|
"thresholdAndActions": "Threshold and Actions"
|
||||||
|
}
|
||||||
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"success": {
|
"success": {
|
||||||
"createTrigger": "Trigger {{name}} created successfully.",
|
"createTrigger": "Trigger {{name}} created successfully.",
|
||||||
|
|||||||
127
web/src/components/input/NameAndIdFields.tsx
Normal file
127
web/src/components/input/NameAndIdFields.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { Control, FieldValues, Path, PathValue } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useFormContext } from "react-hook-form";
|
||||||
|
import { generateFixedHash, isValidId } from "@/utils/stringUtil";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
|
||||||
|
control: Control<T>;
|
||||||
|
type?: string;
|
||||||
|
nameField: Path<T>;
|
||||||
|
idField: Path<T>;
|
||||||
|
nameLabel: string;
|
||||||
|
nameDescription?: string;
|
||||||
|
idLabel?: string;
|
||||||
|
idDescription?: string;
|
||||||
|
processId?: (name: string) => string;
|
||||||
|
placeholderName?: string;
|
||||||
|
placeholderId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||||
|
control,
|
||||||
|
type,
|
||||||
|
nameField,
|
||||||
|
idField,
|
||||||
|
nameLabel,
|
||||||
|
nameDescription,
|
||||||
|
idLabel,
|
||||||
|
idDescription,
|
||||||
|
processId,
|
||||||
|
placeholderName,
|
||||||
|
placeholderId,
|
||||||
|
}: NameAndIdFieldsProps<T>) {
|
||||||
|
const { t } = useTranslation(["common"]);
|
||||||
|
const { watch, setValue, trigger } = useFormContext<T>();
|
||||||
|
const [isIdVisible, setIsIdVisible] = useState(false);
|
||||||
|
|
||||||
|
const defaultProcessId = (name: string) => {
|
||||||
|
const normalized = name.replace(/\s+/g, "_").toLowerCase();
|
||||||
|
if (isValidId(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
} else {
|
||||||
|
return generateFixedHash(name, type);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const effectiveProcessId = processId || defaultProcessId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = watch((value, { name }) => {
|
||||||
|
if (name === nameField) {
|
||||||
|
const processedId = effectiveProcessId(value[nameField] || "");
|
||||||
|
setValue(idField, processedId as PathValue<T, Path<T>>);
|
||||||
|
trigger(idField);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, [watch, setValue, trigger, nameField, idField, effectiveProcessId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={nameField}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel>{nameLabel}</FormLabel>
|
||||||
|
<span
|
||||||
|
className="cursor-pointer text-right text-xs text-muted-foreground"
|
||||||
|
onClick={() => setIsIdVisible(!isIdVisible)}
|
||||||
|
>
|
||||||
|
{isIdVisible
|
||||||
|
? t("label.hide", { item: idLabel ?? t("label.ID") })
|
||||||
|
: t("label.show", {
|
||||||
|
item: idLabel ?? t("label.ID"),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="text-md"
|
||||||
|
placeholder={placeholderName}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{nameDescription && (
|
||||||
|
<FormDescription>{nameDescription}</FormDescription>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{isIdVisible && (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={idField}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{idLabel ?? t("label.ID")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="text-md"
|
||||||
|
placeholder={placeholderId}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{idDescription ?? t("field.internalID")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -47,6 +47,7 @@ import {
|
|||||||
MobilePageHeader,
|
MobilePageHeader,
|
||||||
MobilePageTitle,
|
MobilePageTitle,
|
||||||
} from "../mobile/MobilePage";
|
} from "../mobile/MobilePage";
|
||||||
|
import NameAndIdFields from "@/components/input/NameAndIdFields";
|
||||||
|
|
||||||
type CreateTriggerDialogProps = {
|
type CreateTriggerDialogProps = {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@ -89,6 +90,19 @@ export default function CreateTriggerDialog({
|
|||||||
return Object.keys(config.cameras[selectedCamera].semantic_search.triggers);
|
return Object.keys(config.cameras[selectedCamera].semantic_search.triggers);
|
||||||
}, [config, selectedCamera]);
|
}, [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({
|
const formSchema = z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
name: z
|
name: z
|
||||||
@ -103,7 +117,15 @@ export default function CreateTriggerDialog({
|
|||||||
!existingTriggerNames.includes(value) || value === trigger?.name,
|
!existingTriggerNames.includes(value) || value === trigger?.name,
|
||||||
t("triggers.dialog.form.name.error.alreadyExists"),
|
t("triggers.dialog.form.name.error.alreadyExists"),
|
||||||
),
|
),
|
||||||
friendly_name: z.string().optional(),
|
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"]),
|
type: z.enum(["thumbnail", "description"]),
|
||||||
data: z.string().min(1, t("triggers.dialog.form.content.error.required")),
|
data: z.string().min(1, t("triggers.dialog.form.content.error.required")),
|
||||||
threshold: z
|
threshold: z
|
||||||
@ -138,7 +160,7 @@ export default function CreateTriggerDialog({
|
|||||||
values.data,
|
values.data,
|
||||||
values.threshold,
|
values.threshold,
|
||||||
values.actions,
|
values.actions,
|
||||||
values.friendly_name ?? "",
|
values.friendly_name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -159,7 +181,7 @@ export default function CreateTriggerDialog({
|
|||||||
{
|
{
|
||||||
enabled: trigger.enabled,
|
enabled: trigger.enabled,
|
||||||
name: trigger.name,
|
name: trigger.name,
|
||||||
friendly_name: trigger.friendly_name ?? "",
|
friendly_name: trigger.friendly_name ?? trigger.name,
|
||||||
type: trigger.type,
|
type: trigger.type,
|
||||||
data: trigger.data,
|
data: trigger.data,
|
||||||
threshold: trigger.threshold,
|
threshold: trigger.threshold,
|
||||||
@ -219,47 +241,14 @@ export default function CreateTriggerDialog({
|
|||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-5 pt-4"
|
className="space-y-5 pt-4"
|
||||||
>
|
>
|
||||||
<FormField
|
<NameAndIdFields
|
||||||
|
type="trigger"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
nameField="friendly_name"
|
||||||
render={({ field }) => (
|
idField="name"
|
||||||
<FormItem>
|
nameLabel={t("triggers.dialog.form.name.title")}
|
||||||
<FormLabel>{t("triggers.dialog.form.name.title")}</FormLabel>
|
nameDescription={t("triggers.dialog.form.name.description")}
|
||||||
<FormControl>
|
placeholderName={t("triggers.dialog.form.name.placeholder")}
|
||||||
<Input
|
|
||||||
placeholder={t("triggers.dialog.form.name.placeholder")}
|
|
||||||
className="h-10"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="friendly_name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("triggers.dialog.form.friendly_name.title")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder={t(
|
|
||||||
"triggers.dialog.form.friendly_name.placeholder",
|
|
||||||
)}
|
|
||||||
className="h-10"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t("triggers.dialog.form.friendly_name.description")}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@ -335,9 +324,6 @@ export default function CreateTriggerDialog({
|
|||||||
camera={selectedCamera}
|
camera={selectedCamera}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
{t("triggers.dialog.form.content.imageDesc")}
|
|
||||||
</FormDescription>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -455,7 +441,7 @@ export default function CreateTriggerDialog({
|
|||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<ActivityIndicator />
|
<ActivityIndicator className="size-5" />
|
||||||
<span>{t("button.saving", { ns: "common" })}</span>
|
<span>{t("button.saving", { ns: "common" })}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -15,25 +15,33 @@ import { cn } from "@/lib/utils";
|
|||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
|
|
||||||
type ImagePickerProps = {
|
type ImagePickerProps = {
|
||||||
selectedImageId?: string;
|
selectedImageId?: string;
|
||||||
setSelectedImageId?: (id: string) => void;
|
setSelectedImageId?: (id: string) => void;
|
||||||
camera: string;
|
camera: string;
|
||||||
|
limit?: number;
|
||||||
|
direct?: boolean;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ImagePicker({
|
export default function ImagePicker({
|
||||||
selectedImageId,
|
selectedImageId,
|
||||||
setSelectedImageId,
|
setSelectedImageId,
|
||||||
camera,
|
camera,
|
||||||
|
limit = 100,
|
||||||
|
direct = false,
|
||||||
|
className,
|
||||||
}: ImagePickerProps) {
|
}: ImagePickerProps) {
|
||||||
const { t } = useTranslation(["components/dialog"]);
|
const { t } = useTranslation(["components/dialog", "views/settings"]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [loadedImages, setLoadedImages] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const { data: events } = useSWR<Event[]>(
|
const { data: events } = useSWR<Event[]>(
|
||||||
`events?camera=${camera}&limit=100`,
|
`events?camera=${camera}&limit=${limit}`,
|
||||||
{
|
{
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
},
|
},
|
||||||
@ -62,12 +70,77 @@ export default function ImagePicker({
|
|||||||
if (setSelectedImageId) {
|
if (setSelectedImageId) {
|
||||||
setSelectedImageId(id);
|
setSelectedImageId(id);
|
||||||
}
|
}
|
||||||
setSearchTerm("");
|
if (!direct) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[setSelectedImageId],
|
[setSelectedImageId, direct],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleImageLoad = useCallback((imageId: string) => {
|
||||||
|
setLoadedImages((prev) => new Set(prev).add(imageId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderSearchInput = () => (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("imagePicker.search.placeholder")}
|
||||||
|
className="text-md mb-3 md:text-sm"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
// Clear selected image when user starts typing
|
||||||
|
if (setSelectedImageId) {
|
||||||
|
setSelectedImageId("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderImageGrid = () => (
|
||||||
|
<div className="grid grid-cols-2 gap-4 pr-1 sm:grid-cols-6">
|
||||||
|
{images.length === 0 ? (
|
||||||
|
<div className="col-span-2 text-center text-sm text-muted-foreground sm:col-span-6">
|
||||||
|
{t("imagePicker.noImages")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
images.map((image) => (
|
||||||
|
<div
|
||||||
|
key={image.id}
|
||||||
|
className={cn(
|
||||||
|
"relative aspect-square cursor-pointer overflow-hidden rounded-lg border-2 bg-background transition-all",
|
||||||
|
selectedImageId === image.id &&
|
||||||
|
"border-selected ring-2 ring-selected",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`${apiHost}api/events/${image.id}/thumbnail.webp`}
|
||||||
|
alt={image.label}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onClick={() => handleImageSelect(image.id)}
|
||||||
|
onLoad={() => handleImageLoad(image.id)}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{!loadedImages.has(image.id) && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (direct) {
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={className}>
|
||||||
|
{renderSearchInput()}
|
||||||
|
{renderImageGrid()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef}>
|
<div ref={containerRef}>
|
||||||
<Dialog
|
<Dialog
|
||||||
@ -87,18 +160,27 @@ export default function ImagePicker({
|
|||||||
) : (
|
) : (
|
||||||
<div className="hover:cursor-pointer">
|
<div className="hover:cursor-pointer">
|
||||||
<div className="my-3 flex w-full flex-row items-center justify-between gap-2">
|
<div className="my-3 flex w-full flex-row items-center justify-between gap-2">
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-4">
|
||||||
<img
|
<div className="relative size-16">
|
||||||
src={
|
<img
|
||||||
selectedImage
|
src={
|
||||||
? `${apiHost}api/events/${selectedImage.id}/thumbnail.webp`
|
selectedImage
|
||||||
: `${apiHost}clips/triggers/${camera}/${selectedImageId}.webp`
|
? `${apiHost}api/events/${selectedImage.id}/thumbnail.webp`
|
||||||
}
|
: `${apiHost}clips/triggers/${camera}/${selectedImageId}.webp`
|
||||||
alt={selectedImage?.label || "Selected image"}
|
}
|
||||||
className="h-8 w-8 rounded object-cover"
|
alt={selectedImage?.label || "Selected image"}
|
||||||
/>
|
className="size-16 rounded object-cover"
|
||||||
|
onLoad={() => handleImageLoad(selectedImageId || "")}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{selectedImageId && !loadedImages.has(selectedImageId) && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="text-sm smart-capitalize">
|
<div className="text-sm smart-capitalize">
|
||||||
{selectedImage?.label || selectedImageId}
|
{selectedImage?.label || t("imagePicker.unknownLabel")}
|
||||||
{selectedImage?.sub_label
|
{selectedImage?.sub_label
|
||||||
? ` (${selectedImage.sub_label})`
|
? ` (${selectedImage.sub_label})`
|
||||||
: ""}
|
: ""}
|
||||||
@ -122,48 +204,23 @@ export default function ImagePicker({
|
|||||||
<DialogContent
|
<DialogContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"scrollbar-container overflow-y-auto",
|
"scrollbar-container overflow-y-auto",
|
||||||
isDesktop && "max-h-[75dvh] sm:max-w-xl md:max-w-3xl",
|
isDesktop && "max-h-[75dvh] sm:max-w-xl md:max-w-[70%]",
|
||||||
isMobile && "px-4",
|
isMobile && "scrollbar-container max-h-[90%] overflow-y-auto px-4",
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex flex-row items-center justify-between">
|
<div className="mb-3 flex flex-col items-start justify-start">
|
||||||
<Heading as="h4">{t("imagePicker.selectImage")}</Heading>
|
<Heading as="h4">{t("imagePicker.selectImage")}</Heading>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("triggers.dialog.form.content.imageDesc", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<span tabIndex={0} className="sr-only" />
|
<span tabIndex={0} className="sr-only" />
|
||||||
</div>
|
</div>
|
||||||
<Input
|
{renderSearchInput()}
|
||||||
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="scrollbar-container flex h-full flex-col overflow-y-auto">
|
||||||
<div className="grid grid-cols-3 gap-2 pr-1">
|
{renderImageGrid()}
|
||||||
{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={`${apiHost}api/events/${image.id}/thumbnail.webp`}
|
|
||||||
alt={image.label}
|
|
||||||
className="rounded object-cover"
|
|
||||||
onClick={() => handleImageSelect(image.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
255
web/src/components/trigger/TriggerWizardDialog.tsx
Normal file
255
web/src/components/trigger/TriggerWizardDialog.tsx
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import StepIndicator from "../indicators/StepIndicator";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
import { useReducer, useEffect } from "react";
|
||||||
|
import Step1NameAndType, {
|
||||||
|
Step1FormData,
|
||||||
|
} from "@/components/trigger/wizard/Step1NameAndType";
|
||||||
|
import Step2ConfigureData, {
|
||||||
|
Step2FormData,
|
||||||
|
} from "@/components/trigger/wizard/Step2ConfigureData";
|
||||||
|
import Step3ThresholdAndActions, {
|
||||||
|
Step3FormData,
|
||||||
|
} from "@/components/trigger/wizard/Step3ThresholdAndActions";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { isDesktop } from "react-device-detect";
|
||||||
|
import { Trigger, TriggerAction, TriggerType } from "@/types/trigger";
|
||||||
|
|
||||||
|
const TRIGGER_STEPS = [
|
||||||
|
"wizard.steps.nameAndType",
|
||||||
|
"wizard.steps.configureData",
|
||||||
|
"wizard.steps.thresholdAndActions",
|
||||||
|
];
|
||||||
|
|
||||||
|
type TriggerWizardDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
selectedCamera: string;
|
||||||
|
trigger?: Trigger | null;
|
||||||
|
onCreate: (
|
||||||
|
enabled: boolean,
|
||||||
|
name: string,
|
||||||
|
type: TriggerType,
|
||||||
|
data: string,
|
||||||
|
threshold: number,
|
||||||
|
actions: TriggerAction[],
|
||||||
|
friendly_name: string,
|
||||||
|
) => void;
|
||||||
|
onEdit: (trigger: Trigger) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WizardState = {
|
||||||
|
currentStep: number;
|
||||||
|
step1Data?: Step1FormData;
|
||||||
|
step2Data?: Step2FormData;
|
||||||
|
step3Data?: Step3FormData;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WizardAction =
|
||||||
|
| { type: "NEXT_STEP"; payload?: Partial<WizardState> }
|
||||||
|
| { type: "PREVIOUS_STEP" }
|
||||||
|
| { type: "SET_STEP_1"; payload: Step1FormData }
|
||||||
|
| { type: "SET_STEP_2"; payload: Step2FormData }
|
||||||
|
| { type: "SET_STEP_3"; payload: Step3FormData }
|
||||||
|
| { type: "RESET" };
|
||||||
|
|
||||||
|
const initialState: WizardState = {
|
||||||
|
currentStep: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function wizardReducer(state: WizardState, action: WizardAction): WizardState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_STEP_1":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
step1Data: action.payload,
|
||||||
|
step2Data: undefined,
|
||||||
|
step3Data: undefined,
|
||||||
|
currentStep: 1,
|
||||||
|
};
|
||||||
|
case "SET_STEP_2":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
step2Data: action.payload,
|
||||||
|
currentStep: 2,
|
||||||
|
};
|
||||||
|
case "SET_STEP_3":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
step3Data: action.payload,
|
||||||
|
currentStep: 3,
|
||||||
|
};
|
||||||
|
case "NEXT_STEP":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
...action.payload,
|
||||||
|
currentStep: state.currentStep + 1,
|
||||||
|
};
|
||||||
|
case "PREVIOUS_STEP":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentStep: Math.max(0, state.currentStep - 1),
|
||||||
|
};
|
||||||
|
case "RESET":
|
||||||
|
return initialState;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TriggerWizardDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
selectedCamera,
|
||||||
|
trigger,
|
||||||
|
onCreate,
|
||||||
|
onEdit,
|
||||||
|
isLoading,
|
||||||
|
}: TriggerWizardDialogProps) {
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
|
||||||
|
const [wizardState, dispatch] = useReducer(wizardReducer, initialState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
dispatch({ type: "RESET" });
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Reset wizard state when opening for a different trigger or when creating new
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
dispatch({ type: "RESET" });
|
||||||
|
}
|
||||||
|
}, [open, trigger]);
|
||||||
|
|
||||||
|
const handleStep1Next = (data: Step1FormData) => {
|
||||||
|
dispatch({ type: "SET_STEP_1", payload: data });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStep2Next = (data: Step2FormData) => {
|
||||||
|
dispatch({ type: "SET_STEP_2", payload: data });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStep3Next = (data: Step3FormData) => {
|
||||||
|
// Combine all step data and call the appropriate callback
|
||||||
|
const combinedData = {
|
||||||
|
...wizardState.step1Data!,
|
||||||
|
...wizardState.step2Data!,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (trigger) {
|
||||||
|
onEdit(combinedData);
|
||||||
|
} else {
|
||||||
|
onCreate(
|
||||||
|
combinedData.enabled,
|
||||||
|
combinedData.name,
|
||||||
|
combinedData.type,
|
||||||
|
combinedData.data,
|
||||||
|
combinedData.threshold,
|
||||||
|
combinedData.actions,
|
||||||
|
combinedData.friendly_name || "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Remove handleClose() - let the parent component handle closing after save completes
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
dispatch({ type: "PREVIOUS_STEP" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
dispatch({ type: "RESET" });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !isLoading) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"",
|
||||||
|
isDesktop &&
|
||||||
|
wizardState.currentStep == 1 &&
|
||||||
|
wizardState.step1Data?.type == "thumbnail"
|
||||||
|
? "max-h-[90%] max-w-[70%] overflow-y-auto xl:max-h-[80%]"
|
||||||
|
: "max-h-[90%] overflow-y-auto xl:max-h-[80%]",
|
||||||
|
)}
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StepIndicator
|
||||||
|
steps={TRIGGER_STEPS}
|
||||||
|
currentStep={wizardState.currentStep}
|
||||||
|
variant="dots"
|
||||||
|
className="mb-4 justify-start"
|
||||||
|
/>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("triggers.wizard.title")}</DialogTitle>
|
||||||
|
{wizardState.currentStep === 0 && (
|
||||||
|
<DialogDescription>
|
||||||
|
{t("triggers.wizard.step1.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
{wizardState.currentStep === 1 && (
|
||||||
|
<DialogDescription>
|
||||||
|
{t("triggers.wizard.step2.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
{wizardState.currentStep === 2 && (
|
||||||
|
<DialogDescription>
|
||||||
|
{t("triggers.wizard.step3.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="pb-4">
|
||||||
|
{wizardState.currentStep === 0 && (
|
||||||
|
<Step1NameAndType
|
||||||
|
initialData={wizardState.step1Data}
|
||||||
|
trigger={trigger}
|
||||||
|
selectedCamera={selectedCamera}
|
||||||
|
onNext={handleStep1Next}
|
||||||
|
onCancel={handleClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{wizardState.currentStep === 1 && wizardState.step1Data && (
|
||||||
|
<Step2ConfigureData
|
||||||
|
initialData={wizardState.step2Data}
|
||||||
|
triggerType={wizardState.step1Data.type}
|
||||||
|
selectedCamera={selectedCamera}
|
||||||
|
onNext={handleStep2Next}
|
||||||
|
onBack={handleBack}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{wizardState.currentStep === 2 &&
|
||||||
|
wizardState.step1Data &&
|
||||||
|
wizardState.step2Data && (
|
||||||
|
<Step3ThresholdAndActions
|
||||||
|
initialData={wizardState.step3Data}
|
||||||
|
trigger={trigger}
|
||||||
|
onNext={handleStep3Next}
|
||||||
|
onBack={handleBack}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
web/src/components/trigger/wizard/Step1NameAndType.tsx
Normal file
200
web/src/components/trigger/wizard/Step1NameAndType.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
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 NameAndIdFields from "@/components/input/NameAndIdFields";
|
||||||
|
import { Form, FormDescription } from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { Trigger, TriggerType } from "@/types/trigger";
|
||||||
|
|
||||||
|
export type Step1FormData = {
|
||||||
|
enabled: boolean;
|
||||||
|
name: string;
|
||||||
|
friendly_name: string;
|
||||||
|
type: TriggerType;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Step1NameAndTypeProps = {
|
||||||
|
initialData?: Step1FormData;
|
||||||
|
trigger?: Trigger | null;
|
||||||
|
selectedCamera: string;
|
||||||
|
onNext: (data: Step1FormData) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Step1NameAndType({
|
||||||
|
initialData,
|
||||||
|
trigger,
|
||||||
|
selectedCamera,
|
||||||
|
onNext,
|
||||||
|
onCancel,
|
||||||
|
}: Step1NameAndTypeProps) {
|
||||||
|
const { t } = useTranslation("views/settings");
|
||||||
|
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 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(["description", "thumbnail"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: {
|
||||||
|
enabled: true,
|
||||||
|
name: initialData?.name ?? trigger?.name ?? "",
|
||||||
|
friendly_name: initialData?.friendly_name ?? trigger?.friendly_name ?? "",
|
||||||
|
type: initialData?.type ?? trigger?.type ?? "description",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (values: z.infer<typeof formSchema>) => {
|
||||||
|
onNext({
|
||||||
|
enabled: true,
|
||||||
|
name: values.name,
|
||||||
|
friendly_name: values.friendly_name || "",
|
||||||
|
type: values.type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
form.reset(initialData);
|
||||||
|
} else if (trigger) {
|
||||||
|
form.reset({
|
||||||
|
enabled: trigger.enabled,
|
||||||
|
name: trigger.name,
|
||||||
|
friendly_name: trigger.friendly_name || "",
|
||||||
|
type: trigger.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [initialData, trigger, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
|
||||||
|
<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")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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="description">
|
||||||
|
{t("triggers.type.description")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="thumbnail">
|
||||||
|
{t("triggers.type.thumbnail")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
{t("triggers.dialog.form.type.description")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{t("button.cancel", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="select"
|
||||||
|
disabled={!form.formState.isValid}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{t("button.next", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
web/src/components/trigger/wizard/Step2ConfigureData.tsx
Normal file
133
web/src/components/trigger/wizard/Step2ConfigureData.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import ImagePicker from "@/components/overlay/ImagePicker";
|
||||||
|
import { TriggerType } from "@/types/trigger";
|
||||||
|
|
||||||
|
export type Step2FormData = {
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Step2ConfigureDataProps = {
|
||||||
|
initialData?: Step2FormData;
|
||||||
|
triggerType: TriggerType;
|
||||||
|
selectedCamera: string;
|
||||||
|
onNext: (data: Step2FormData) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Step2ConfigureData({
|
||||||
|
initialData,
|
||||||
|
triggerType,
|
||||||
|
selectedCamera,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: Step2ConfigureDataProps) {
|
||||||
|
const { t } = useTranslation("views/settings");
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
data: z.string().min(1, t("triggers.dialog.form.content.error.required")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: {
|
||||||
|
data: initialData?.data ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (values: z.infer<typeof formSchema>) => {
|
||||||
|
onNext({
|
||||||
|
data: values.data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
form.reset(initialData);
|
||||||
|
}
|
||||||
|
}, [initialData, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="data"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
{triggerType === "thumbnail" ? (
|
||||||
|
<>
|
||||||
|
<FormLabel className="font-normal">
|
||||||
|
{t("triggers.dialog.form.content.imagePlaceholder")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<ImagePicker
|
||||||
|
selectedImageId={field.value}
|
||||||
|
setSelectedImageId={field.onChange}
|
||||||
|
camera={selectedCamera}
|
||||||
|
direct
|
||||||
|
className="max-h-[50dvh] overflow-y-auto rounded-lg border p-4"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("triggers.dialog.form.content.imageDesc")}
|
||||||
|
</FormDescription>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder={t(
|
||||||
|
"triggers.dialog.form.content.textPlaceholder",
|
||||||
|
)}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("triggers.dialog.form.content.textDesc")}
|
||||||
|
</FormDescription>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{t("button.back", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="select"
|
||||||
|
disabled={!form.formState.isValid}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{t("button.next", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
web/src/components/trigger/wizard/Step3ThresholdAndActions.tsx
Normal file
194
web/src/components/trigger/wizard/Step3ThresholdAndActions.tsx
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import { useEffect, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Trigger, TriggerAction } from "@/types/trigger";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
|
||||||
|
export type Step3FormData = {
|
||||||
|
threshold: number;
|
||||||
|
actions: TriggerAction[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Step3ThresholdAndActionsProps = {
|
||||||
|
initialData?: Step3FormData;
|
||||||
|
trigger?: Trigger | null;
|
||||||
|
onNext: (data: Step3FormData) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Step3ThresholdAndActions({
|
||||||
|
initialData,
|
||||||
|
trigger,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
isLoading = false,
|
||||||
|
}: Step3ThresholdAndActionsProps) {
|
||||||
|
const { t } = useTranslation("views/settings");
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
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"])),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: {
|
||||||
|
threshold: initialData?.threshold ?? trigger?.threshold ?? 0.5,
|
||||||
|
actions:
|
||||||
|
initialData?.actions ?? (trigger?.actions as TriggerAction[]) ?? [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
(values: z.infer<typeof formSchema>) => {
|
||||||
|
onNext({
|
||||||
|
threshold: values.threshold,
|
||||||
|
actions: values.actions,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onNext],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
const formData = form.getValues();
|
||||||
|
// Basic validation
|
||||||
|
if (formData.threshold < 0 || formData.threshold > 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onNext(formData);
|
||||||
|
}, [form, onNext]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
form.reset(initialData);
|
||||||
|
} else if (trigger) {
|
||||||
|
form.reset({
|
||||||
|
threshold: trigger.threshold,
|
||||||
|
actions: trigger.actions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [initialData, trigger, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
|
||||||
|
<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>
|
||||||
|
<FormDescription>
|
||||||
|
{t("triggers.dialog.form.threshold.desc")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="actions"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("triggers.dialog.form.actions.title")}</FormLabel>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{["notification"].map((action) => (
|
||||||
|
<div key={action} className="flex 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>
|
||||||
|
<FormLabel className="text-sm font-normal">
|
||||||
|
{t(`triggers.actions.${action}`)}
|
||||||
|
</FormLabel>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
{t("triggers.dialog.form.actions.desc")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{t("button.back", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1"
|
||||||
|
variant="select"
|
||||||
|
>
|
||||||
|
{isLoading && <ActivityIndicator className="mr-2 size-5" />}
|
||||||
|
{isLoading
|
||||||
|
? t("button.saving", { ns: "common" })
|
||||||
|
: t("triggers.dialog.form.save", {
|
||||||
|
defaultValue: "Save Trigger",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,39 +1,4 @@
|
|||||||
// ==================== Camera Name Processing ====================
|
import { generateFixedHash, isValidId } from "./stringUtil";
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a fixed-length hash from a camera name for use as a valid camera identifier.
|
|
||||||
* Works safely with Unicode input while outputting Latin-only identifiers.
|
|
||||||
*
|
|
||||||
* @param name - The original camera name/display name
|
|
||||||
* @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars)
|
|
||||||
*/
|
|
||||||
export function generateFixedHash(name: string): string {
|
|
||||||
// Safely encode Unicode as UTF-8 bytes
|
|
||||||
const utf8Bytes = new TextEncoder().encode(name);
|
|
||||||
|
|
||||||
// Convert to base64 manually
|
|
||||||
let binary = "";
|
|
||||||
for (const byte of utf8Bytes) {
|
|
||||||
binary += String.fromCharCode(byte);
|
|
||||||
}
|
|
||||||
const base64 = btoa(binary);
|
|
||||||
|
|
||||||
// Strip out non-alphanumeric characters and truncate
|
|
||||||
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
|
|
||||||
|
|
||||||
return `cam_${cleanHash.toLowerCase()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a string is a valid camera name identifier.
|
|
||||||
* Valid camera names contain only ASCII letters, numbers, underscores, and hyphens.
|
|
||||||
*
|
|
||||||
* @param name - The camera name to validate
|
|
||||||
* @returns True if the name is valid, false otherwise
|
|
||||||
*/
|
|
||||||
export function isValidCameraName(name: string): boolean {
|
|
||||||
return /^[a-zA-Z0-9_-]+$/.test(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes a user-entered camera name and returns both the final camera name
|
* Processes a user-entered camera name and returns both the final camera name
|
||||||
@ -48,7 +13,7 @@ export function processCameraName(userInput: string): {
|
|||||||
} {
|
} {
|
||||||
const normalizedInput = userInput.replace(/\s+/g, "_").toLowerCase();
|
const normalizedInput = userInput.replace(/\s+/g, "_").toLowerCase();
|
||||||
|
|
||||||
if (isValidCameraName(normalizedInput)) {
|
if (isValidId(normalizedInput)) {
|
||||||
return {
|
return {
|
||||||
finalCameraName: normalizedInput,
|
finalCameraName: normalizedInput,
|
||||||
friendlyName: userInput.includes(" ") ? userInput : undefined,
|
friendlyName: userInput.includes(" ") ? userInput : undefined,
|
||||||
@ -56,7 +21,7 @@ export function processCameraName(userInput: string): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
finalCameraName: generateFixedHash(userInput),
|
finalCameraName: generateFixedHash(userInput, "cam"),
|
||||||
friendlyName: userInput,
|
friendlyName: userInput,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,3 +9,39 @@ export const capitalizeAll = (text: string): string => {
|
|||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
.join(" ");
|
.join(" ");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a fixed-length hash from a camera name for use as a valid camera identifier.
|
||||||
|
* Works safely with Unicode input while outputting Latin-only identifiers.
|
||||||
|
*
|
||||||
|
* @param name - The original camera name/display name
|
||||||
|
* @param prefix - Optional prefix for the identifier
|
||||||
|
* @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars)
|
||||||
|
*/
|
||||||
|
export function generateFixedHash(name: string, prefix: string = "id"): string {
|
||||||
|
// Safely encode Unicode as UTF-8 bytes
|
||||||
|
const utf8Bytes = new TextEncoder().encode(name);
|
||||||
|
|
||||||
|
// Convert to base64 manually
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of utf8Bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
const base64 = btoa(binary);
|
||||||
|
|
||||||
|
// Strip out non-alphanumeric characters and truncate
|
||||||
|
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
|
||||||
|
|
||||||
|
return `${prefix}_${cleanHash.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a string is a valid camera name identifier.
|
||||||
|
* Valid camera names contain only ASCII letters, numbers, underscores, and hyphens.
|
||||||
|
*
|
||||||
|
* @param name - The camera name to validate
|
||||||
|
* @returns True if the name is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export function isValidId(name: string): boolean {
|
||||||
|
return /^[a-zA-Z0-9_-]+$/.test(name);
|
||||||
|
}
|
||||||
|
|||||||
@ -13,14 +13,24 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
import {
|
import {
|
||||||
LuPlus,
|
LuPlus,
|
||||||
LuTrash,
|
LuTrash,
|
||||||
LuPencil,
|
LuPencil,
|
||||||
LuSearch,
|
LuSearch,
|
||||||
LuExternalLink,
|
LuExternalLink,
|
||||||
|
LuCircle,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import TriggerWizardDialog from "@/components/trigger/TriggerWizardDialog";
|
||||||
import CreateTriggerDialog from "@/components/overlay/CreateTriggerDialog";
|
import CreateTriggerDialog from "@/components/overlay/CreateTriggerDialog";
|
||||||
import DeleteTriggerDialog from "@/components/overlay/DeleteTriggerDialog";
|
import DeleteTriggerDialog from "@/components/overlay/DeleteTriggerDialog";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
@ -90,7 +100,7 @@ export default function TriggerView({
|
|||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [showDelete, setShowDelete] = useState(false);
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
const [selectedTrigger, setSelectedTrigger] = useState<Trigger | null>(null);
|
const [selectedTrigger, setSelectedTrigger] = useState<Trigger | null>(null);
|
||||||
const [triggeredTrigger, setTriggeredTrigger] = useState<string>();
|
const [triggeredTrigger, setTriggeredTrigger] = useState<string[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const cameraName = useCameraFriendlyName(selectedCamera);
|
const cameraName = useCameraFriendlyName(selectedCamera);
|
||||||
@ -127,28 +137,22 @@ export default function TriggerView({
|
|||||||
|
|
||||||
mutate();
|
mutate();
|
||||||
|
|
||||||
setTriggeredTrigger(triggers_status_ws.name);
|
setTriggeredTrigger((prev) => {
|
||||||
const target = document.querySelector(
|
const current = prev || [];
|
||||||
`#trigger-${triggers_status_ws.name}`,
|
if (!current.includes(triggers_status_ws.name)) {
|
||||||
);
|
const newTriggers = [...current, triggers_status_ws.name];
|
||||||
if (target) {
|
return newTriggers;
|
||||||
target.scrollIntoView({
|
|
||||||
block: "center",
|
|
||||||
behavior: "smooth",
|
|
||||||
inline: "nearest",
|
|
||||||
});
|
|
||||||
const ring = target.querySelector(".trigger-ring");
|
|
||||||
if (ring) {
|
|
||||||
ring.classList.add(`outline-selected`);
|
|
||||||
ring.classList.remove("outline-transparent");
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
ring.classList.remove(`outline-selected`);
|
|
||||||
ring.classList.add("outline-transparent");
|
|
||||||
}, 3000);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}
|
}
|
||||||
}
|
return current;
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setTriggeredTrigger((prev) =>
|
||||||
|
(prev || []).filter((name) => name !== triggers_status_ws.name),
|
||||||
|
);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
}, [triggers_status_ws, selectedCamera, mutate]);
|
}, [triggers_status_ws, selectedCamera, mutate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -228,6 +232,7 @@ export default function TriggerView({
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
|
setSelectedTrigger(null);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[t, updateConfig, selectedCamera, setUnsavedChanges],
|
[t, updateConfig, selectedCamera, setUnsavedChanges],
|
||||||
@ -313,7 +318,6 @@ export default function TriggerView({
|
|||||||
// Regular update without rename
|
// Regular update without rename
|
||||||
saveToConfig(trigger, true);
|
saveToConfig(trigger, true);
|
||||||
}
|
}
|
||||||
setSelectedTrigger(null);
|
|
||||||
},
|
},
|
||||||
[t, saveToConfig, selectedCamera, selectedTrigger, setUnsavedChanges],
|
[t, saveToConfig, selectedCamera, selectedTrigger, setUnsavedChanges],
|
||||||
);
|
);
|
||||||
@ -386,6 +390,7 @@ export default function TriggerView({
|
|||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
setShowDelete(false);
|
setShowDelete(false);
|
||||||
setUnsavedChanges(false);
|
setUnsavedChanges(false);
|
||||||
|
setTriggeredTrigger([]);
|
||||||
}
|
}
|
||||||
}, [selectedCamera, setUnsavedChanges]);
|
}, [selectedCamera, setUnsavedChanges]);
|
||||||
|
|
||||||
@ -480,72 +485,69 @@ export default function TriggerView({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<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="flex flex-1 flex-col gap-2 md:hidden">
|
||||||
<div className="h-full overflow-auto p-0">
|
{triggers.length === 0 ? (
|
||||||
{triggers.length === 0 ? (
|
<div className="flex h-24 items-center justify-center rounded-lg border border-border bg-background_alt">
|
||||||
<div className="flex h-24 items-center justify-center">
|
<p className="text-center text-muted-foreground">
|
||||||
<p className="text-center text-muted-foreground">
|
{t("triggers.table.noTriggers")}
|
||||||
{t("triggers.table.noTriggers")}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
triggers.map((trigger) => (
|
||||||
<div className="space-y-2">
|
<div
|
||||||
{triggers.map((trigger) => (
|
key={trigger.name}
|
||||||
<div
|
id={`trigger-${trigger.name}`}
|
||||||
key={trigger.name}
|
className="rounded-lg border border-border bg-background p-4"
|
||||||
id={`trigger-${trigger.name}`}
|
>
|
||||||
className="relative flex items-center justify-between rounded-lg border border-border bg-background p-4 transition-all"
|
<div className="flex items-start justify-between gap-3">
|
||||||
>
|
<div className="flex items-start gap-3">
|
||||||
<div
|
<div className="mt-1">
|
||||||
className={cn(
|
<LuCircle
|
||||||
"trigger-ring pointer-events-none absolute inset-0 z-10 size-full rounded-md outline outline-[3px] -outline-offset-[2.8px] duration-500",
|
|
||||||
triggeredTrigger === trigger.name
|
|
||||||
? "shadow-selected outline-selected"
|
|
||||||
: "outline-transparent duration-500",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"truncate text-lg font-medium",
|
"size-3 duration-500",
|
||||||
|
triggeredTrigger.includes(trigger.name)
|
||||||
|
? "fill-selected text-selected"
|
||||||
|
: "fill-muted text-muted dark:fill-secondary-highlight dark:text-secondary-highlight",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"font-medium",
|
||||||
!trigger.enabled && "opacity-60",
|
!trigger.enabled && "opacity-60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{trigger.friendly_name || trigger.name}
|
{trigger.friendly_name || trigger.name}
|
||||||
</h3>
|
</div>
|
||||||
<div
|
<div className="mt-2 flex flex-col gap-2">
|
||||||
className={cn(
|
<Badge
|
||||||
"mt-1 flex flex-col gap-1 text-sm text-muted-foreground md:flex-row md:items-center md:gap-3",
|
variant={
|
||||||
!trigger.enabled && "opacity-60",
|
trigger.type === "thumbnail"
|
||||||
)}
|
? "default"
|
||||||
>
|
: "outline"
|
||||||
<div>
|
}
|
||||||
<Badge
|
className={cn(
|
||||||
variant={
|
"w-fit",
|
||||||
trigger.type === "thumbnail"
|
trigger.type === "thumbnail"
|
||||||
? "default"
|
? "bg-primary/20 text-primary hover:bg-primary/30"
|
||||||
: "outline"
|
: "",
|
||||||
}
|
!trigger.enabled && "opacity-60",
|
||||||
className={
|
)}
|
||||||
trigger.type === "thumbnail"
|
>
|
||||||
? "bg-primary/20 text-primary hover:bg-primary/30"
|
{t(`triggers.type.${trigger.type}`)}
|
||||||
: ""
|
</Badge>
|
||||||
}
|
|
||||||
>
|
|
||||||
{t(`triggers.type.${trigger.type}`)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`}
|
to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm",
|
"flex items-center gap-1.5 text-xs text-muted-foreground",
|
||||||
!trigger_status?.triggers[trigger.name]
|
!trigger_status?.triggers[trigger.name]
|
||||||
?.triggering_event_id &&
|
?.triggering_event_id &&
|
||||||
"pointer-events-none",
|
"pointer-events-none",
|
||||||
|
!trigger.enabled && "opacity-60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center">
|
<span>
|
||||||
{t("triggers.table.lastTriggered")}:{" "}
|
{t("triggers.table.lastTriggered")}:{" "}
|
||||||
{trigger_status &&
|
{trigger_status &&
|
||||||
trigger_status.triggers[trigger.name]
|
trigger_status.triggers[trigger.name]
|
||||||
@ -574,9 +576,173 @@ export default function TriggerView({
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
: "Never"}
|
: "Never"}
|
||||||
|
</span>
|
||||||
|
{trigger_status?.triggers[trigger.name]
|
||||||
|
?.triggering_event_id && (
|
||||||
|
<LuSearch className="size-3" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTrigger(trigger);
|
||||||
|
setShowCreate(true);
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<LuPencil className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("triggers.table.edit")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTrigger(trigger);
|
||||||
|
setShowDelete(true);
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<LuTrash className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("triggers.table.deleteTrigger")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Table View */}
|
||||||
|
<div className="scrollbar-container hidden flex-1 overflow-hidden rounded-lg border border-border bg-background_alt md:mr-3 md:block">
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-muted/50">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-4"></TableHead>
|
||||||
|
<TableHead>{t("name", { ns: "common" })}</TableHead>
|
||||||
|
<TableHead>{t("triggers.table.type")}</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("triggers.table.lastTriggered")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("triggers.table.actions")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{triggers.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="h-24 text-center">
|
||||||
|
{t("triggers.table.noTriggers")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
triggers.map((trigger) => (
|
||||||
|
<TableRow
|
||||||
|
key={trigger.name}
|
||||||
|
id={`trigger-${trigger.name}`}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<LuCircle
|
||||||
|
className={cn(
|
||||||
|
"size-3 duration-500",
|
||||||
|
triggeredTrigger.includes(trigger.name)
|
||||||
|
? "fill-selected text-selected"
|
||||||
|
: "fill-muted text-muted dark:fill-secondary-highlight dark:text-secondary-highlight",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div
|
||||||
|
className={cn(!trigger.enabled && "opacity-60")}
|
||||||
|
>
|
||||||
|
{trigger.friendly_name || trigger.name}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
trigger.type === "thumbnail"
|
||||||
|
? "default"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
trigger.type === "thumbnail"
|
||||||
|
? "bg-primary/20 text-primary hover:bg-primary/30"
|
||||||
|
: "",
|
||||||
|
!trigger.enabled && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(`triggers.type.${trigger.type}`)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 text-sm",
|
||||||
|
!trigger_status?.triggers[trigger.name]
|
||||||
|
?.triggering_event_id &&
|
||||||
|
"pointer-events-none",
|
||||||
|
!trigger.enabled && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{trigger_status &&
|
||||||
|
trigger_status.triggers[trigger.name]
|
||||||
|
?.last_triggered
|
||||||
|
? formatUnixTimestampToDateTime(
|
||||||
|
trigger_status.triggers[trigger.name]
|
||||||
|
?.last_triggered,
|
||||||
|
{
|
||||||
|
timezone: config.ui.timezone,
|
||||||
|
date_format:
|
||||||
|
config.ui.time_format == "24hour"
|
||||||
|
? t(
|
||||||
|
"time.formattedTimestamp2.24hour",
|
||||||
|
{
|
||||||
|
ns: "common",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"time.formattedTimestamp2.12hour",
|
||||||
|
{
|
||||||
|
ns: "common",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
time_style: "medium",
|
||||||
|
date_style: "medium",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: "Never"}
|
||||||
|
</span>
|
||||||
|
{trigger_status?.triggers[trigger.name]
|
||||||
|
?.triggering_event_id && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<LuSearch className="ml-2 size-3.5" />
|
<LuSearch className="size-3.5" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{t("details.item.button.viewInExplore", {
|
{t("details.item.button.viewInExplore", {
|
||||||
@ -584,65 +750,85 @@ export default function TriggerView({
|
|||||||
})}
|
})}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="text-right">
|
||||||
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<TooltipProvider>
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
variant="outline"
|
||||||
variant="outline"
|
className="h-8 px-2"
|
||||||
className="h-8 w-8 p-0"
|
onClick={() => {
|
||||||
onClick={() => {
|
setSelectedTrigger(trigger);
|
||||||
setSelectedTrigger(trigger);
|
setShowCreate(true);
|
||||||
setShowCreate(true);
|
}}
|
||||||
}}
|
disabled={isLoading}
|
||||||
disabled={isLoading}
|
>
|
||||||
>
|
<LuPencil className="size-3.5" />
|
||||||
<LuPencil className="size-3.5" />
|
<span className="ml-1.5 hidden sm:inline-block">
|
||||||
</Button>
|
{t("triggers.table.edit")}
|
||||||
</TooltipTrigger>
|
</span>
|
||||||
<TooltipContent>
|
</Button>
|
||||||
<p>{t("triggers.table.edit")}</p>
|
</TooltipTrigger>
|
||||||
</TooltipContent>
|
<TooltipContent>
|
||||||
</Tooltip>
|
<p>{t("triggers.table.edit")}</p>
|
||||||
<Tooltip>
|
</TooltipContent>
|
||||||
<TooltipTrigger asChild>
|
</Tooltip>
|
||||||
<Button
|
<Tooltip>
|
||||||
size="sm"
|
<TooltipTrigger asChild>
|
||||||
variant="destructive"
|
<Button
|
||||||
className="h-8 w-8 p-0 text-white"
|
size="sm"
|
||||||
onClick={() => {
|
variant="destructive"
|
||||||
setSelectedTrigger(trigger);
|
className="h-8 px-2"
|
||||||
setShowDelete(true);
|
onClick={() => {
|
||||||
}}
|
setSelectedTrigger(trigger);
|
||||||
disabled={isLoading}
|
setShowDelete(true);
|
||||||
>
|
}}
|
||||||
<LuTrash className="size-3.5" />
|
disabled={isLoading}
|
||||||
</Button>
|
>
|
||||||
</TooltipTrigger>
|
<LuTrash className="size-3.5" />
|
||||||
<TooltipContent>
|
<span className="ml-1.5 hidden sm:inline-block">
|
||||||
<p>{t("triggers.table.deleteTrigger")}</p>
|
{t("triggers.table.deleteTrigger")}
|
||||||
</TooltipContent>
|
</span>
|
||||||
</Tooltip>
|
</Button>
|
||||||
</TooltipProvider>
|
</TooltipTrigger>
|
||||||
</div>
|
<TooltipContent>
|
||||||
</div>
|
<p>{t("triggers.table.deleteTrigger")}</p>
|
||||||
))}
|
</TooltipContent>
|
||||||
</div>
|
</Tooltip>
|
||||||
)}
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<TriggerWizardDialog
|
||||||
|
open={showCreate && (!selectedTrigger || selectedTrigger.name === "")}
|
||||||
|
onClose={() => {
|
||||||
|
setShowCreate(false);
|
||||||
|
setSelectedTrigger(null);
|
||||||
|
setUnsavedChanges(false);
|
||||||
|
}}
|
||||||
|
selectedCamera={selectedCamera}
|
||||||
|
trigger={null}
|
||||||
|
onCreate={onCreate}
|
||||||
|
onEdit={onEdit}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
<CreateTriggerDialog
|
<CreateTriggerDialog
|
||||||
show={showCreate}
|
show={showCreate && !!selectedTrigger && selectedTrigger.name !== ""}
|
||||||
trigger={selectedTrigger}
|
trigger={selectedTrigger}
|
||||||
selectedCamera={selectedCamera}
|
selectedCamera={selectedCamera}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user