frigate/web/src/views/settings/UiSettingsView.tsx
Josh Hawkins 24766ce427
Use user-namespaced keys for idb persistence (#21110)
* add new hooks

* use new hooks for user based keys

* fix layout race condition
2025-12-01 07:59:54 -06:00

339 lines
12 KiB
TypeScript

import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useCallback, useContext, useEffect } from "react";
import { Toaster } from "sonner";
import { toast } from "sonner";
import { Separator } from "../../components/ui/separator";
import { Button } from "../../components/ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import {
useUserPersistence,
deleteUserNamespacedKey,
} from "@/hooks/use-user-persistence";
import { isSafari } 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";
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
const WEEK_STARTS_ON = ["Sunday", "Monday"];
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 clearStoredLayouts = useCallback(() => {
if (!config) {
return [];
}
Object.entries(config.camera_groups).forEach(async (value) => {
await deleteUserNamespacedKey(`${value[0]}-draggable-layout`, username)
.then(() => {
toast.success(
t("general.toast.success.clearStoredLayout", {
cameraName: value[0],
}),
{
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]);
useEffect(() => {
document.title = t("documentTitle.general");
}, [t]);
// settings
const [autoLive, setAutoLive] = useUserPersistence("autoLiveView", true);
const [cameraNames, setCameraName] = useUserPersistence(
"displayCameraNames",
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,
);
return (
<>
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
<Heading as="h4" className="mb-2">
{t("general.title")}
</Heading>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
{t("general.liveDashboard.title")}
</Heading>
<div className="mt-2 space-y-6">
<div className="space-y-3">
<div className="flex flex-row items-center justify-start gap-2">
<Switch
id="auto-live"
checked={autoLive}
onCheckedChange={setAutoLive}
/>
<Label className="cursor-pointer" htmlFor="auto-live">
{t("general.liveDashboard.automaticLiveView.label")}
</Label>
</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>{t("general.liveDashboard.automaticLiveView.desc")}</p>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-row items-center justify-start gap-2">
<Switch
id="images-only"
checked={alertVideos}
onCheckedChange={setAlertVideos}
/>
<Label className="cursor-pointer" htmlFor="images-only">
{t("general.liveDashboard.playAlertVideos.label")}
</Label>
</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>{t("general.liveDashboard.playAlertVideos.desc")}</p>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-row items-center justify-start gap-2">
<Switch
id="camera-names"
checked={cameraNames}
onCheckedChange={setCameraName}
/>
<Label className="cursor-pointer" htmlFor="camera-names">
{t("general.liveDashboard.displayCameraNames.label")}
</Label>
</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>{t("general.liveDashboard.displayCameraNames.desc")}</p>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-row items-center justify-start gap-2">
<Label
className="cursor-pointer"
htmlFor="live-fallback-timeout"
>
{t("general.liveDashboard.liveFallbackTimeout.label")}
</Label>
</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>{t("general.liveDashboard.liveFallbackTimeout.desc")}</p>
</div>
<Select
value={fallbackTimeout?.toString()}
onValueChange={(value) => setFallbackTimeout(parseInt(value))}
>
<SelectTrigger className="w-36">
{t("time.second", {
ns: "common",
time: fallbackTimeout,
count: fallbackTimeout,
})}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((timeout) => (
<SelectItem
key={timeout}
className="cursor-pointer"
value={timeout.toString()}
>
{t("time.second", {
ns: "common",
time: timeout,
count: timeout,
})}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<div className="my-3 flex w-full flex-col space-y-6">
<div className="mt-2 space-y-3">
<div className="space-y-0.5">
<div className="text-md">
{t("general.storedLayouts.title")}
</div>
<div className="my-2 text-sm text-muted-foreground">
<p>{t("general.storedLayouts.desc")}</p>
</div>
</div>
<Button
aria-label={t("general.storedLayouts.clearAll")}
onClick={clearStoredLayouts}
>
{t("general.storedLayouts.clearAll")}
</Button>
</div>
<div className="mt-2 space-y-3">
<div className="space-y-0.5">
<div className="text-md">
{t("general.cameraGroupStreaming.title")}
</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>{t("general.cameraGroupStreaming.desc")}</p>
</div>
</div>
<Button
aria-label={t("general.cameraGroupStreaming.clearAll")}
onClick={clearStreamingSettings}
>
{t("general.cameraGroupStreaming.clearAll")}
</Button>
</div>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
{t("general.recordingsViewer.title")}
</Heading>
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<div className="text-md">
{t("general.recordingsViewer.defaultPlaybackRate.label")}
</div>
<div className="my-2 text-sm text-muted-foreground">
<p>
{t("general.recordingsViewer.defaultPlaybackRate.desc")}
</p>
</div>
</div>
</div>
<Select
value={playbackRate?.toString()}
onValueChange={(value) => setPlaybackRate(parseFloat(value))}
>
<SelectTrigger className="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>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
{t("general.calendar.title")}
</Heading>
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<div className="text-md">
{t("general.calendar.firstWeekday.label")}
</div>
<div className="my-2 text-sm text-muted-foreground">
<p>{t("general.calendar.firstWeekday.desc")}</p>
</div>
</div>
</div>
<Select
value={weekStartsOn?.toString()}
onValueChange={(value) => setWeekStartsOn(parseInt(value))}
>
<SelectTrigger className="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>
<Separator className="my-2 flex bg-secondary" />
</div>
</div>
</div>
</>
);
}