mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-25 01:28:22 +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?"
|
"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": {
|
"cameraSetting": {
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
"noCamera": "No 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 { prepareSectionSavePayload } from "@/utils/configUtil";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
||||||
|
import SaveAllPreviewPopover, {
|
||||||
|
type SaveAllPreviewItem,
|
||||||
|
} from "@/components/overlay/detail/SaveAllPreviewPopover";
|
||||||
import { useRestart } from "@/api/ws";
|
import { useRestart } from "@/api/ws";
|
||||||
|
|
||||||
const allSettingsViews = [
|
const allSettingsViews = [
|
||||||
@ -152,6 +155,42 @@ const allSettingsViews = [
|
|||||||
] as const;
|
] as const;
|
||||||
type SettingsType = (typeof allSettingsViews)[number];
|
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 = (
|
const createSectionPage = (
|
||||||
sectionKey: string,
|
sectionKey: string,
|
||||||
level: "global" | "camera",
|
level: "global" | "camera",
|
||||||
@ -620,6 +659,42 @@ export default function Settings() {
|
|||||||
const { data: fullSchema } = useSWR<RJSFSchema>("config/schema.json");
|
const { data: fullSchema } = useSWR<RJSFSchema>("config/schema.json");
|
||||||
|
|
||||||
const hasPendingChanges = Object.keys(pendingDataBySection).length > 0;
|
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
|
// Map a pendingDataKey to SettingsType menu key for clearing section status
|
||||||
const pendingKeyToMenuKey = useCallback(
|
const pendingKeyToMenuKey = useCallback(
|
||||||
@ -982,8 +1057,17 @@ export default function Settings() {
|
|||||||
{!contentMobileOpen && (
|
{!contentMobileOpen && (
|
||||||
<div className="flex size-full flex-col">
|
<div className="flex size-full flex-col">
|
||||||
<div className="sticky -top-2 z-50 mb-2 bg-background p-4">
|
<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" />
|
<Logo className="h-8" />
|
||||||
|
<div className="absolute right-0">
|
||||||
|
<CameraSelectButton
|
||||||
|
allCameras={cameras}
|
||||||
|
selectedCamera={selectedCamera}
|
||||||
|
setSelectedCamera={setSelectedCamera}
|
||||||
|
cameraEnabledStates={cameraEnabledStates}
|
||||||
|
currentPage={page}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row text-center">
|
<div className="flex flex-row text-center">
|
||||||
<h2 className="ml-2 text-lg">
|
<h2 className="ml-2 text-lg">
|
||||||
@ -1035,12 +1119,20 @@ export default function Settings() {
|
|||||||
{hasPendingChanges && (
|
{hasPendingChanges && (
|
||||||
<div className="sticky bottom-0 z-50 mt-2 bg-background p-4">
|
<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 flex-col items-center gap-2">
|
||||||
<span className="text-sm text-danger">
|
<div className="flex items-center gap-2">
|
||||||
{t("unsavedChanges", {
|
<span className="text-sm text-danger">
|
||||||
ns: "views/settings",
|
{t("unsavedChanges", {
|
||||||
defaultValue: "You have unsaved changes",
|
ns: "views/settings",
|
||||||
})}
|
defaultValue: "You have unsaved changes",
|
||||||
</span>
|
})}
|
||||||
|
</span>
|
||||||
|
<SaveAllPreviewPopover
|
||||||
|
items={pendingChangesPreview}
|
||||||
|
className="h-7 w-7"
|
||||||
|
align="center"
|
||||||
|
side="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUndoAll}
|
onClick={handleUndoAll}
|
||||||
@ -1169,7 +1261,19 @@ export default function Settings() {
|
|||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex items-center gap-5">
|
<div className="flex items-center gap-5">
|
||||||
{hasPendingChanges && (
|
{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
|
<Button
|
||||||
onClick={handleUndoAll}
|
onClick={handleUndoAll}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user