Files
frigate/web/src/views/settings/UiSettingsView.tsx
T

700 lines
22 KiB
TypeScript
Raw Normal View History

import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
ChangeEvent,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
2026-05-29 17:00:30 -05:00
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";
2024-05-14 07:38:03 -06:00
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
2024-05-29 08:01:39 -06:00
} from "../../components/ui/select";
import { useTranslation } from "react-i18next";
import { AuthContext } from "@/context/auth-context";
2026-02-27 09:55:36 -06:00
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";
2024-05-14 07:38:03 -06:00
const WEEK_STARTS_ON = ["Sunday", "Monday"];
const IMPORT_FAILED_FLAG = "frigate-ui-settings-import-failed";
type ConfirmTarget = "layouts" | "streaming" | "naturalAspect";
2026-02-27 09:55:36 -06:00
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 (
<div className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-4 md:block">
<Label className="cursor-pointer" htmlFor={id}>
{label}
</Label>
<div className="md:hidden">
<Switch
id={id}
checked={checked ?? false}
onCheckedChange={onCheckedChange}
/>
</div>
</div>
<p className={DESCRIPTION_CLASS_NAME}>{description}</p>
</div>
<div className="hidden w-full md:flex md:max-w-2xl md:items-center">
<Switch
id={`${id}-desktop`}
checked={checked ?? false}
onCheckedChange={onCheckedChange}
/>
</div>
</div>
);
}
type ValueSettingRowProps = {
id: string;
label: string;
description: string;
control: ReactNode;
};
function ValueSettingRow({
id,
label,
description,
control,
}: ValueSettingRowProps) {
return (
<div className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<Label className="cursor-pointer" htmlFor={id}>
{label}
</Label>
<p className="hidden text-sm text-muted-foreground md:block">
{description}
</p>
</div>
<div className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}>
{control}
<p className="text-sm text-muted-foreground md:hidden">{description}</p>
</div>
</div>
);
}
2024-10-15 18:25:59 -06:00
export default function UiSettingsView() {
const { data: config } = useSWR<FrigateConfig>("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];
2026-02-27 09:55:36 -06:00
const clearStoredLayouts = useCallback(() => {
if (!config) {
return [];
}
2026-02-27 09:55:36 -06:00
Object.entries(config.camera_groups).forEach(async ([cameraName]) => {
await deleteUserNamespacedKey(`${cameraName}-draggable-layout`, username)
.then(() => {
toast.success(
2026-02-27 09:55:36 -06:00
t("general.toast.success.clearStoredLayout", { cameraName }),
{
position: "top-center",
},
);
})
.catch((error) => {
2025-03-08 10:01:08 -06:00
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]);
2025-02-10 10:42:35 -06:00
const clearStreamingSettings = useCallback(async () => {
if (!config) {
return [];
}
2026-02-27 09:55:36 -06:00
await deleteUserNamespacedKey("streaming-settings", username)
2025-02-10 10:42:35 -06:00
.then(() => {
toast.success(t("general.toast.success.clearStreamingSettings"), {
2025-02-10 10:42:35 -06:00
position: "top-center",
});
})
.catch((error) => {
2025-03-08 10:01:08 -06:00
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("general.toast.error.clearStreamingSettingsFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
2025-02-10 10:42:35 -06:00
});
}, [config, t, username]);
2025-02-10 10:42:35 -06:00
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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<TransferSection, boolean>) => {
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],
);
2024-04-27 12:02:01 -05:00
useEffect(() => {
document.title = t("documentTitle.general");
}, [t]);
2024-04-27 12:02:01 -05:00
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(
2025-11-11 16:00:54 -07:00
"liveFallbackTimeout",
3,
);
2024-05-14 07:38:03 -06:00
const [pendingConfirm, setPendingConfirm] = useState<ConfirmTarget | null>(
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,
]);
2026-02-27 09:55:36 -06:00
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"),
},
]),
2026-02-27 09:55:36 -06:00
];
return (
<div className="flex size-full flex-col">
2026-02-27 09:55:36 -06:00
<div className="scrollbar-container mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2">
2026-03-19 09:47:57 -05:00
<Heading as="h4" className="mb-3">
{t("general.title")}
</Heading>
2026-02-27 09:55:36 -06:00
<div className="w-full max-w-5xl space-y-6">
<SettingsGroupCard title={t("general.liveDashboard.title")}>
<div className="space-y-6">
{liveDashboardSwitchRows.map((row) => (
<SwitchSettingRow key={row.id} {...row} />
))}
2026-02-27 09:55:36 -06:00
<ValueSettingRow
id="live-fallback-timeout"
label={t("general.liveDashboard.liveFallbackTimeout.label")}
description={t(
"general.liveDashboard.liveFallbackTimeout.desc",
)}
2026-02-27 09:55:36 -06:00
control={
<Select
value={fallbackTimeout?.toString()}
onValueChange={(value) =>
setFallbackTimeout(parseInt(value, 10))
}
>
<SelectTrigger
id="live-fallback-timeout"
className="w-full md:w-36"
>
2026-02-27 09:55:36 -06:00
{t("time.second", {
ns: "common",
time: fallbackTimeout,
count: fallbackTimeout,
})}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{[1, 2, 3, 5, 8, 10, 12, 15].map((timeout) => (
<SelectItem
key={timeout}
className="cursor-pointer"
value={timeout.toString()}
>
{t("time.second", {
ns: "common",
time: timeout,
count: timeout,
})}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
}
/>
<ValueSettingRow
id="stored-layouts-clear"
label={t("general.storedLayouts.title")}
description={t("general.storedLayouts.desc")}
control={
<Button
id="stored-layouts-clear"
aria-label={t("general.storedLayouts.clearAll")}
className="w-full md:w-auto"
onClick={() => setPendingConfirm("layouts")}
2026-02-27 09:55:36 -06:00
>
{t("general.storedLayouts.clearAll")}
</Button>
}
/>
<ValueSettingRow
id="camera-group-streaming-clear"
label={t("general.cameraGroupStreaming.title")}
description={t("general.cameraGroupStreaming.desc")}
control={
<Button
id="camera-group-streaming-clear"
aria-label={t("general.cameraGroupStreaming.clearAll")}
className="w-full md:w-auto"
onClick={() => setPendingConfirm("streaming")}
2026-02-27 09:55:36 -06:00
>
{t("general.cameraGroupStreaming.clearAll")}
</Button>
}
/>
</div>
</SettingsGroupCard>
<SettingsGroupCard title={t("general.backupRestore.title")}>
<ValueSettingRow
id="ui-settings-transfer"
label={t("general.backupRestore.transfer.label")}
description={t("general.backupRestore.transfer.desc")}
control={
<div className="flex flex-col gap-2 md:flex-row">
<Button
id="ui-settings-export"
aria-label={t("general.backupRestore.transfer.export")}
className="w-full md:w-auto"
disabled={auth.isLoading || !config}
onClick={handleExport}
>
{t("general.backupRestore.transfer.export")}
</Button>
<Button
id="ui-settings-import"
aria-label={t("general.backupRestore.transfer.import")}
className="w-full md:w-auto"
disabled={auth.isLoading || !config}
onClick={() => fileInputRef.current?.click()}
>
{t("general.backupRestore.transfer.import")}
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={handleFileSelected}
/>
</div>
}
/>
</SettingsGroupCard>
2026-02-27 09:55:36 -06:00
<SettingsGroupCard title={t("general.recordingsViewer.title")}>
<ValueSettingRow
id="default-playback-rate"
label={t("general.recordingsViewer.defaultPlaybackRate.label")}
description={t(
"general.recordingsViewer.defaultPlaybackRate.desc",
)}
control={
<Select
value={playbackRate?.toString()}
onValueChange={(value) => setPlaybackRate(parseFloat(value))}
>
<SelectTrigger
id="default-playback-rate"
className="w-full md:w-20"
>
{`${playbackRate}x`}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{PLAYBACK_RATE_DEFAULT.map((rate) => (
<SelectItem
key={rate}
className="cursor-pointer"
value={rate.toString()}
>
{rate}x
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
}
/>
</SettingsGroupCard>
<SettingsGroupCard title={t("general.calendar.title")}>
<ValueSettingRow
id="first-weekday"
label={t("general.calendar.firstWeekday.label")}
description={t("general.calendar.firstWeekday.desc")}
control={
<Select
value={weekStartsOn?.toString()}
onValueChange={(value) =>
setWeekStartsOn(parseInt(value, 10))
}
>
<SelectTrigger id="first-weekday" className="w-full md:w-32">
{t(
"general.calendar.firstWeekday." +
WEEK_STARTS_ON[weekStartsOn ?? 0].toLowerCase(),
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{WEEK_STARTS_ON.map((day, index) => (
<SelectItem
key={index}
className="cursor-pointer"
value={index.toString()}
>
{t(
"general.calendar.firstWeekday." +
day.toLowerCase(),
)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
}
/>
</SettingsGroupCard>
</div>
</div>
{pendingImport && (
<ImportUiSettingsDialog
open={pendingImport != null}
onOpenChange={(open) => {
if (!open) {
setPendingImport(null);
}
}}
fileName={pendingImport.name}
file={pendingImport.file}
summary={pendingImport.summary}
currentNaturalAspect={naturalAspect ?? false}
onConfirm={handleImportConfirm}
/>
)}
<AlertDialog
open={pendingConfirm != null}
onOpenChange={(open) => {
if (!open) {
setPendingConfirm(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pendingConfirm && confirmCopy(pendingConfirm).title}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingConfirm && confirmCopy(pendingConfirm).description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={handleConfirm}
>
{pendingConfirm && confirmCopy(pendingConfirm).action}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
2026-02-27 09:55:36 -06:00
</div>
);
}