mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
add popover to show what fields will be saved
This commit is contained in:
parent
7d92fade91
commit
4dd12ac934
@ -91,6 +91,23 @@
|
||||
"desc": "Do you want to save your changes before continuing?"
|
||||
}
|
||||
},
|
||||
"saveAllPreview": {
|
||||
"title": "Changes to be saved",
|
||||
"triggerLabel": "Review pending changes",
|
||||
"empty": "No pending changes.",
|
||||
"scope": {
|
||||
"label": "Scope",
|
||||
"global": "Global",
|
||||
"camera": "Camera: {{cameraName}}"
|
||||
},
|
||||
"field": {
|
||||
"label": "Field"
|
||||
},
|
||||
"value": {
|
||||
"label": "New value",
|
||||
"reset": "Reset"
|
||||
}
|
||||
},
|
||||
"cameraSetting": {
|
||||
"camera": "Camera",
|
||||
"noCamera": "No Camera"
|
||||
|
||||
142
web/src/components/overlay/detail/SaveAllPreviewPopover.tsx
Normal file
142
web/src/components/overlay/detail/SaveAllPreviewPopover.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuInfo, LuX } from "react-icons/lu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SaveAllPreviewItem = {
|
||||
scope: "global" | "camera";
|
||||
cameraName?: string;
|
||||
fieldPath: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
type SaveAllPreviewPopoverProps = {
|
||||
items: SaveAllPreviewItem[];
|
||||
className?: string;
|
||||
align?: "start" | "center" | "end";
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
};
|
||||
|
||||
export default function SaveAllPreviewPopover({
|
||||
items,
|
||||
className,
|
||||
align = "end",
|
||||
side = "bottom",
|
||||
}: SaveAllPreviewPopoverProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const resetLabel = t("saveAllPreview.value.reset", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
|
||||
const formatValue = useCallback(
|
||||
(value: unknown) => {
|
||||
if (value === "") return resetLabel;
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
},
|
||||
[resetLabel],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-8", className)}
|
||||
aria-label={t("saveAllPreview.triggerLabel", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
>
|
||||
<LuInfo className="size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align={align}
|
||||
side={side}
|
||||
className="w-[90vw] max-w-sm border bg-background p-4 shadow-lg"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-primary-variant">
|
||||
{t("saveAllPreview.title", { ns: "views/settings" })}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label={t("button.close", { ns: "common" })}
|
||||
>
|
||||
<LuX className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
{t("saveAllPreview.empty", { ns: "views/settings" })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="scrollbar-container mt-3 flex max-h-72 flex-col gap-2 overflow-y-auto pr-1">
|
||||
{items.map((item) => {
|
||||
const scopeLabel =
|
||||
item.scope === "global"
|
||||
? t("saveAllPreview.scope.global", {
|
||||
ns: "views/settings",
|
||||
})
|
||||
: t("saveAllPreview.scope.camera", {
|
||||
ns: "views/settings",
|
||||
cameraName: item.cameraName,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={`${item.scope}-${item.cameraName ?? "global"}-${
|
||||
item.fieldPath
|
||||
}`}
|
||||
className="rounded-md border border-secondary bg-background_alt p-2"
|
||||
>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("saveAllPreview.scope.label", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</span>
|
||||
<span className="truncate">{scopeLabel}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("saveAllPreview.field.label", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</span>
|
||||
<span className="font-mono break-words">
|
||||
{item.fieldPath}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("saveAllPreview.value.label", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</span>
|
||||
<span className="font-mono whitespace-pre-wrap break-words">
|
||||
{formatValue(item.value)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@ -87,6 +87,9 @@ import { RJSFSchema } from "@rjsf/utils";
|
||||
import { prepareSectionSavePayload } from "@/utils/configUtil";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
||||
import SaveAllPreviewPopover, {
|
||||
type SaveAllPreviewItem,
|
||||
} from "@/components/overlay/detail/SaveAllPreviewPopover";
|
||||
import { useRestart } from "@/api/ws";
|
||||
|
||||
const allSettingsViews = [
|
||||
@ -152,6 +155,42 @@ const allSettingsViews = [
|
||||
] as const;
|
||||
type SettingsType = (typeof allSettingsViews)[number];
|
||||
|
||||
const parsePendingDataKey = (pendingDataKey: string) => {
|
||||
if (pendingDataKey.includes("::")) {
|
||||
const idx = pendingDataKey.indexOf("::");
|
||||
return {
|
||||
scope: "camera" as const,
|
||||
cameraName: pendingDataKey.slice(0, idx),
|
||||
sectionPath: pendingDataKey.slice(idx + 2),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scope: "global" as const,
|
||||
cameraName: undefined,
|
||||
sectionPath: pendingDataKey,
|
||||
};
|
||||
};
|
||||
|
||||
const flattenOverrides = (
|
||||
value: unknown,
|
||||
path: string[] = [],
|
||||
): Array<{ path: string; value: unknown }> => {
|
||||
if (value === undefined) return [];
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return [{ path: path.join("."), value }];
|
||||
}
|
||||
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) {
|
||||
return [{ path: path.join("."), value: {} }];
|
||||
}
|
||||
|
||||
return entries.flatMap(([key, entryValue]) =>
|
||||
flattenOverrides(entryValue, [...path, key]),
|
||||
);
|
||||
};
|
||||
|
||||
const createSectionPage = (
|
||||
sectionKey: string,
|
||||
level: "global" | "camera",
|
||||
@ -620,6 +659,42 @@ export default function Settings() {
|
||||
const { data: fullSchema } = useSWR<RJSFSchema>("config/schema.json");
|
||||
|
||||
const hasPendingChanges = Object.keys(pendingDataBySection).length > 0;
|
||||
const pendingChangesPreview = useMemo<SaveAllPreviewItem[]>(() => {
|
||||
if (!config || !fullSchema) return [];
|
||||
|
||||
const items: SaveAllPreviewItem[] = [];
|
||||
Object.entries(pendingDataBySection).forEach(
|
||||
([pendingDataKey, pendingData]) => {
|
||||
const payload = prepareSectionSavePayload({
|
||||
pendingDataKey,
|
||||
pendingData,
|
||||
config,
|
||||
fullSchema,
|
||||
});
|
||||
|
||||
if (!payload) return;
|
||||
|
||||
const { scope, cameraName, sectionPath } =
|
||||
parsePendingDataKey(pendingDataKey);
|
||||
const flattened = flattenOverrides(payload.sanitizedOverrides);
|
||||
|
||||
flattened.forEach(({ path, value }) => {
|
||||
const fieldPath = path ? `${sectionPath}.${path}` : sectionPath;
|
||||
items.push({ scope, cameraName, fieldPath, value });
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return items.sort((left, right) => {
|
||||
const scopeCompare = left.scope.localeCompare(right.scope);
|
||||
if (scopeCompare !== 0) return scopeCompare;
|
||||
const cameraCompare = (left.cameraName ?? "").localeCompare(
|
||||
right.cameraName ?? "",
|
||||
);
|
||||
if (cameraCompare !== 0) return cameraCompare;
|
||||
return left.fieldPath.localeCompare(right.fieldPath);
|
||||
});
|
||||
}, [config, fullSchema, pendingDataBySection]);
|
||||
|
||||
// Map a pendingDataKey to SettingsType menu key for clearing section status
|
||||
const pendingKeyToMenuKey = useCallback(
|
||||
@ -982,8 +1057,17 @@ export default function Settings() {
|
||||
{!contentMobileOpen && (
|
||||
<div className="flex size-full flex-col">
|
||||
<div className="sticky -top-2 z-50 mb-2 bg-background p-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="relative flex w-full items-center justify-center">
|
||||
<Logo className="h-8" />
|
||||
<div className="absolute right-0">
|
||||
<CameraSelectButton
|
||||
allCameras={cameras}
|
||||
selectedCamera={selectedCamera}
|
||||
setSelectedCamera={setSelectedCamera}
|
||||
cameraEnabledStates={cameraEnabledStates}
|
||||
currentPage={page}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row text-center">
|
||||
<h2 className="ml-2 text-lg">
|
||||
@ -1035,12 +1119,20 @@ export default function Settings() {
|
||||
{hasPendingChanges && (
|
||||
<div className="sticky bottom-0 z-50 mt-2 bg-background p-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-sm text-danger">
|
||||
{t("unsavedChanges", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "You have unsaved changes",
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">
|
||||
{t("unsavedChanges", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "You have unsaved changes",
|
||||
})}
|
||||
</span>
|
||||
<SaveAllPreviewPopover
|
||||
items={pendingChangesPreview}
|
||||
className="h-7 w-7"
|
||||
align="center"
|
||||
side="top"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleUndoAll}
|
||||
@ -1169,7 +1261,19 @@ export default function Settings() {
|
||||
</Heading>
|
||||
<div className="flex items-center gap-5">
|
||||
{hasPendingChanges && (
|
||||
<div className="flex flex-row gap-2 border-r border-secondary pr-5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row items-center gap-2",
|
||||
CAMERA_SELECT_BUTTON_PAGES.includes(page) &&
|
||||
"border-r border-secondary pr-5",
|
||||
)}
|
||||
>
|
||||
<SaveAllPreviewPopover
|
||||
items={pendingChangesPreview}
|
||||
className="size-8"
|
||||
align="end"
|
||||
side="bottom"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleUndoAll}
|
||||
variant="outline"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user