import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
ChangeEvent,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { Button, buttonVariants } from "../../components/ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import {
useUserPersistence,
deleteUserNamespacedKey,
} from "@/hooks/use-user-persistence";
import { isMobileOnly } from "react-device-detect";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "../../components/ui/select";
import { useTranslation } from "react-i18next";
import { AuthContext } from "@/context/auth-context";
import {
SettingsGroupCard,
SPLIT_ROW_CLASS_NAME,
DESCRIPTION_CLASS_NAME,
CONTROL_COLUMN_CLASS_NAME,
} from "@/components/card/SettingsGroupCard";
import Heading from "@/components/ui/heading";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
import ImportUiSettingsDialog from "@/components/overlay/dialog/ImportUiSettingsDialog";
import {
applyImportPayload,
buildExportPayload,
downloadJson,
exportFileName,
hasImportableContent,
ImportSummary,
ParseError,
parseUiSettingsFile,
summarizeImport,
TransferSection,
UiSettingsFile,
} from "@/utils/uiSettingsTransfer";
const WEEK_STARTS_ON = ["Sunday", "Monday"];
const IMPORT_FAILED_FLAG = "frigate-ui-settings-import-failed";
type ConfirmTarget = "layouts" | "streaming" | "naturalAspect";
type SwitchSettingRowProps = {
id: string;
label: string;
description: string;
checked: boolean | undefined;
onCheckedChange: (checked: boolean | undefined) => void;
};
function SwitchSettingRow({
id,
label,
description,
checked,
onCheckedChange,
}: SwitchSettingRowProps) {
return (
);
}
type ValueSettingRowProps = {
id: string;
label: string;
description: string;
control: ReactNode;
};
function ValueSettingRow({
id,
label,
description,
control,
}: ValueSettingRowProps) {
return (
);
}
export default function UiSettingsView() {
const { data: config } = useSWR("config");
const { t } = useTranslation("views/settings");
const { auth } = useContext(AuthContext);
const username = auth?.user?.username;
const PLAYBACK_RATE_DEFAULT = [0.5, 1, 2, 4, 8, 16];
const clearStoredLayouts = useCallback(() => {
if (!config) {
return [];
}
Object.entries(config.camera_groups).forEach(async ([cameraName]) => {
await deleteUserNamespacedKey(`${cameraName}-draggable-layout`, username)
.then(() => {
toast.success(
t("general.toast.success.clearStoredLayout", { cameraName }),
{
position: "top-center",
},
);
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("general.toast.error.clearStoredLayoutFailed", { errorMessage }),
{
position: "top-center",
},
);
});
});
}, [config, t, username]);
const clearStreamingSettings = useCallback(async () => {
if (!config) {
return [];
}
await deleteUserNamespacedKey("streaming-settings", username)
.then(() => {
toast.success(t("general.toast.success.clearStreamingSettings"), {
position: "top-center",
});
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("general.toast.error.clearStreamingSettingsFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
});
}, [config, t, username]);
const fileInputRef = useRef(null);
const [pendingImport, setPendingImport] = useState<{
name: string;
file: UiSettingsFile;
summary: ImportSummary;
} | null>(null);
const importErrorMessage = useCallback(
(error: ParseError) => {
// literal keys per branch: a template key would be invisible to
// npm run i18n:extract, which CI verifies
switch (error) {
case "invalid_json":
return t("general.toast.error.importInvalidJson");
case "wrong_type":
return t("general.toast.error.importWrongType");
case "unsupported_version":
return t("general.toast.error.importUnsupportedVersion");
case "invalid_schema":
return t("general.toast.error.importInvalidSchema");
}
},
[t],
);
const handleExport = useCallback(async () => {
if (!config || auth.isLoading) {
return;
}
let payload: UiSettingsFile;
try {
payload = await buildExportPayload(
Object.keys(config.camera_groups),
config.version,
username,
);
} catch {
toast.error(t("general.toast.error.exportUiSettingsFailed"), {
position: "top-center",
});
return;
}
downloadJson(payload, exportFileName(new Date()));
toast.success(t("general.toast.success.exportUiSettings"), {
position: "top-center",
});
}, [config, auth.isLoading, username, t]);
const handleFileSelected = useCallback(
async (event: ChangeEvent) => {
const selected = event.target.files?.[0];
// reset so choosing the same file again still fires a change event
event.target.value = "";
if (!selected || !config || auth.isLoading) {
return;
}
const result = parseUiSettingsFile(await selected.text());
if (!result.ok) {
toast.error(importErrorMessage(result.error), {
position: "top-center",
});
return;
}
const summary = summarizeImport(
result.file,
Object.keys(config.camera_groups),
Object.keys(config.cameras),
);
// a file exported from a browser with nothing stored is structurally
// valid, and would open a dialog with every switch disabled
if (!hasImportableContent(summary)) {
toast.error(t("general.toast.error.importNothingToApply"), {
position: "top-center",
});
return;
}
setPendingImport({ name: selected.name, file: result.file, summary });
},
[config, auth.isLoading, importErrorMessage, t],
);
const handleImportConfirm = useCallback(
async (sections: Record) => {
if (!pendingImport || auth.isLoading) {
return;
}
try {
await applyImportPayload(pendingImport.file, sections, username);
} catch {
// writes are already in flight when one rejects, so reload anyway:
// staying mounted lets the persistence providers write their stale
// state back over whatever did land
sessionStorage.setItem(IMPORT_FAILED_FLAG, "1");
}
window.location.reload();
},
[pendingImport, auth.isLoading, username],
);
useEffect(() => {
document.title = t("documentTitle.general");
}, [t]);
useEffect(() => {
if (!sessionStorage.getItem(IMPORT_FAILED_FLAG)) {
return;
}
sessionStorage.removeItem(IMPORT_FAILED_FLAG);
toast.error(t("general.toast.error.importUiSettingsFailed"), {
position: "top-center",
});
}, [t]);
const [autoLive, setAutoLive] = useUserPersistence("autoLiveView", true);
const [cameraNames, setCameraName] = useUserPersistence(
"displayCameraNames",
false,
);
const [naturalAspect, setNaturalAspect] = useUserPersistence(
"naturalAspectLayout",
false,
);
const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1);
const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0);
const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true);
const [fallbackTimeout, setFallbackTimeout] = useUserPersistence(
"liveFallbackTimeout",
3,
);
const [pendingConfirm, setPendingConfirm] = useState(
null,
);
const confirmCopy = useCallback(
(target: ConfirmTarget) => {
// literal keys per branch: a template key would be invisible to
// npm run i18n:extract, which CI verifies
switch (target) {
case "layouts":
return {
title: t("general.storedLayouts.clearAll"),
description: t("general.storedLayouts.clearConfirm"),
action: t("button.clear", { ns: "common" }),
};
case "streaming":
return {
title: t("general.cameraGroupStreaming.clearAll"),
description: t("general.cameraGroupStreaming.clearConfirm"),
action: t("button.clear", { ns: "common" }),
};
case "naturalAspect":
return {
title: t("general.liveDashboard.naturalAspectLayout.label"),
description: t(
"general.liveDashboard.naturalAspectLayout.descNote",
),
action: naturalAspect
? t("button.disable", { ns: "common" })
: t("button.enable", { ns: "common" }),
};
}
},
[naturalAspect, t],
);
const handleConfirm = useCallback(() => {
switch (pendingConfirm) {
case "layouts":
clearStoredLayouts();
break;
case "streaming":
clearStreamingSettings();
break;
case "naturalAspect":
// a layout only renders in the mode that built it
setNaturalAspect(!naturalAspect);
clearStoredLayouts();
break;
}
setPendingConfirm(null);
}, [
pendingConfirm,
clearStoredLayouts,
clearStreamingSettings,
naturalAspect,
setNaturalAspect,
]);
const liveDashboardSwitchRows = [
{
id: "auto-live",
label: t("general.liveDashboard.automaticLiveView.label"),
description: t("general.liveDashboard.automaticLiveView.desc"),
checked: autoLive,
onCheckedChange: setAutoLive,
},
{
id: "images-only",
label: t("general.liveDashboard.playAlertVideos.label"),
description: t("general.liveDashboard.playAlertVideos.desc"),
checked: alertVideos,
onCheckedChange: setAlertVideos,
},
{
id: "camera-names",
label: t("general.liveDashboard.displayCameraNames.label"),
description: t("general.liveDashboard.displayCameraNames.desc"),
checked: cameraNames,
onCheckedChange: setCameraName,
},
// phones use the static grid, so tile sizing has nothing to affect there
...(isMobileOnly
? []
: [
{
id: "natural-aspect",
label: t("general.liveDashboard.naturalAspectLayout.label"),
description: t("general.liveDashboard.naturalAspectLayout.desc"),
checked: naturalAspect,
onCheckedChange: () => setPendingConfirm("naturalAspect"),
},
]),
];
return (
{t("general.title")}
{liveDashboardSwitchRows.map((row) => (
))}
setFallbackTimeout(parseInt(value, 10))
}
>
{t("time.second", {
ns: "common",
time: fallbackTimeout,
count: fallbackTimeout,
})}
{[1, 2, 3, 5, 8, 10, 12, 15].map((timeout) => (
{t("time.second", {
ns: "common",
time: timeout,
count: timeout,
})}
))}
}
/>
setPendingConfirm("layouts")}
>
{t("general.storedLayouts.clearAll")}
}
/>
setPendingConfirm("streaming")}
>
{t("general.cameraGroupStreaming.clearAll")}
}
/>
}
/>
setPlaybackRate(parseFloat(value))}
>
{`${playbackRate}x`}
{PLAYBACK_RATE_DEFAULT.map((rate) => (
{rate}x
))}
}
/>
setWeekStartsOn(parseInt(value, 10))
}
>
{t(
"general.calendar.firstWeekday." +
WEEK_STARTS_ON[weekStartsOn ?? 0].toLowerCase(),
)}
{WEEK_STARTS_ON.map((day, index) => (
{t(
"general.calendar.firstWeekday." +
day.toLowerCase(),
)}
))}
}
/>
{pendingImport && (
{
if (!open) {
setPendingImport(null);
}
}}
fileName={pendingImport.name}
file={pendingImport.file}
summary={pendingImport.summary}
currentNaturalAspect={naturalAspect ?? false}
onConfirm={handleImportConfirm}
/>
)}
{
if (!open) {
setPendingConfirm(null);
}
}}
>
{pendingConfirm && confirmCopy(pendingConfirm).title}
{pendingConfirm && confirmCopy(pendingConfirm).description}
{t("button.cancel", { ns: "common" })}
{pendingConfirm && confirmCopy(pendingConfirm).action}
);
}