From 8a0c8489143836c69d5074095128ef2b7a58bf28 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:01:38 -0500 Subject: [PATCH] add recognized plate picker to lpr known plates in settings (#24059) --- web/public/locales/en/views/settings.json | 7 +- .../theme/fields/KnownPlatesField.tsx | 247 +++++++++++++++++- 2 files changed, 241 insertions(+), 13 deletions(-) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 9af9d53108..974de49e06 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1519,8 +1519,13 @@ "keyPatternError": "Use only letters, numbers, hyphens, and underscores (no spaces)" }, "knownPlates": { + "assignedTo": "Assigned to {{name}}", + "detected": "Detected plates", "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": { "streamNameLabel": "Stream name", diff --git a/web/src/components/config-form/theme/fields/KnownPlatesField.tsx b/web/src/components/config-form/theme/fields/KnownPlatesField.tsx index ee0820dc29..71bd251825 100644 --- a/web/src/components/config-form/theme/fields/KnownPlatesField.tsx +++ b/web/src/components/config-form/theme/fields/KnownPlatesField.tsx @@ -1,6 +1,7 @@ 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 useSWR from "swr"; import { Collapsible, CollapsibleContent, @@ -8,11 +9,24 @@ import { } from "@/components/ui/collapsible"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 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 { + LuCheck, LuChevronDown, LuChevronRight, + LuChevronsUpDown, LuPlus, LuTrash2, } from "react-icons/lu"; @@ -23,6 +37,183 @@ import { MapKeyInput } from "../components"; type KnownPlatesData = Record; +type PlateComboboxProps = { + id: string; + value: string; + entryName: string; + disabled?: boolean; + detectedPlates: string[]; + plateAssignments: Map; + 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(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 ( + + + + + + + + + {showCustomOption && ( + + commit(trimmedSearch)} + > + + + {t("configForm.knownPlates.useCustom", { + ns: "views/settings", + value: trimmedSearch, + })} + + + + )} + {detectedPlates.length > 0 ? ( + + {detectedPlates.map((plate) => { + const assignedTo = plateAssignments.get(plate); + const showAssignedTo = + !!assignedTo && assignedTo !== entryName; + + return ( + commit(plate)} + > + + {plate} + {showAssignedTo && ( + + {t("configForm.knownPlates.assignedTo", { + ns: "views/settings", + name: assignedTo, + })} + + )} + + ); + })} + + ) : ( + !showCustomOption && ( +
+ {t("configForm.knownPlates.noneDetected", { + ns: "views/settings", + })} +
+ ) + )} +
+
+
+
+ ); +} + export function KnownPlatesField(props: FieldProps) { const { schema, formData, onChange, idSchema, disabled, readonly } = props; const formContext = props.registry?.formContext as @@ -87,6 +278,35 @@ export function KnownPlatesField(props: FieldProps) { } }, [hasItems]); + const { data: recognizedPlates } = useSWR( + open ? ["recognized_license_plates", { split_joined: 1 }] : null, + { revalidateOnFocus: false }, + ); + + const detectedPlates = useMemo( + () => recognizedPlates ?? [], + [recognizedPlates], + ); + + const plateAssignments = useMemo(() => { + const assignments = new Map(); + 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(null); + const clearPendingOpenPlate = useCallback( + () => setPendingOpenPlate(null), + [], + ); + const handleAddEntry = useCallback(() => { const next = { ...data, "": [""] }; onChange(next, fieldPath); @@ -120,8 +340,9 @@ export function KnownPlatesField(props: FieldProps) { const handleAddPlate = useCallback( (key: string) => { - const next = { ...data, [key]: [...(data[key] || []), ""] }; - onChange(next, fieldPath); + const plates = [...(data[key] || []), ""]; + onChange({ ...data, [key]: plates }, fieldPath); + setPendingOpenPlate(`${key}::${plates.length - 1}`); }, [data, fieldPath, onChange], ); @@ -154,9 +375,6 @@ export function KnownPlatesField(props: FieldProps) { const namePlaceholder = t("configForm.knownPlates.namePlaceholder", { ns: "views/settings", }); - const platePlaceholder = t("configForm.knownPlates.platePlaceholder", { - ns: "views/settings", - }); return ( @@ -224,15 +442,20 @@ export function KnownPlatesField(props: FieldProps) {
{plates.map((plate, plateIndex) => (
- - handleUpdatePlate(key, plateIndex, e.target.value) + detectedPlates={detectedPlates} + plateAssignments={plateAssignments} + autoOpen={ + pendingOpenPlate === `${key}::${plateIndex}` + } + onAutoOpened={clearPendingOpenPlate} + onCommit={(next) => + handleUpdatePlate(key, plateIndex, next) } - className="flex-1" /> {plates.length > 1 && (