mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-17 05:38:25 +03:00
mark pending changes and add confirmation dialog for resets
This commit is contained in:
parent
95a0530ce6
commit
b9149b6366
@ -117,6 +117,7 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
|
"undo": "Undo",
|
||||||
"done": "Done",
|
"done": "Done",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
|
|||||||
@ -1290,5 +1290,8 @@
|
|||||||
"resetSuccess": "Reset to global defaults",
|
"resetSuccess": "Reset to global defaults",
|
||||||
"resetError": "Failed to reset settings"
|
"resetError": "Failed to reset settings"
|
||||||
},
|
},
|
||||||
"unsavedChanges": "You have unsaved changes"
|
"unsavedChanges": "You have unsaved changes",
|
||||||
|
"confirmReset": "Confirm Reset",
|
||||||
|
"resetToDefaultDescription": "This will reset all settings in this section to their default values. This action cannot be undone.",
|
||||||
|
"resetToGlobalDescription": "This will reset the settings in this section to the global defaults. This action cannot be undone."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,16 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||||
import { isJsonObject } from "@/lib/utils";
|
import { isJsonObject } from "@/lib/utils";
|
||||||
import { ConfigSectionData, JsonObject, JsonValue } from "@/types/configForm";
|
import { ConfigSectionData, JsonObject, JsonValue } from "@/types/configForm";
|
||||||
@ -92,6 +102,14 @@ export interface BaseSectionProps {
|
|||||||
hasChanges: boolean;
|
hasChanges: boolean;
|
||||||
isOverridden: boolean;
|
isOverridden: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
/** Pending form data keyed by "sectionKey" or "cameraName::sectionKey" */
|
||||||
|
pendingDataBySection?: Record<string, unknown>;
|
||||||
|
/** Callback to update pending data for a section */
|
||||||
|
onPendingDataChange?: (
|
||||||
|
sectionKey: string,
|
||||||
|
cameraName: string | undefined,
|
||||||
|
data: ConfigSectionData | null,
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateSectionOptions {
|
export interface CreateSectionOptions {
|
||||||
@ -140,6 +158,8 @@ export function ConfigSection({
|
|||||||
defaultCollapsed = false,
|
defaultCollapsed = false,
|
||||||
showTitle,
|
showTitle,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
|
pendingDataBySection,
|
||||||
|
onPendingDataChange,
|
||||||
}: ConfigSectionProps) {
|
}: ConfigSectionProps) {
|
||||||
const { t, i18n } = useTranslation([
|
const { t, i18n } = useTranslation([
|
||||||
level === "camera" ? "config/cameras" : "config/global",
|
level === "camera" ? "config/cameras" : "config/global",
|
||||||
@ -148,12 +168,40 @@ export function ConfigSection({
|
|||||||
"common",
|
"common",
|
||||||
]);
|
]);
|
||||||
const [isOpen, setIsOpen] = useState(!defaultCollapsed);
|
const [isOpen, setIsOpen] = useState(!defaultCollapsed);
|
||||||
const [pendingData, setPendingData] = useState<ConfigSectionData | null>(
|
|
||||||
null,
|
// Create a key for this section's pending data
|
||||||
|
const pendingDataKey = useMemo(
|
||||||
|
() =>
|
||||||
|
level === "camera" && cameraName
|
||||||
|
? `${cameraName}::${sectionPath}`
|
||||||
|
: sectionPath,
|
||||||
|
[level, cameraName, sectionPath],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use pending data from parent if available, otherwise use local state
|
||||||
|
const [localPendingData, setLocalPendingData] =
|
||||||
|
useState<ConfigSectionData | null>(null);
|
||||||
|
|
||||||
|
const pendingData =
|
||||||
|
pendingDataBySection !== undefined
|
||||||
|
? (pendingDataBySection[pendingDataKey] as ConfigSectionData | null)
|
||||||
|
: localPendingData;
|
||||||
|
|
||||||
|
const setPendingData = useCallback(
|
||||||
|
(data: ConfigSectionData | null) => {
|
||||||
|
if (onPendingDataChange) {
|
||||||
|
onPendingDataChange(sectionPath, cameraName, data);
|
||||||
|
} else {
|
||||||
|
setLocalPendingData(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onPendingDataChange, sectionPath, cameraName],
|
||||||
);
|
);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [formKey, setFormKey] = useState(0);
|
const [formKey, setFormKey] = useState(0);
|
||||||
|
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
|
||||||
const isResettingRef = useRef(false);
|
const isResettingRef = useRef(false);
|
||||||
|
const isInitializingRef = useRef(true);
|
||||||
|
|
||||||
const updateTopic =
|
const updateTopic =
|
||||||
level === "camera" && cameraName
|
level === "camera" && cameraName
|
||||||
@ -226,9 +274,15 @@ export function ConfigSection({
|
|||||||
|
|
||||||
// Clear pendingData whenever formData changes (e.g., from server refresh)
|
// Clear pendingData whenever formData changes (e.g., from server refresh)
|
||||||
// This prevents RJSF's initial onChange call from being treated as a user edit
|
// This prevents RJSF's initial onChange call from being treated as a user edit
|
||||||
|
// Only clear if pendingData is managed locally (not by parent)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPendingData(null);
|
if (!pendingData) {
|
||||||
}, [formData]);
|
isInitializingRef.current = true;
|
||||||
|
}
|
||||||
|
if (onPendingDataChange === undefined) {
|
||||||
|
setPendingData(null);
|
||||||
|
}
|
||||||
|
}, [formData, pendingData, setPendingData, onPendingDataChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isResettingRef.current) {
|
if (isResettingRef.current) {
|
||||||
@ -323,20 +377,36 @@ export function ConfigSection({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
|
const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
|
||||||
if (isEqual(formData, sanitizedData)) {
|
const rawData = sanitizeSectionData(rawFormData as ConfigSectionData);
|
||||||
|
const overrides = buildOverrides(sanitizedData, rawData, schemaDefaults);
|
||||||
|
if (isInitializingRef.current && !pendingData) {
|
||||||
|
isInitializingRef.current = false;
|
||||||
|
if (overrides === undefined) {
|
||||||
|
setPendingData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (overrides === undefined) {
|
||||||
setPendingData(null);
|
setPendingData(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPendingData(sanitizedData);
|
setPendingData(sanitizedData);
|
||||||
},
|
},
|
||||||
[formData, sanitizeSectionData],
|
[
|
||||||
|
pendingData,
|
||||||
|
rawFormData,
|
||||||
|
sanitizeSectionData,
|
||||||
|
buildOverrides,
|
||||||
|
schemaDefaults,
|
||||||
|
setPendingData,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
isResettingRef.current = true;
|
isResettingRef.current = true;
|
||||||
setPendingData(null);
|
setPendingData(null);
|
||||||
setFormKey((prev) => prev + 1);
|
setFormKey((prev) => prev + 1);
|
||||||
}, []);
|
}, [setPendingData]);
|
||||||
|
|
||||||
// Handle save button click
|
// Handle save button click
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
@ -433,6 +503,7 @@ export function ConfigSection({
|
|||||||
buildOverrides,
|
buildOverrides,
|
||||||
schemaDefaults,
|
schemaDefaults,
|
||||||
updateTopic,
|
updateTopic,
|
||||||
|
setPendingData,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
|
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
|
||||||
@ -496,6 +567,7 @@ export function ConfigSection({
|
|||||||
t,
|
t,
|
||||||
refreshConfig,
|
refreshConfig,
|
||||||
updateTopic,
|
updateTopic,
|
||||||
|
setPendingData,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sectionValidation = useMemo(
|
const sectionValidation = useMemo(
|
||||||
@ -597,7 +669,7 @@ export function ConfigSection({
|
|||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-danger">
|
||||||
{t("unsavedChanges", {
|
{t("unsavedChanges", {
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
defaultValue: "You have unsaved changes",
|
defaultValue: "You have unsaved changes",
|
||||||
@ -606,6 +678,26 @@ export function ConfigSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{((level === "camera" && isOverridden) || level === "global") &&
|
||||||
|
!hasChanges && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsResetDialogOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
disabled={isSaving || disabled}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<LuRotateCcw className="h-4 w-4" />
|
||||||
|
{level === "global"
|
||||||
|
? t("button.resetToDefault", {
|
||||||
|
ns: "common",
|
||||||
|
defaultValue: "Reset to Default",
|
||||||
|
})
|
||||||
|
: t("button.resetToGlobal", {
|
||||||
|
ns: "common",
|
||||||
|
defaultValue: "Reset to Global",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
@ -613,7 +705,7 @@ export function ConfigSection({
|
|||||||
disabled={isSaving || disabled}
|
disabled={isSaving || disabled}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
{t("reset", { ns: "common", defaultValue: "Reset" })}
|
{t("undo", { ns: "common", defaultValue: "Undo" })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@ -629,6 +721,37 @@ export function ConfigSection({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={isResetDialogOpen} onOpenChange={setIsResetDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("confirmReset", { ns: "views/settings" })}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{level === "global"
|
||||||
|
? t("resetToDefaultDescription", { ns: "views/settings" })
|
||||||
|
: t("resetToGlobalDescription", { ns: "views/settings" })}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t("cancel", { ns: "common" })}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-selected text-white hover:bg-selected/90"
|
||||||
|
onClick={async () => {
|
||||||
|
await handleResetToGlobal();
|
||||||
|
setIsResetDialogOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{level === "global"
|
||||||
|
? t("button.resetToDefault", { ns: "common" })
|
||||||
|
: t("button.resetToGlobal", { ns: "common" })}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -664,28 +787,6 @@ export function ConfigSection({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{((level === "camera" && isOverridden) || level === "global") && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleResetToGlobal();
|
|
||||||
}}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<LuRotateCcw className="h-4 w-4" />
|
|
||||||
{level === "global"
|
|
||||||
? t("button.resetToDefault", {
|
|
||||||
ns: "common",
|
|
||||||
defaultValue: "Reset to Default",
|
|
||||||
})
|
|
||||||
: t("button.resetToGlobal", {
|
|
||||||
ns: "common",
|
|
||||||
defaultValue: "Reset to Global",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
@ -724,52 +825,9 @@ export function ConfigSection({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{((level === "camera" && isOverridden) || level === "global") && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleResetToGlobal}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<LuRotateCcw className="h-4 w-4" />
|
|
||||||
{level === "global"
|
|
||||||
? t("button.resetToDefault", {
|
|
||||||
ns: "common",
|
|
||||||
defaultValue: "Reset to Default",
|
|
||||||
})
|
|
||||||
: t("button.resetToGlobal", {
|
|
||||||
ns: "common",
|
|
||||||
defaultValue: "Reset to Global",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reset button when title is hidden but we're at camera level with override */}
|
|
||||||
{!shouldShowTitle &&
|
|
||||||
((level === "camera" && isOverridden) || level === "global") && (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleResetToGlobal}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<LuRotateCcw className="h-4 w-4" />
|
|
||||||
{level === "global"
|
|
||||||
? t("button.resetToDefault", {
|
|
||||||
ns: "common",
|
|
||||||
defaultValue: "Reset to Default",
|
|
||||||
})
|
|
||||||
: t("button.resetToGlobal", {
|
|
||||||
ns: "common",
|
|
||||||
defaultValue: "Reset to Global",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sectionContent}
|
{sectionContent}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import useOptimisticState from "@/hooks/use-optimistic-state";
|
|||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
import { FaVideo } from "react-icons/fa";
|
import { FaVideo } from "react-icons/fa";
|
||||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import type { ConfigSectionData } from "@/types/configForm";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||||
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
||||||
@ -539,6 +540,11 @@ export default function Settings() {
|
|||||||
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
||||||
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Store pending form data keyed by "sectionKey" or "cameraName::sectionKey"
|
||||||
|
const [pendingDataBySection, setPendingDataBySection] = useState<
|
||||||
|
Record<string, unknown>
|
||||||
|
>({});
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const cameras = useMemo(() => {
|
const cameras = useMemo(() => {
|
||||||
@ -666,6 +672,30 @@ export default function Settings() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handlePendingDataChange = useCallback(
|
||||||
|
(
|
||||||
|
sectionKey: string,
|
||||||
|
cameraName: string | undefined,
|
||||||
|
data: ConfigSectionData | null,
|
||||||
|
) => {
|
||||||
|
const pendingDataKey = cameraName
|
||||||
|
? `${cameraName}::${sectionKey}`
|
||||||
|
: sectionKey;
|
||||||
|
|
||||||
|
setPendingDataBySection((prev) => {
|
||||||
|
if (data === null) {
|
||||||
|
const { [pendingDataKey]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[pendingDataKey]: data,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize override status for all camera sections
|
// Initialize override status for all camera sections
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCamera || !cameraOverrides) return;
|
if (!selectedCamera || !cameraOverrides) return;
|
||||||
@ -701,8 +731,7 @@ export default function Settings() {
|
|||||||
const status = sectionStatusByKey[key];
|
const status = sectionStatusByKey[key];
|
||||||
const showOverrideDot =
|
const showOverrideDot =
|
||||||
CAMERA_SECTION_KEYS.has(key) && status?.isOverridden;
|
CAMERA_SECTION_KEYS.has(key) && status?.isOverridden;
|
||||||
// const showUnsavedDot = status?.hasChanges;
|
const showUnsavedDot = status?.hasChanges;
|
||||||
const showUnsavedDot = false; // Disable unsaved changes indicator for now
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-between pr-4 md:pr-0">
|
<div className="flex w-full items-center justify-between pr-4 md:pr-0">
|
||||||
@ -824,6 +853,8 @@ export default function Settings() {
|
|||||||
setUnsavedChanges={setUnsavedChanges}
|
setUnsavedChanges={setUnsavedChanges}
|
||||||
selectedZoneMask={filterZoneMask}
|
selectedZoneMask={filterZoneMask}
|
||||||
onSectionStatusChange={handleSectionStatusChange}
|
onSectionStatusChange={handleSectionStatusChange}
|
||||||
|
pendingDataBySection={pendingDataBySection}
|
||||||
|
onPendingDataChange={handlePendingDataChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@ -983,6 +1014,8 @@ export default function Settings() {
|
|||||||
setUnsavedChanges={setUnsavedChanges}
|
setUnsavedChanges={setUnsavedChanges}
|
||||||
selectedZoneMask={filterZoneMask}
|
selectedZoneMask={filterZoneMask}
|
||||||
onSectionStatusChange={handleSectionStatusChange}
|
onSectionStatusChange={handleSectionStatusChange}
|
||||||
|
pendingDataBySection={pendingDataBySection}
|
||||||
|
onPendingDataChange={handlePendingDataChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Heading from "@/components/ui/heading";
|
|
||||||
import type { SectionConfig } from "@/components/config-form/sections";
|
import type { SectionConfig } from "@/components/config-form/sections";
|
||||||
import { ConfigSectionTemplate } from "@/components/config-form/sections";
|
import { ConfigSectionTemplate } from "@/components/config-form/sections";
|
||||||
import type { PolygonType } from "@/types/canvas";
|
import type { PolygonType } from "@/types/canvas";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { ConfigSectionData } from "@/types/configForm";
|
||||||
|
|
||||||
export type SettingsPageProps = {
|
export type SettingsPageProps = {
|
||||||
selectedCamera?: string;
|
selectedCamera?: string;
|
||||||
@ -15,6 +15,12 @@ export type SettingsPageProps = {
|
|||||||
level: "global" | "camera",
|
level: "global" | "camera",
|
||||||
status: SectionStatus,
|
status: SectionStatus,
|
||||||
) => void;
|
) => void;
|
||||||
|
pendingDataBySection?: Record<string, unknown>;
|
||||||
|
onPendingDataChange?: (
|
||||||
|
sectionKey: string,
|
||||||
|
cameraName: string | undefined,
|
||||||
|
data: ConfigSectionData | null,
|
||||||
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SectionStatus = {
|
export type SectionStatus = {
|
||||||
@ -41,7 +47,8 @@ export function SingleSectionPage({
|
|||||||
showOverrideIndicator = true,
|
showOverrideIndicator = true,
|
||||||
selectedCamera,
|
selectedCamera,
|
||||||
setUnsavedChanges,
|
setUnsavedChanges,
|
||||||
onSectionStatusChange,
|
pendingDataBySection,
|
||||||
|
onPendingDataChange,
|
||||||
}: SingleSectionPageProps) {
|
}: SingleSectionPageProps) {
|
||||||
const sectionNamespace =
|
const sectionNamespace =
|
||||||
level === "camera" ? "config/cameras" : "config/global";
|
level === "camera" ? "config/cameras" : "config/global";
|
||||||
@ -55,10 +62,6 @@ export function SingleSectionPage({
|
|||||||
isOverridden: false,
|
isOverridden: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onSectionStatusChange?.(sectionKey, level, sectionStatus);
|
|
||||||
}, [onSectionStatusChange, sectionKey, level, sectionStatus]);
|
|
||||||
|
|
||||||
if (level === "camera" && !selectedCamera) {
|
if (level === "camera" && !selectedCamera) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
@ -68,32 +71,44 @@ export function SingleSectionPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col pr-2">
|
<div className="flex size-full flex-col lg:pr-2">
|
||||||
<div className="space-y-4">
|
<div className="mb-5 flex items-center justify-between gap-4">
|
||||||
<Heading as="h3">
|
<div className="flex flex-col">
|
||||||
{t(`${sectionKey}.label`, { ns: sectionNamespace })}
|
<div className="text-xl">
|
||||||
</Heading>
|
{t(`${sectionKey}.label`, { ns: sectionNamespace })}
|
||||||
|
</div>
|
||||||
{i18n.exists(`${sectionKey}.description`, {
|
{i18n.exists(`${sectionKey}.description`, {
|
||||||
ns: sectionNamespace,
|
ns: sectionNamespace,
|
||||||
}) && (
|
}) && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="my-1 text-sm text-muted-foreground">
|
||||||
{t(`${sectionKey}.description`, { ns: sectionNamespace })}
|
{t(`${sectionKey}.description`, { ns: sectionNamespace })}
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
</div>
|
||||||
{level === "camera" &&
|
<div className="flex flex-col items-end gap-2 md:flex-row md:items-center">
|
||||||
showOverrideIndicator &&
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
sectionStatus.isOverridden && (
|
{level === "camera" &&
|
||||||
<Badge variant="secondary" className="text-xs">
|
showOverrideIndicator &&
|
||||||
{t("overridden", { ns: "common", defaultValue: "Overridden" })}
|
sectionStatus.isOverridden && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="border-2 border-selected text-xs text-primary-variant"
|
||||||
|
>
|
||||||
|
{t("overridden", {
|
||||||
|
ns: "common",
|
||||||
|
defaultValue: "Overridden",
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{sectionStatus.hasChanges && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-danger text-xs text-white"
|
||||||
|
>
|
||||||
|
{t("modified", { ns: "common", defaultValue: "Modified" })}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{sectionStatus.hasChanges && (
|
</div>
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{t("modified", { ns: "common", defaultValue: "Modified" })}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ConfigSectionTemplate
|
<ConfigSectionTemplate
|
||||||
@ -104,6 +119,8 @@ export function SingleSectionPage({
|
|||||||
onSave={() => setUnsavedChanges?.(false)}
|
onSave={() => setUnsavedChanges?.(false)}
|
||||||
showTitle={false}
|
showTitle={false}
|
||||||
sectionConfig={sectionConfig}
|
sectionConfig={sectionConfig}
|
||||||
|
pendingDataBySection={pendingDataBySection}
|
||||||
|
onPendingDataChange={onPendingDataChange}
|
||||||
requiresRestart={requiresRestart}
|
requiresRestart={requiresRestart}
|
||||||
onStatusChange={setSectionStatus}
|
onStatusChange={setSectionStatus}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user