mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
re-add override and modified indicators
This commit is contained in:
parent
f8eee8ed0b
commit
95a0530ce6
@ -87,6 +87,11 @@ export interface BaseSectionProps {
|
||||
defaultCollapsed?: boolean;
|
||||
/** Whether to show the section title (default: false for global, true for camera) */
|
||||
showTitle?: boolean;
|
||||
/** Callback when section status changes */
|
||||
onStatusChange?: (status: {
|
||||
hasChanges: boolean;
|
||||
isOverridden: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface CreateSectionOptions {
|
||||
@ -134,6 +139,7 @@ export function ConfigSection({
|
||||
collapsible = false,
|
||||
defaultCollapsed = false,
|
||||
showTitle,
|
||||
onStatusChange,
|
||||
}: ConfigSectionProps) {
|
||||
const { t, i18n } = useTranslation([
|
||||
level === "camera" ? "config/cameras" : "config/global",
|
||||
@ -301,6 +307,10 @@ export function ConfigSection({
|
||||
return !isEqual(formData, pendingData);
|
||||
}, [formData, pendingData]);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChange?.({ hasChanges, isOverridden });
|
||||
}, [hasChanges, isOverridden, onStatusChange]);
|
||||
|
||||
// Handle form data change
|
||||
const handleChange = useCallback(
|
||||
(data: unknown) => {
|
||||
@ -346,14 +356,15 @@ export function ConfigSection({
|
||||
return;
|
||||
}
|
||||
|
||||
// await axios.put("config/set", {
|
||||
// requires_restart: requiresRestart ? 0 : 1,
|
||||
// update_topic: updateTopic,
|
||||
// config_data: {
|
||||
// [basePath]: overrides,
|
||||
// },
|
||||
// });
|
||||
await axios.put("config/sett", {
|
||||
requires_restart: requiresRestart ? 0 : 1,
|
||||
update_topic: updateTopic,
|
||||
config_data: {
|
||||
[basePath]: overrides,
|
||||
},
|
||||
});
|
||||
// log save to console for debugging
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Saved config data:", {
|
||||
[basePath]: overrides,
|
||||
update_topic: updateTopic,
|
||||
@ -432,17 +443,20 @@ export function ConfigSection({
|
||||
const basePath =
|
||||
level === "camera" && cameraName
|
||||
? `cameras.${cameraName}.${sectionPath}`
|
||||
: sectionPath; // const configData = level === "global" ? schemaDefaults : "";
|
||||
: sectionPath;
|
||||
|
||||
// await axios.put("config/set", {
|
||||
// requires_restart: requiresRestart ? 0 : 1,
|
||||
// update_topic: updateTopic,
|
||||
// config_data: {
|
||||
// [basePath]: configData,
|
||||
// },
|
||||
// });
|
||||
const configData = level === "global" ? schemaDefaults : "";
|
||||
|
||||
await axios.put("config/sett", {
|
||||
requires_restart: requiresRestart ? 0 : 1,
|
||||
update_topic: updateTopic,
|
||||
config_data: {
|
||||
[basePath]: configData,
|
||||
},
|
||||
});
|
||||
|
||||
// log reset to console for debugging
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
level === "global"
|
||||
? "Reset to defaults for path:"
|
||||
@ -474,6 +488,7 @@ export function ConfigSection({
|
||||
);
|
||||
}
|
||||
}, [
|
||||
schemaDefaults,
|
||||
sectionPath,
|
||||
level,
|
||||
cameraName,
|
||||
|
||||
@ -17,7 +17,13 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { FaVideo } from "react-icons/fa";
|
||||
@ -39,12 +45,14 @@ import MaintenanceSettingsView from "@/views/settings/MaintenanceSettingsView";
|
||||
import {
|
||||
SingleSectionPage,
|
||||
type SettingsPageProps,
|
||||
type SectionStatus,
|
||||
} from "@/views/settings/SingleSectionPage";
|
||||
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useInitialCameraState } from "@/api/ws";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAllCameraOverrides } from "@/hooks/use-config-override";
|
||||
import TriggerView from "@/views/settings/TriggerView";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import {
|
||||
@ -420,6 +428,45 @@ const CAMERA_SELECT_BUTTON_PAGES = [
|
||||
|
||||
const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"];
|
||||
|
||||
// keys for camera sections
|
||||
const CAMERA_SECTION_MAPPING: Record<string, SettingsType> = {
|
||||
detect: "cameraDetect",
|
||||
ffmpeg: "cameraFfmpeg",
|
||||
record: "cameraRecording",
|
||||
snapshots: "cameraSnapshots",
|
||||
motion: "cameraMotion",
|
||||
objects: "cameraObjects",
|
||||
review: "cameraConfigReview",
|
||||
audio: "cameraAudioEvents",
|
||||
audio_transcription: "cameraAudioTranscription",
|
||||
notifications: "cameraNotifications",
|
||||
live: "cameraLivePlayback",
|
||||
birdseye: "cameraBirdseye",
|
||||
face_recognition: "cameraFaceRecognition",
|
||||
lpr: "cameraLpr",
|
||||
mqtt: "cameraMqttConfig",
|
||||
onvif: "cameraOnvif",
|
||||
ui: "cameraUi",
|
||||
timestamp_style: "cameraTimestampStyle",
|
||||
};
|
||||
|
||||
// keys for global sections
|
||||
const GLOBAL_SECTION_MAPPING: Record<string, SettingsType> = {
|
||||
detect: "globalDetect",
|
||||
record: "globalRecording",
|
||||
snapshots: "globalSnapshots",
|
||||
motion: "globalMotion",
|
||||
objects: "globalObjects",
|
||||
review: "globalReview",
|
||||
audio: "globalAudioEvents",
|
||||
live: "globalLivePlayback",
|
||||
timestamp_style: "globalTimestampStyle",
|
||||
};
|
||||
|
||||
const CAMERA_SECTION_KEYS = new Set<SettingsType>(
|
||||
Object.values(CAMERA_SECTION_MAPPING),
|
||||
);
|
||||
|
||||
const getCurrentComponent = (page: SettingsType) => {
|
||||
for (const group of settingsGroups) {
|
||||
for (const item of group.items) {
|
||||
@ -436,11 +483,13 @@ function MobileMenuItem({
|
||||
onSelect,
|
||||
onClose,
|
||||
className,
|
||||
label,
|
||||
}: {
|
||||
item: { key: string };
|
||||
onSelect: (key: string) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
label?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
@ -455,7 +504,11 @@ function MobileMenuItem({
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
<div className="smart-capitalize">{t("menu." + item.key)}</div>
|
||||
<div className="w-full">
|
||||
{label ?? (
|
||||
<div className="smart-capitalize">{t("menu." + item.key)}</div>
|
||||
)}
|
||||
</div>
|
||||
<LuChevronRight className="size-4" />
|
||||
</div>
|
||||
);
|
||||
@ -466,6 +519,9 @@ export default function Settings() {
|
||||
const [page, setPage] = useState<SettingsType>("profileSettings");
|
||||
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
||||
const [contentMobileOpen, setContentMobileOpen] = useState(false);
|
||||
const [sectionStatusByKey, setSectionStatusByKey] = useState<
|
||||
Partial<Record<SettingsType, SectionStatus>>
|
||||
>({});
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
@ -497,6 +553,9 @@ export default function Settings() {
|
||||
|
||||
const [selectedCamera, setSelectedCamera] = useState<string>("");
|
||||
|
||||
// Get all camera overrides for the selected camera
|
||||
const cameraOverrides = useAllCameraOverrides(config, selectedCamera);
|
||||
|
||||
const { payload: allCameraStates } = useInitialCameraState(
|
||||
cameras.length > 0 ? cameras[0].name : "",
|
||||
true,
|
||||
@ -589,6 +648,81 @@ export default function Settings() {
|
||||
}
|
||||
}, [t, contentMobileOpen]);
|
||||
|
||||
const handleSectionStatusChange = useCallback(
|
||||
(sectionKey: string, level: "global" | "camera", status: SectionStatus) => {
|
||||
// Map section keys to menu keys based on level
|
||||
let menuKey: string;
|
||||
if (level === "camera") {
|
||||
menuKey = CAMERA_SECTION_MAPPING[sectionKey] || sectionKey;
|
||||
} else {
|
||||
menuKey = GLOBAL_SECTION_MAPPING[sectionKey] || sectionKey;
|
||||
}
|
||||
|
||||
setSectionStatusByKey((prev) => ({
|
||||
...prev,
|
||||
[menuKey]: status,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Initialize override status for all camera sections
|
||||
useEffect(() => {
|
||||
if (!selectedCamera || !cameraOverrides) return;
|
||||
|
||||
const overrideMap: Partial<Record<SettingsType, SectionStatus>> = {};
|
||||
|
||||
// Set override status for all camera sections using the shared mapping
|
||||
Object.entries(CAMERA_SECTION_MAPPING).forEach(
|
||||
([sectionKey, settingsKey]) => {
|
||||
const isOverridden = cameraOverrides.includes(sectionKey);
|
||||
overrideMap[settingsKey] = {
|
||||
hasChanges: false,
|
||||
isOverridden,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
setSectionStatusByKey((prev) => {
|
||||
// Merge but preserve hasChanges from previous state
|
||||
const merged = { ...prev };
|
||||
Object.entries(overrideMap).forEach(([key, status]) => {
|
||||
merged[key as SettingsType] = {
|
||||
hasChanges: prev[key as SettingsType]?.hasChanges || false,
|
||||
isOverridden: status.isOverridden,
|
||||
};
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
}, [selectedCamera, cameraOverrides]);
|
||||
|
||||
const renderMenuItemLabel = useCallback(
|
||||
(key: SettingsType) => {
|
||||
const status = sectionStatusByKey[key];
|
||||
const showOverrideDot =
|
||||
CAMERA_SECTION_KEYS.has(key) && status?.isOverridden;
|
||||
// const showUnsavedDot = status?.hasChanges;
|
||||
const showUnsavedDot = false; // Disable unsaved changes indicator for now
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between pr-4 md:pr-0">
|
||||
<div className="smart-capitalize">{t("menu." + key)}</div>
|
||||
{(showOverrideDot || showUnsavedDot) && (
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
{showOverrideDot && (
|
||||
<span className="inline-block size-2 rounded-full bg-selected" />
|
||||
)}
|
||||
{showUnsavedDot && (
|
||||
<span className="inline-block size-2 rounded-full bg-danger" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[sectionStatusByKey, t],
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
@ -625,6 +759,7 @@ export default function Settings() {
|
||||
key={item.key}
|
||||
item={item}
|
||||
className={cn(filteredItems.length == 1 && "pl-2")}
|
||||
label={renderMenuItemLabel(item.key as SettingsType)}
|
||||
onSelect={(key) => {
|
||||
if (
|
||||
!isAdmin &&
|
||||
@ -688,6 +823,7 @@ export default function Settings() {
|
||||
selectedCamera={selectedCamera}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
selectedZoneMask={filterZoneMask}
|
||||
onSectionStatusChange={handleSectionStatusChange}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
@ -779,9 +915,9 @@ export default function Settings() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="smart-capitalize">
|
||||
{t("menu." + filteredItems[0].key)}
|
||||
</div>
|
||||
{renderMenuItemLabel(
|
||||
filteredItems[0].key as SettingsType,
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
@ -819,8 +955,10 @@ export default function Settings() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-full cursor-pointer smart-capitalize">
|
||||
{t("menu." + item.key)}
|
||||
<div className="w-full cursor-pointer">
|
||||
{renderMenuItemLabel(
|
||||
item.key as SettingsType,
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
@ -844,6 +982,7 @@ export default function Settings() {
|
||||
selectedCamera={selectedCamera}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
selectedZoneMask={filterZoneMask}
|
||||
onSectionStatusChange={handleSectionStatusChange}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
@ -1,13 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import type { SectionConfig } from "@/components/config-form/sections";
|
||||
import { ConfigSectionTemplate } from "@/components/config-form/sections";
|
||||
import type { PolygonType } from "@/types/canvas";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export type SettingsPageProps = {
|
||||
selectedCamera?: string;
|
||||
setUnsavedChanges?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
selectedZoneMask?: PolygonType[];
|
||||
onSectionStatusChange?: (
|
||||
sectionKey: string,
|
||||
level: "global" | "camera",
|
||||
status: SectionStatus,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type SectionStatus = {
|
||||
hasChanges: boolean;
|
||||
isOverridden: boolean;
|
||||
};
|
||||
|
||||
export type SingleSectionPageOptions = {
|
||||
@ -29,6 +41,7 @@ export function SingleSectionPage({
|
||||
showOverrideIndicator = true,
|
||||
selectedCamera,
|
||||
setUnsavedChanges,
|
||||
onSectionStatusChange,
|
||||
}: SingleSectionPageProps) {
|
||||
const sectionNamespace =
|
||||
level === "camera" ? "config/cameras" : "config/global";
|
||||
@ -37,6 +50,14 @@ export function SingleSectionPage({
|
||||
"views/settings",
|
||||
"common",
|
||||
]);
|
||||
const [sectionStatus, setSectionStatus] = useState<SectionStatus>({
|
||||
hasChanges: false,
|
||||
isOverridden: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onSectionStatusChange?.(sectionKey, level, sectionStatus);
|
||||
}, [onSectionStatusChange, sectionKey, level, sectionStatus]);
|
||||
|
||||
if (level === "camera" && !selectedCamera) {
|
||||
return (
|
||||
@ -48,10 +69,11 @@ export function SingleSectionPage({
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col pr-2">
|
||||
<div className="mb-4">
|
||||
<div className="space-y-4">
|
||||
<Heading as="h3">
|
||||
{t(`${sectionKey}.label`, { ns: sectionNamespace })}
|
||||
</Heading>
|
||||
|
||||
{i18n.exists(`${sectionKey}.description`, {
|
||||
ns: sectionNamespace,
|
||||
}) && (
|
||||
@ -59,6 +81,20 @@ export function SingleSectionPage({
|
||||
{t(`${sectionKey}.description`, { ns: sectionNamespace })}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{level === "camera" &&
|
||||
showOverrideIndicator &&
|
||||
sectionStatus.isOverridden && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("overridden", { ns: "common", defaultValue: "Overridden" })}
|
||||
</Badge>
|
||||
)}
|
||||
{sectionStatus.hasChanges && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("modified", { ns: "common", defaultValue: "Modified" })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ConfigSectionTemplate
|
||||
sectionKey={sectionKey}
|
||||
@ -69,6 +105,7 @@ export function SingleSectionPage({
|
||||
showTitle={false}
|
||||
sectionConfig={sectionConfig}
|
||||
requiresRestart={requiresRestart}
|
||||
onStatusChange={setSectionStatus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user