mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-25 22:01:51 +03:00
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* remove redundant per-view toasters in settings * add variants to standardize dialog footer button layouts * remove text-md this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html * make wizard footers consistent with dialog footers * consistent destructive button style remove text-white from individual buttons and add it to the variant
509 lines
18 KiB
TypeScript
509 lines
18 KiB
TypeScript
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from "@/components/ui/form";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { useForm } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { z } from "zod";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMemo } from "react";
|
|
import { LuX, LuPlus, LuInfo, LuExternalLink } from "react-icons/lu";
|
|
import useSWR from "swr";
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
|
import { getTranslatedLabel } from "@/utils/i18n";
|
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
|
|
export type ModelType = "state" | "object";
|
|
export type ObjectClassificationType = "sub_label" | "attribute";
|
|
|
|
export type Step1FormData = {
|
|
modelName: string;
|
|
modelType: ModelType;
|
|
objectLabel?: string;
|
|
objectType?: ObjectClassificationType;
|
|
classes: string[];
|
|
};
|
|
|
|
type Step1NameAndDefineProps = {
|
|
initialData?: Partial<Step1FormData>;
|
|
defaultModelType?: "state" | "object";
|
|
onNext: (data: Step1FormData) => void;
|
|
onCancel: () => void;
|
|
};
|
|
|
|
export default function Step1NameAndDefine({
|
|
initialData,
|
|
defaultModelType,
|
|
onNext,
|
|
onCancel,
|
|
}: Step1NameAndDefineProps) {
|
|
const { t } = useTranslation(["views/classificationModel"]);
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
const { getLocaleDocUrl } = useDocDomain();
|
|
|
|
const objectLabels = useMemo(() => {
|
|
if (!config) return [];
|
|
|
|
const labels = new Set<string>();
|
|
|
|
Object.values(config.cameras).forEach((cameraConfig) => {
|
|
if (!cameraConfig.enabled || !cameraConfig.enabled_in_config) {
|
|
return;
|
|
}
|
|
|
|
cameraConfig.objects.track.forEach((label) => {
|
|
if (!config.model.all_attributes.includes(label)) {
|
|
labels.add(label);
|
|
}
|
|
});
|
|
});
|
|
|
|
return [...labels].sort();
|
|
}, [config]);
|
|
|
|
const step1FormData = z
|
|
.object({
|
|
modelName: z
|
|
.string()
|
|
.min(1, t("wizard.step1.errors.nameRequired"))
|
|
.max(64, t("wizard.step1.errors.nameLength"))
|
|
.refine((value) => !/^\d+$/.test(value), {
|
|
message: t("wizard.step1.errors.nameOnlyNumbers"),
|
|
}),
|
|
modelType: z.enum(["state", "object"]),
|
|
objectLabel: z.string().optional(),
|
|
objectType: z.enum(["sub_label", "attribute"]).optional(),
|
|
classes: z
|
|
.array(
|
|
z
|
|
.string()
|
|
.refine(
|
|
(val) => val.trim().toLowerCase() !== "none",
|
|
t("wizard.step1.errors.noneNotAllowed"),
|
|
),
|
|
)
|
|
.min(1, t("wizard.step1.errors.classRequired"))
|
|
.refine(
|
|
(classes) => {
|
|
const nonEmpty = classes.filter((c) => c.trim().length > 0);
|
|
return nonEmpty.length >= 1;
|
|
},
|
|
{ message: t("wizard.step1.errors.classRequired") },
|
|
)
|
|
.refine(
|
|
(classes) => {
|
|
const nonEmpty = classes.filter((c) => c.trim().length > 0);
|
|
const unique = new Set(nonEmpty.map((c) => c.toLowerCase()));
|
|
return unique.size === nonEmpty.length;
|
|
},
|
|
{ message: t("wizard.step1.errors.classesUnique") },
|
|
),
|
|
})
|
|
.refine(
|
|
(data) => {
|
|
// State models require at least 2 classes
|
|
if (data.modelType === "state") {
|
|
const nonEmpty = data.classes.filter((c) => c.trim().length > 0);
|
|
return nonEmpty.length >= 2;
|
|
}
|
|
return true;
|
|
},
|
|
{
|
|
message: t("wizard.step1.errors.stateRequiresTwoClasses"),
|
|
path: ["classes"],
|
|
},
|
|
)
|
|
.refine(
|
|
(data) => {
|
|
if (data.modelType === "object") {
|
|
return data.objectLabel !== undefined && data.objectLabel !== "";
|
|
}
|
|
return true;
|
|
},
|
|
{
|
|
message: t("wizard.step1.errors.objectLabelRequired"),
|
|
path: ["objectLabel"],
|
|
},
|
|
)
|
|
.refine(
|
|
(data) => {
|
|
if (data.modelType === "object") {
|
|
return data.objectType !== undefined;
|
|
}
|
|
return true;
|
|
},
|
|
{
|
|
message: t("wizard.step1.errors.objectTypeRequired"),
|
|
path: ["objectType"],
|
|
},
|
|
);
|
|
|
|
const form = useForm<z.infer<typeof step1FormData>>({
|
|
resolver: zodResolver(step1FormData),
|
|
defaultValues: {
|
|
modelName: initialData?.modelName || "",
|
|
modelType: initialData?.modelType || defaultModelType || "state",
|
|
objectLabel: initialData?.objectLabel,
|
|
objectType: initialData?.objectType || "sub_label",
|
|
classes: initialData?.classes?.length ? initialData.classes : [""],
|
|
},
|
|
mode: "onChange",
|
|
});
|
|
|
|
const watchedClasses = form.watch("classes");
|
|
const watchedModelType = form.watch("modelType");
|
|
const watchedObjectType = form.watch("objectType");
|
|
|
|
const handleAddClass = () => {
|
|
const currentClasses = form.getValues("classes");
|
|
form.setValue("classes", [...currentClasses, ""], { shouldValidate: true });
|
|
};
|
|
|
|
const handleRemoveClass = (index: number) => {
|
|
const currentClasses = form.getValues("classes");
|
|
const newClasses = currentClasses.filter((_, i) => i !== index);
|
|
|
|
// Ensure at least one field remains (even if empty)
|
|
if (newClasses.length === 0) {
|
|
form.setValue("classes", [""], { shouldValidate: true });
|
|
} else {
|
|
form.setValue("classes", newClasses, { shouldValidate: true });
|
|
}
|
|
};
|
|
|
|
const onSubmit = (data: z.infer<typeof step1FormData>) => {
|
|
// Filter out empty classes
|
|
const filteredClasses = data.classes.filter((c) => c.trim().length > 0);
|
|
onNext({
|
|
...data,
|
|
classes: filteredClasses,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="modelName"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="text-primary-variant">
|
|
{t("wizard.step1.name")}
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
className="h-8"
|
|
placeholder={t("wizard.step1.namePlaceholder")}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="modelType"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="text-primary-variant">
|
|
{t("wizard.step1.type")}
|
|
</FormLabel>
|
|
<FormControl>
|
|
<RadioGroup
|
|
onValueChange={field.onChange}
|
|
defaultValue={field.value}
|
|
className="flex flex-col gap-4 pt-2"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<RadioGroupItem
|
|
className={
|
|
watchedModelType === "state"
|
|
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
|
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
|
}
|
|
id="state"
|
|
value="state"
|
|
/>
|
|
<Label className="cursor-pointer" htmlFor="state">
|
|
{t("wizard.step1.typeState")}
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<RadioGroupItem
|
|
className={
|
|
watchedModelType === "object"
|
|
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
|
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
|
}
|
|
id="object"
|
|
value="object"
|
|
/>
|
|
<Label className="cursor-pointer" htmlFor="object">
|
|
{t("wizard.step1.typeObject")}
|
|
</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{watchedModelType === "object" && (
|
|
<>
|
|
<FormField
|
|
control={form.control}
|
|
name="objectLabel"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="text-primary-variant">
|
|
{t("wizard.step1.objectLabel")}
|
|
</FormLabel>
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
defaultValue={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger className="h-8">
|
|
<SelectValue
|
|
placeholder={t(
|
|
"wizard.step1.objectLabelPlaceholder",
|
|
)}
|
|
/>
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
{objectLabels.map((label) => (
|
|
<SelectItem
|
|
key={label}
|
|
value={label}
|
|
className="cursor-pointer hover:bg-secondary-highlight"
|
|
>
|
|
{getTranslatedLabel(label)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="objectType"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<div className="flex items-center gap-1">
|
|
<FormLabel className="text-primary-variant">
|
|
{t("wizard.step1.classificationType")}
|
|
</FormLabel>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-4 w-4 p-0"
|
|
>
|
|
<LuInfo className="size-3" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
|
<div className="flex flex-col gap-2">
|
|
<div className="text-sm">
|
|
{t("wizard.step1.classificationTypeDesc")}
|
|
</div>
|
|
<div className="mt-3 flex items-center text-primary">
|
|
<a
|
|
href={getLocaleDocUrl(
|
|
"configuration/custom_classification/object_classification#classification-type",
|
|
)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline cursor-pointer"
|
|
>
|
|
{t("readTheDocumentation", { ns: "common" })}
|
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
<FormControl>
|
|
<RadioGroup
|
|
onValueChange={field.onChange}
|
|
defaultValue={field.value}
|
|
className="flex flex-col gap-4 pt-2"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<RadioGroupItem
|
|
className={
|
|
watchedObjectType === "sub_label"
|
|
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
|
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
|
}
|
|
id="sub_label"
|
|
value="sub_label"
|
|
/>
|
|
<Label className="cursor-pointer" htmlFor="sub_label">
|
|
{t("wizard.step1.classificationSubLabel")}
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<RadioGroupItem
|
|
className={
|
|
watchedObjectType === "attribute"
|
|
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
|
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
|
}
|
|
id="attribute"
|
|
value="attribute"
|
|
/>
|
|
<Label className="cursor-pointer" htmlFor="attribute">
|
|
{t("wizard.step1.classificationAttribute")}
|
|
</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1">
|
|
<FormLabel className="text-primary-variant">
|
|
{watchedModelType === "state"
|
|
? t("wizard.step1.states")
|
|
: t("wizard.step1.classes")}
|
|
</FormLabel>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
|
<LuInfo className="size-3" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
|
<div className="flex flex-col gap-2">
|
|
<div className="text-sm">
|
|
{watchedModelType === "state"
|
|
? t("wizard.step1.classesStateDesc")
|
|
: t("wizard.step1.classesObjectDesc")}
|
|
</div>
|
|
<div className="mt-3 flex items-center text-primary">
|
|
<a
|
|
href={getLocaleDocUrl(
|
|
watchedModelType === "state"
|
|
? "configuration/custom_classification/state_classification"
|
|
: "configuration/custom_classification/object_classification",
|
|
)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline cursor-pointer"
|
|
>
|
|
{t("readTheDocumentation", { ns: "common" })}
|
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
|
onClick={handleAddClass}
|
|
>
|
|
<LuPlus />
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{watchedClasses.map((_, index) => (
|
|
<FormField
|
|
key={index}
|
|
control={form.control}
|
|
name={`classes.${index}`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormControl>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
className="h-8"
|
|
placeholder={t("wizard.step1.classPlaceholder")}
|
|
{...field}
|
|
/>
|
|
{watchedClasses.length > 1 && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
onClick={() => handleRemoveClass(index)}
|
|
>
|
|
<LuX className="size-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
{form.formState.errors.classes && (
|
|
<p className="text-sm font-medium text-destructive">
|
|
{form.formState.errors.classes.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
|
|
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
|
<Button type="button" onClick={onCancel} className="sm:flex-1">
|
|
{t("button.cancel", { ns: "common" })}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={form.handleSubmit(onSubmit)}
|
|
variant="select"
|
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
|
disabled={!form.formState.isValid}
|
|
>
|
|
{t("button.continue", { ns: "common" })}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|