mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-04 23:14:12 +02:00
Miscellaneous fixes (#22780)
* fix mobile export crash by removing stale iOS non-modal drawer workaround * Remove titlecase to avoid Gemma4 handling plain labels as proper nouns * Improve titling: * Make directions more clear * Properly capitalize delivery services * update dispatcher config reference on save * subscribe to review topic so ReviewDescriptionProcessor knows genai is enabled * auto-send ON genai review WS message when enabled_in_config transitions to true * remove unused object level * update docs to clarify pre/post capture settings * add ui docs links * improve known_plates field in settings UI * only show save all when multiple sections are changed or if the section being changed is not currently being viewed * fix docs --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
@@ -1326,6 +1326,10 @@
|
||||
"keyPlaceholder": "New key",
|
||||
"remove": "Remove"
|
||||
},
|
||||
"knownPlates": {
|
||||
"namePlaceholder": "e.g., Wife's Car",
|
||||
"platePlaceholder": "Plate number or regex"
|
||||
},
|
||||
"timezone": {
|
||||
"defaultOption": "Use browser timezone"
|
||||
},
|
||||
|
||||
@@ -67,6 +67,13 @@ const lpr: SectionConfigOverrides = {
|
||||
format: {
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
known_plates: {
|
||||
"ui:field": "KnownPlatesField",
|
||||
"ui:options": {
|
||||
label: false,
|
||||
suppressDescription: true,
|
||||
},
|
||||
},
|
||||
replace_rules: {
|
||||
"ui:field": "ReplaceRulesField",
|
||||
"ui:options": {
|
||||
|
||||
@@ -16,6 +16,16 @@ const record: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
],
|
||||
fieldDocs: {
|
||||
"alerts.pre_capture":
|
||||
"/configuration/record#pre-capture-and-post-capture",
|
||||
"alerts.post_capture":
|
||||
"/configuration/record#pre-capture-and-post-capture",
|
||||
"detections.pre_capture":
|
||||
"/configuration/record#pre-capture-and-post-capture",
|
||||
"detections.post_capture":
|
||||
"/configuration/record#pre-capture-and-post-capture",
|
||||
},
|
||||
restartRequired: [],
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import useSWR from "swr";
|
||||
import { Trans } from "react-i18next";
|
||||
import Heading from "@/components/ui/heading";
|
||||
@@ -37,6 +37,21 @@ export default function CameraReviewStatusToggles({
|
||||
const { payload: revDescState, send: sendRevDesc } =
|
||||
useReviewDescriptionState(cameraId);
|
||||
|
||||
// Sync WS runtime state when review genai transitions from disabled to enabled in config
|
||||
const prevRevGenaiEnabled = useRef(
|
||||
cameraConfig?.review?.genai?.enabled_in_config,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const wasEnabled = prevRevGenaiEnabled.current;
|
||||
const isEnabled = cameraConfig?.review?.genai?.enabled_in_config;
|
||||
prevRevGenaiEnabled.current = isEnabled;
|
||||
|
||||
if (!wasEnabled && isEnabled) {
|
||||
sendRevDesc("ON");
|
||||
}
|
||||
}, [cameraConfig?.review?.genai?.enabled_in_config, sendRevDesc]);
|
||||
|
||||
if (!selectedCamera || !cameraConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
277
web/src/components/config-form/theme/fields/KnownPlatesField.tsx
Normal file
277
web/src/components/config-form/theme/fields/KnownPlatesField.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} 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 { cn } from "@/lib/utils";
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
LuPlus,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import get from "lodash/get";
|
||||
import { isSubtreeModified } from "../utils";
|
||||
|
||||
type KnownPlatesData = Record<string, string[]>;
|
||||
|
||||
export function KnownPlatesField(props: FieldProps) {
|
||||
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
|
||||
const data: KnownPlatesData = useMemo(() => {
|
||||
if (!formData || typeof formData !== "object" || Array.isArray(formData)) {
|
||||
return {};
|
||||
}
|
||||
return formData as KnownPlatesData;
|
||||
}, [formData]);
|
||||
|
||||
const entries = useMemo(() => Object.entries(data), [data]);
|
||||
|
||||
const title = (schema as RJSFSchema).title;
|
||||
const description = (schema as RJSFSchema).description;
|
||||
|
||||
const hasItems = entries.length > 0;
|
||||
const emptyPath = useMemo(() => [] as FieldPathList, []);
|
||||
const fieldPath =
|
||||
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
|
||||
emptyPath;
|
||||
|
||||
const isModified = useMemo(() => {
|
||||
const baselineRoot = formContext?.baselineFormData;
|
||||
const baselineValue = baselineRoot
|
||||
? get(baselineRoot, fieldPath)
|
||||
: undefined;
|
||||
return isSubtreeModified(
|
||||
data,
|
||||
baselineValue,
|
||||
formContext?.overrides,
|
||||
fieldPath,
|
||||
formContext?.formData,
|
||||
);
|
||||
}, [fieldPath, formContext, data]);
|
||||
|
||||
const [open, setOpen] = useState(hasItems || isModified);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModified) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [isModified]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasItems) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [hasItems]);
|
||||
|
||||
const handleAddEntry = useCallback(() => {
|
||||
const next = { ...data, "": [""] };
|
||||
onChange(next, fieldPath);
|
||||
}, [data, fieldPath, onChange]);
|
||||
|
||||
const handleRemoveEntry = useCallback(
|
||||
(key: string) => {
|
||||
const next = { ...data };
|
||||
delete next[key];
|
||||
onChange(next, fieldPath);
|
||||
},
|
||||
[data, fieldPath, onChange],
|
||||
);
|
||||
|
||||
const handleRenameKey = useCallback(
|
||||
(oldKey: string, newKey: string) => {
|
||||
if (oldKey === newKey) return;
|
||||
// Preserve order by rebuilding the object
|
||||
const next: KnownPlatesData = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (k === oldKey) {
|
||||
next[newKey] = v;
|
||||
} else {
|
||||
next[k] = v;
|
||||
}
|
||||
}
|
||||
onChange(next, fieldPath);
|
||||
},
|
||||
[data, fieldPath, onChange],
|
||||
);
|
||||
|
||||
const handleAddPlate = useCallback(
|
||||
(key: string) => {
|
||||
const next = { ...data, [key]: [...(data[key] || []), ""] };
|
||||
onChange(next, fieldPath);
|
||||
},
|
||||
[data, fieldPath, onChange],
|
||||
);
|
||||
|
||||
const handleRemovePlate = useCallback(
|
||||
(key: string, plateIndex: number) => {
|
||||
const plates = [...(data[key] || [])];
|
||||
plates.splice(plateIndex, 1);
|
||||
const next = { ...data, [key]: plates };
|
||||
onChange(next, fieldPath);
|
||||
},
|
||||
[data, fieldPath, onChange],
|
||||
);
|
||||
|
||||
const handleUpdatePlate = useCallback(
|
||||
(key: string, plateIndex: number, value: string) => {
|
||||
const plates = [...(data[key] || [])];
|
||||
plates[plateIndex] = value;
|
||||
const next = { ...data, [key]: plates };
|
||||
onChange(next, fieldPath);
|
||||
},
|
||||
[data, fieldPath, onChange],
|
||||
);
|
||||
|
||||
const baseId = idSchema?.$id || "known_plates";
|
||||
const deleteLabel = t("button.delete", {
|
||||
ns: "common",
|
||||
defaultValue: "Delete",
|
||||
});
|
||||
const namePlaceholder = t("configForm.knownPlates.namePlaceholder", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const platePlaceholder = t("configForm.knownPlates.platePlaceholder", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle
|
||||
className={cn("text-sm", isModified && "text-danger")}
|
||||
>
|
||||
{title}
|
||||
</CardTitle>
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{open ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
{entries.map(([key, plates], entryIndex) => {
|
||||
const entryId = `${baseId}-${entryIndex}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entryIndex}
|
||||
className="space-y-2 rounded-md border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={`${entryId}-key`}
|
||||
defaultValue={key}
|
||||
placeholder={namePlaceholder}
|
||||
disabled={disabled || readonly}
|
||||
onBlur={(e) => handleRenameKey(key, e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveEntry(key)}
|
||||
disabled={disabled || readonly}
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
className="shrink-0"
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="ml-1 space-y-2 border-l-2 border-muted-foreground/20 pl-3">
|
||||
{plates.map((plate, plateIndex) => (
|
||||
<div key={plateIndex} className="flex items-center gap-2">
|
||||
<Input
|
||||
id={`${entryId}-plate-${plateIndex}`}
|
||||
value={plate}
|
||||
placeholder={platePlaceholder}
|
||||
disabled={disabled || readonly}
|
||||
onChange={(e) =>
|
||||
handleUpdatePlate(key, plateIndex, e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
{plates.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemovePlate(key, plateIndex)}
|
||||
disabled={disabled || readonly}
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
className="shrink-0"
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAddPlate(key)}
|
||||
disabled={disabled || readonly}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("button.add", {
|
||||
ns: "common",
|
||||
defaultValue: "Add",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddEntry}
|
||||
disabled={disabled || readonly}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("button.add", { ns: "common", defaultValue: "Add" })}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default KnownPlatesField;
|
||||
@@ -49,6 +49,7 @@ import { DetectorHardwareField } from "./fields/DetectorHardwareField";
|
||||
import { ReplaceRulesField } from "./fields/ReplaceRulesField";
|
||||
import { CameraInputsField } from "./fields/CameraInputsField";
|
||||
import { DictAsYamlField } from "./fields/DictAsYamlField";
|
||||
import { KnownPlatesField } from "./fields/KnownPlatesField";
|
||||
|
||||
export interface FrigateTheme {
|
||||
widgets: RegistryWidgetsType;
|
||||
@@ -105,5 +106,6 @@ export const frigateTheme: FrigateTheme = {
|
||||
ReplaceRulesField: ReplaceRulesField,
|
||||
CameraInputsField: CameraInputsField,
|
||||
DictAsYamlField: DictAsYamlField,
|
||||
KnownPlatesField: KnownPlatesField,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
|
||||
import { toast } from "sonner";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import SaveExportOverlay from "./SaveExportOverlay";
|
||||
import { isIOS, isMobile } from "react-device-detect";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -505,7 +505,6 @@ export default function MobileReviewSettingsDrawer({
|
||||
setShowPreview={setShowExportPreview}
|
||||
/>
|
||||
<Drawer
|
||||
modal={!(isIOS && drawerMode == "export")}
|
||||
open={drawerMode != "none"}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
|
||||
@@ -818,6 +818,27 @@ export default function Settings() {
|
||||
[],
|
||||
);
|
||||
|
||||
// Show save/undo all buttons only when changes span multiple sections
|
||||
// or the single changed section is not the one currently being viewed
|
||||
const showSaveAllButtons = useMemo(() => {
|
||||
const pendingKeys = Object.keys(pendingDataBySection);
|
||||
if (pendingKeys.length === 0) return false;
|
||||
if (pendingKeys.length >= 2) return true;
|
||||
|
||||
// Exactly one pending section — check if it matches the current view
|
||||
const key = pendingKeys[0];
|
||||
const menuKey = pendingKeyToMenuKey(key);
|
||||
if (menuKey !== pageToggle) return true;
|
||||
|
||||
// For camera-scoped keys, also check if the camera matches
|
||||
if (key.includes("::")) {
|
||||
const cameraName = key.slice(0, key.indexOf("::"));
|
||||
return cameraName !== selectedCamera;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [pendingDataBySection, pendingKeyToMenuKey, pageToggle, selectedCamera]);
|
||||
|
||||
const handleSaveAll = useCallback(async () => {
|
||||
if (
|
||||
!config ||
|
||||
@@ -1491,7 +1512,7 @@ export default function Settings() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasPendingChanges && (
|
||||
{showSaveAllButtons && (
|
||||
<div className="sticky bottom-0 z-50 mt-2 bg-background p-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1667,7 +1688,7 @@ export default function Settings() {
|
||||
</Heading>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasPendingChanges && (
|
||||
{showSaveAllButtons && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row items-center gap-2",
|
||||
|
||||
Reference in New Issue
Block a user