add recognized plate picker to lpr known plates in settings (#24059)

This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent a16ca285f7
commit c9c6a923ef
2 changed files with 241 additions and 13 deletions
+6 -1
View File
@@ -1519,8 +1519,13 @@
"keyPatternError": "Use only letters, numbers, hyphens, and underscores (no spaces)" "keyPatternError": "Use only letters, numbers, hyphens, and underscores (no spaces)"
}, },
"knownPlates": { "knownPlates": {
"assignedTo": "Assigned to {{name}}",
"detected": "Detected plates",
"namePlaceholder": "e.g., Wife's Car", "namePlaceholder": "e.g., Wife's Car",
"platePlaceholder": "Plate number or regex" "noneDetected": "No plates detected yet",
"platePlaceholder": "Plate number or regex",
"search": "Search or enter a plate",
"useCustom": "Use \"{{value}}\""
}, },
"liveStreams": { "liveStreams": {
"streamNameLabel": "Stream name", "streamNameLabel": "Stream name",
@@ -1,6 +1,7 @@
import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils"; import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
@@ -8,11 +9,24 @@ import {
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
LuCheck,
LuChevronDown, LuChevronDown,
LuChevronRight, LuChevronRight,
LuChevronsUpDown,
LuPlus, LuPlus,
LuTrash2, LuTrash2,
} from "react-icons/lu"; } from "react-icons/lu";
@@ -23,6 +37,183 @@ import { MapKeyInput } from "../components";
type KnownPlatesData = Record<string, string[]>; type KnownPlatesData = Record<string, string[]>;
type PlateComboboxProps = {
id: string;
value: string;
entryName: string;
disabled?: boolean;
detectedPlates: string[];
plateAssignments: Map<string, string>;
autoOpen: boolean;
onAutoOpened: () => void;
onCommit: (next: string) => void;
};
/**
* Plate entry that doubles as a picker for plates Frigate has already
* recognized. Free text is still accepted so regexes remain typeable.
*/
function PlateCombobox({
id,
value,
entryName,
disabled,
detectedPlates,
plateAssignments,
autoOpen,
onAutoOpened,
onCommit,
}: PlateComboboxProps) {
const { t } = useTranslation(["views/settings"]);
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!autoOpen) return;
setOpen(true);
onAutoOpened();
}, [autoOpen, onAutoOpened]);
// Seed the search box with the current plate and select it, so the first
// keystroke replaces the plate instead of appending to it.
useEffect(() => {
if (!open) {
setSearchValue("");
return;
}
setSearchValue(value);
const frame = requestAnimationFrame(() => inputRef.current?.select());
return () => cancelAnimationFrame(frame);
}, [open, value]);
const trimmedSearch = searchValue.trim();
const matchesDetected = useMemo(
() =>
detectedPlates.some(
(plate) => plate.toLowerCase() === trimmedSearch.toLowerCase(),
),
[detectedPlates, trimmedSearch],
);
const showCustomOption = trimmedSearch.length > 0 && !matchesDetected;
const commit = useCallback(
(next: string) => {
onCommit(next);
setOpen(false);
},
[onCommit],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"min-w-0 flex-1 justify-between font-normal",
!value && "text-muted-foreground",
)}
>
<span className="truncate">
{value ||
t("configForm.knownPlates.platePlaceholder", {
ns: "views/settings",
})}
</span>
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[--radix-popover-trigger-width] p-0"
>
<Command>
<CommandInput
ref={inputRef}
placeholder={t("configForm.knownPlates.search", {
ns: "views/settings",
})}
value={searchValue}
onValueChange={setSearchValue}
/>
<CommandList>
{showCustomOption && (
<CommandGroup>
<CommandItem
value={trimmedSearch}
onSelect={() => commit(trimmedSearch)}
>
<LuPlus className="mr-2 h-4 w-4 shrink-0" />
<span className="truncate">
{t("configForm.knownPlates.useCustom", {
ns: "views/settings",
value: trimmedSearch,
})}
</span>
</CommandItem>
</CommandGroup>
)}
{detectedPlates.length > 0 ? (
<CommandGroup
heading={t("configForm.knownPlates.detected", {
ns: "views/settings",
})}
>
{detectedPlates.map((plate) => {
const assignedTo = plateAssignments.get(plate);
const showAssignedTo =
!!assignedTo && assignedTo !== entryName;
return (
<CommandItem
key={plate}
value={plate}
onSelect={() => commit(plate)}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4 shrink-0",
value === plate ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{plate}</span>
{showAssignedTo && (
<span className="ml-auto shrink-0 pl-2 text-xs text-muted-foreground">
{t("configForm.knownPlates.assignedTo", {
ns: "views/settings",
name: assignedTo,
})}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
) : (
!showCustomOption && (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("configForm.knownPlates.noneDetected", {
ns: "views/settings",
})}
</div>
)
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function KnownPlatesField(props: FieldProps) { export function KnownPlatesField(props: FieldProps) {
const { schema, formData, onChange, idSchema, disabled, readonly } = props; const { schema, formData, onChange, idSchema, disabled, readonly } = props;
const formContext = props.registry?.formContext as const formContext = props.registry?.formContext as
@@ -87,6 +278,35 @@ export function KnownPlatesField(props: FieldProps) {
} }
}, [hasItems]); }, [hasItems]);
const { data: recognizedPlates } = useSWR<string[]>(
open ? ["recognized_license_plates", { split_joined: 1 }] : null,
{ revalidateOnFocus: false },
);
const detectedPlates = useMemo(
() => recognizedPlates ?? [],
[recognizedPlates],
);
const plateAssignments = useMemo(() => {
const assignments = new Map<string, string>();
for (const [name, plates] of entries) {
for (const plate of plates) {
const trimmed = plate.trim();
if (trimmed && !assignments.has(trimmed)) {
assignments.set(trimmed, name);
}
}
}
return assignments;
}, [entries]);
const [pendingOpenPlate, setPendingOpenPlate] = useState<string | null>(null);
const clearPendingOpenPlate = useCallback(
() => setPendingOpenPlate(null),
[],
);
const handleAddEntry = useCallback(() => { const handleAddEntry = useCallback(() => {
const next = { ...data, "": [""] }; const next = { ...data, "": [""] };
onChange(next, fieldPath); onChange(next, fieldPath);
@@ -120,8 +340,9 @@ export function KnownPlatesField(props: FieldProps) {
const handleAddPlate = useCallback( const handleAddPlate = useCallback(
(key: string) => { (key: string) => {
const next = { ...data, [key]: [...(data[key] || []), ""] }; const plates = [...(data[key] || []), ""];
onChange(next, fieldPath); onChange({ ...data, [key]: plates }, fieldPath);
setPendingOpenPlate(`${key}::${plates.length - 1}`);
}, },
[data, fieldPath, onChange], [data, fieldPath, onChange],
); );
@@ -154,9 +375,6 @@ export function KnownPlatesField(props: FieldProps) {
const namePlaceholder = t("configForm.knownPlates.namePlaceholder", { const namePlaceholder = t("configForm.knownPlates.namePlaceholder", {
ns: "views/settings", ns: "views/settings",
}); });
const platePlaceholder = t("configForm.knownPlates.platePlaceholder", {
ns: "views/settings",
});
return ( return (
<Card className="w-full"> <Card className="w-full">
<Collapsible open={open} onOpenChange={setOpen}> <Collapsible open={open} onOpenChange={setOpen}>
@@ -224,15 +442,20 @@ export function KnownPlatesField(props: FieldProps) {
<div className="ml-1 space-y-2 border-l-2 border-muted-foreground/20 pl-3"> <div className="ml-1 space-y-2 border-l-2 border-muted-foreground/20 pl-3">
{plates.map((plate, plateIndex) => ( {plates.map((plate, plateIndex) => (
<div key={plateIndex} className="flex items-center gap-2"> <div key={plateIndex} className="flex items-center gap-2">
<Input <PlateCombobox
id={`${entryId}-plate-${plateIndex}`} id={`${entryId}-plate-${plateIndex}`}
value={plate} value={plate}
placeholder={platePlaceholder} entryName={key}
disabled={disabled || readonly} disabled={disabled || readonly}
onChange={(e) => detectedPlates={detectedPlates}
handleUpdatePlate(key, plateIndex, e.target.value) plateAssignments={plateAssignments}
autoOpen={
pendingOpenPlate === `${key}::${plateIndex}`
}
onAutoOpened={clearPendingOpenPlate}
onCommit={(next) =>
handleUpdatePlate(key, plateIndex, next)
} }
className="flex-1"
/> />
{plates.length > 1 && ( {plates.length > 1 && (
<Button <Button