refactor profile settings to match rjsf forms

This commit is contained in:
Josh Hawkins 2026-02-12 14:45:12 -06:00
parent fc5ad3edf7
commit ad00001049

View File

@ -1,10 +1,7 @@
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 "@/components/ui/sonner";
import { toast } from "sonner";
import { Separator } from "../../components/ui/separator";
import { ReactNode, useCallback, useContext, useEffect } from "react";
import { Toaster, toast } from "sonner";
import { Button } from "../../components/ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
@ -25,6 +22,100 @@ 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"];
const SPLIT_ROW_CLASS_NAME =
"space-y-2 md:grid md:grid-cols-[minmax(14rem,22rem)_minmax(0,1fr)] md:items-start md:gap-x-6 md:space-y-0";
const DESCRIPTION_CLASS_NAME = "text-sm text-muted-foreground";
const CONTROL_COLUMN_CLASS_NAME = "w-full md:max-w-2xl";
type SettingsGroupCardProps = {
title: string;
children: ReactNode;
};
function SettingsGroupCard({ title, children }: SettingsGroupCardProps) {
return (
<div className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4">
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
{title}
</div>
{children}
</div>
);
}
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>
);
}
export default function UiSettingsView() {
const { data: config } = useSWR<FrigateConfig>("config");
@ -37,13 +128,11 @@ export default function UiSettingsView() {
return [];
}
Object.entries(config.camera_groups).forEach(async (value) => {
await deleteUserNamespacedKey(`${value[0]}-draggable-layout`, username)
Object.entries(config.camera_groups).forEach(async ([cameraName]) => {
await deleteUserNamespacedKey(`${cameraName}-draggable-layout`, username)
.then(() => {
toast.success(
t("general.toast.success.clearStoredLayout", {
cameraName: value[0],
}),
t("general.toast.success.clearStoredLayout", { cameraName }),
{
position: "top-center",
},
@ -69,7 +158,7 @@ export default function UiSettingsView() {
return [];
}
await deleteUserNamespacedKey(`streaming-settings`, username)
await deleteUserNamespacedKey("streaming-settings", username)
.then(() => {
toast.success(t("general.toast.success.clearStreamingSettings"), {
position: "top-center",
@ -95,8 +184,6 @@ export default function UiSettingsView() {
document.title = t("documentTitle.general");
}, [t]);
// settings
const [autoLive, setAutoLive] = useUserPersistence("autoLiveView", true);
const [cameraNames, setCameraName] = useUserPersistence(
"displayCameraNames",
@ -110,229 +197,195 @@ export default function UiSettingsView() {
3,
);
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,
},
];
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>
<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">
<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} />
))}
<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, 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>
</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(),
<ValueSettingRow
id="live-fallback-timeout"
label={t("general.liveDashboard.liveFallbackTimeout.label")}
description={t(
"general.liveDashboard.liveFallbackTimeout.desc",
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{WEEK_STARTS_ON.map((day, index) => (
<SelectItem
key={index}
className="cursor-pointer"
value={index.toString()}
control={
<Select
value={fallbackTimeout?.toString()}
onValueChange={(value) =>
setFallbackTimeout(parseInt(value, 10))
}
>
<SelectTrigger
id="live-fallback-timeout"
className="w-full md:w-36"
>
{t("general.calendar.firstWeekday." + day.toLowerCase())}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Separator className="my-2 flex bg-secondary" />
</div>
{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={clearStoredLayouts}
>
{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={clearStreamingSettings}
>
{t("general.cameraGroupStreaming.clearAll")}
</Button>
}
/>
</div>
</SettingsGroupCard>
<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>
</>
</div>
);
}