mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-01 00:22:19 +03:00
feat: add i18n (translation/localization) (#16877)
* Translation module init * Add more i18n keys * fix: fix string wrong * refactor: use namespace translation file * chore: add more translation key * fix: fix some page name error * refactor: change Trans tag for t function * chore: fix some key not work * chore: fix SearchFilterDialog i18n key error Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * chore: fix en i18n file filter missing some keys * chore: add some i18n keys * chore: add more i18n keys again * feat: add search page i18n * feat: add explore model i18n keys * Update web/src/components/menu/GeneralSettings.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update web/src/components/menu/GeneralSettings.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update web/src/components/menu/GeneralSettings.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * feat: add more live i18n keys * feat: add more search setting i18n keys * fix: remove some comment * fix: fix some setting page url error * Update web/src/views/settings/SearchSettingsView.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * fix: add system missing keys * fix: update password update i18n keys * chore: remove outdate translation.json file * fix: fix exploreSettings error * chore: add object setting i18n keys * Update web/src/views/recording/RecordingView.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update web/public/locales/en/components/filter.json Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update web/src/components/overlay/ExportDialog.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * feat: add more i18n keys * fix: fix motionDetectionTuner html node * feat: add more page i18n keys * fix: cameraStream i18n keys error * feat: add Player i18n keys * feat: add more toast i18n keys * feat: change explore setting name * feat: add more document title i18n keys * feat: add more search i18n keys * fix: fix accessDenied i18n keys error * chore: add objectType i18n * chore: add inputWithTags i18n * chore: add SearchFilterDialog i18n * Update web/src/views/settings/ObjectSettingsView.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update web/src/views/settings/ObjectSettingsView.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update web/src/views/settings/ObjectSettingsView.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update web/src/views/settings/ObjectSettingsView.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update web/src/views/settings/ObjectSettingsView.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * chore: add some missing i18n keys * chore: remove most import { t } from "i18next"; --------- Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
co-authored by
Josh Hawkins
parent
db541abed4
commit
d34533981f
@@ -13,6 +13,7 @@ import { toast } from "sonner";
|
||||
import DeleteUserDialog from "@/components/overlay/DeleteUserDialog";
|
||||
import { HiTrash } from "react-icons/hi";
|
||||
import { FaUserEdit } from "react-icons/fa";
|
||||
|
||||
import { LuPlus, LuShield, LuUserCog } from "react-icons/lu";
|
||||
import {
|
||||
Table,
|
||||
@@ -30,8 +31,10 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import RoleChangeDialog from "@/components/overlay/RoleChangeDialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function AuthenticationView() {
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { data: users, mutate: mutateUsers } = useSWR<User[]>("users");
|
||||
|
||||
@@ -46,30 +49,38 @@ export default function AuthenticationView() {
|
||||
>();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Authentication Settings - Frigate";
|
||||
}, []);
|
||||
document.title = t("documentTitle.authentication");
|
||||
}, [t]);
|
||||
|
||||
const onSavePassword = useCallback((user: string, password: string) => {
|
||||
axios
|
||||
.put(`users/${user}/password`, { password })
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
setShowSetPassword(false);
|
||||
toast.success("Password updated successfully", {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to save password: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
const onSavePassword = useCallback(
|
||||
(user: string, password: string) => {
|
||||
axios
|
||||
.put(`users/${user}/password`, { password })
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
setShowSetPassword(false);
|
||||
toast.success(t("users.toast.success.updatePassword"), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("users.toast.error.setPasswordFailed", {
|
||||
errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const onCreate = (
|
||||
user: string,
|
||||
@@ -85,7 +96,7 @@ export default function AuthenticationView() {
|
||||
users?.push({ username: user, role: role });
|
||||
return users;
|
||||
}, false);
|
||||
toast.success(`User ${user} created successfully`, {
|
||||
toast.success(t("users.toast.success.createUser", { user }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
@@ -95,9 +106,14 @@ export default function AuthenticationView() {
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to create user: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("users.toast.error.createUserFailed", {
|
||||
errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -111,7 +127,7 @@ export default function AuthenticationView() {
|
||||
(users) => users?.filter((u) => u.username !== user),
|
||||
false,
|
||||
);
|
||||
toast.success(`User ${user} deleted successfully`, {
|
||||
toast.success(t("users.toast.success.deleteUser", { user }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
@@ -121,9 +137,14 @@ export default function AuthenticationView() {
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to delete user: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("users.toast.error.deleteUserFailed", {
|
||||
errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -142,7 +163,7 @@ export default function AuthenticationView() {
|
||||
),
|
||||
false,
|
||||
);
|
||||
toast.success(`Role updated for ${user}`, {
|
||||
toast.success(t("users.toast.success.roleUpdated", { user }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
@@ -152,9 +173,14 @@ export default function AuthenticationView() {
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to update role: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("users.toast.error.roleUpdateFailed", {
|
||||
errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -173,20 +199,20 @@ export default function AuthenticationView() {
|
||||
<div className="mb-5 flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start">
|
||||
<Heading as="h3" className="my-2">
|
||||
User Management
|
||||
{t("users.management")}
|
||||
</Heading>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage this Frigate instance's user accounts.
|
||||
{t("users.management.desc")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="flex items-center gap-2 self-start sm:self-auto"
|
||||
aria-label="Add a new user"
|
||||
aria-label={t("users.addUser")}
|
||||
variant="default"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add User
|
||||
{t("users.addUser")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -195,16 +221,20 @@ export default function AuthenticationView() {
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px]">Username</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead className="w-[250px]">
|
||||
{t("users.table.username")}
|
||||
</TableHead>
|
||||
<TableHead>{t("users.table.role")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("users.table.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-24 text-center">
|
||||
No users found.
|
||||
{t("users.table.noUsers")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -231,7 +261,9 @@ export default function AuthenticationView() {
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{user.role || "viewer"}
|
||||
{t("role." + (user.role || "viewer"), {
|
||||
ns: "common",
|
||||
})}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -255,12 +287,12 @@ export default function AuthenticationView() {
|
||||
>
|
||||
<LuUserCog className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
Role
|
||||
{t("role.title", { ns: "common" })}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Change user role</p>
|
||||
<p>{t("users.table.changeRole")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -278,12 +310,12 @@ export default function AuthenticationView() {
|
||||
>
|
||||
<FaUserEdit className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
Password
|
||||
{t("users.table.password")}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Update password</p>
|
||||
<p>{t("users.updatePassword")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -301,12 +333,12 @@ export default function AuthenticationView() {
|
||||
>
|
||||
<HiTrash className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
Delete
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete user</p>
|
||||
<p>{t("users.table.deleteUser")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { LuExternalLink } from "react-icons/lu";
|
||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAlertsState, useDetectionsState, useEnabledState } from "@/api/ws";
|
||||
@@ -45,6 +46,8 @@ export default function CameraSettingsView({
|
||||
selectedCamera,
|
||||
setUnsavedChanges,
|
||||
}: CameraSettingsViewProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
|
||||
@@ -76,18 +79,18 @@ export default function CameraSettingsView({
|
||||
const alertsLabels = useMemo(() => {
|
||||
return cameraConfig?.review.alerts.labels
|
||||
? cameraConfig.review.alerts.labels
|
||||
.map((label) => label.replaceAll("_", " "))
|
||||
.map((label) => t(label, { ns: "objects" }))
|
||||
.join(", ")
|
||||
: "";
|
||||
}, [cameraConfig]);
|
||||
}, [cameraConfig, t]);
|
||||
|
||||
const detectionsLabels = useMemo(() => {
|
||||
return cameraConfig?.review.detections.labels
|
||||
? cameraConfig.review.detections.labels
|
||||
.map((label) => label.replaceAll("_", " "))
|
||||
.map((label) => t(label, { ns: "objects" }))
|
||||
.join(", ")
|
||||
: "";
|
||||
}, [cameraConfig]);
|
||||
}, [cameraConfig, t]);
|
||||
|
||||
// form
|
||||
|
||||
@@ -157,17 +160,20 @@ export default function CameraSettingsView({
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
`Review classification configuration has been saved. Restart Frigate to apply changes.`,
|
||||
toast.success(t("camera.reviewClassification.toast.success"), {
|
||||
position: "top-center",
|
||||
});
|
||||
updateConfig();
|
||||
} else {
|
||||
toast.error(
|
||||
t("toast.save.error", {
|
||||
errorMessage: res.statusText,
|
||||
ns: "common",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
updateConfig();
|
||||
} else {
|
||||
toast.error(`Failed to save config changes: ${res.statusText}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -175,15 +181,21 @@ export default function CameraSettingsView({
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("toast.save.error", {
|
||||
errorMessage,
|
||||
ns: "common",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
},
|
||||
[updateConfig, setIsLoading, selectedCamera, cameraConfig],
|
||||
[updateConfig, setIsLoading, selectedCamera, cameraConfig, t],
|
||||
);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
@@ -252,13 +264,13 @@ export default function CameraSettingsView({
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||
<Heading as="h3" className="my-2">
|
||||
Camera Settings
|
||||
<Trans ns="views/settings">camera.title</Trans>
|
||||
</Heading>
|
||||
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
Streams
|
||||
<Trans ns="views/settings">camera.streams.title</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
@@ -271,19 +283,18 @@ export default function CameraSettingsView({
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="camera-enabled">Enable</Label>
|
||||
<Label htmlFor="camera-enabled">
|
||||
<Trans>button.enabled</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
Disabling a camera completely stops Frigate's processing of this
|
||||
camera's streams. Detection, recording, and debugging will be
|
||||
unavailable.
|
||||
<br /> <em>Note: This does not disable go2rtc restreams.</em>
|
||||
<Trans ns="views/settings">camera.streams.desc</Trans>
|
||||
</div>
|
||||
<Separator className="mb-2 mt-4 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
Review
|
||||
<Trans ns="views/settings">camera.review.title</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
||||
@@ -297,7 +308,9 @@ export default function CameraSettingsView({
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="alerts-enabled">Alerts</Label>
|
||||
<Label htmlFor="alerts-enabled">
|
||||
<Trans ns="views/settings">camera.review.alerts</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -311,12 +324,13 @@ export default function CameraSettingsView({
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="detections-enabled">Detections</Label>
|
||||
<Label htmlFor="detections-enabled">
|
||||
<Trans ns="views/settings">camera.review.detections</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
Enable/disable alerts and detections for this camera. When
|
||||
disabled, no new review items will be generated.
|
||||
<Trans ns="views/settings">camera.review.desc</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -324,16 +338,15 @@ export default function CameraSettingsView({
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
Review Classification
|
||||
<Trans ns="views/settings">camera.reviewClassification.title</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Frigate categorizes review items as Alerts and Detections. By
|
||||
default, all <em>person</em> and <em>car</em> objects are
|
||||
considered Alerts. You can refine categorization of your review
|
||||
items by configuring required zones for them.
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.desc
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
@@ -342,7 +355,9 @@ export default function CameraSettingsView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the Documentation{" "}
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.readTheDocumentation
|
||||
</Trans>{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -371,11 +386,15 @@ export default function CameraSettingsView({
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<FormLabel className="flex flex-row items-center text-base">
|
||||
Alerts{" "}
|
||||
<Trans ns="views/settings">
|
||||
camera.review.alerts
|
||||
</Trans>
|
||||
<MdCircle className="ml-3 size-2 text-severity_alert" />
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Select zones for Alerts
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.selectAlertsZones
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</div>
|
||||
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
|
||||
@@ -424,20 +443,37 @@ export default function CameraSettingsView({
|
||||
</>
|
||||
) : (
|
||||
<div className="font-normal text-destructive">
|
||||
No zones are defined for this camera.
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.noDefinedZones
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
<div className="text-sm">
|
||||
All {alertsLabels} objects
|
||||
{watchedAlertsZones && watchedAlertsZones.length > 0
|
||||
? ` detected in ${watchedAlertsZones.map((zone) => capitalizeFirstLetter(zone).replaceAll("_", " ")).join(", ")}`
|
||||
: ""}{" "}
|
||||
on{" "}
|
||||
{capitalizeFirstLetter(
|
||||
cameraConfig?.name ?? "",
|
||||
).replaceAll("_", " ")}{" "}
|
||||
will be shown as Alerts.
|
||||
? t(
|
||||
"camera.reviewClassification.zoneObjectAlertsTips",
|
||||
{
|
||||
alertsLabels,
|
||||
zone: watchedAlertsZones
|
||||
.map((zone) =>
|
||||
capitalizeFirstLetter(zone).replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
),
|
||||
)
|
||||
.join(", "),
|
||||
cameraName: capitalizeFirstLetter(
|
||||
cameraConfig?.name ?? "",
|
||||
).replaceAll("_", " "),
|
||||
},
|
||||
)
|
||||
: t("camera.reviewClassification.objectAlertsTips", {
|
||||
alertsLabels,
|
||||
cameraName: capitalizeFirstLetter(
|
||||
cameraConfig?.name ?? "",
|
||||
).replaceAll("_", " "),
|
||||
})}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -452,12 +488,16 @@ export default function CameraSettingsView({
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<FormLabel className="flex flex-row items-center text-base">
|
||||
Detections{" "}
|
||||
<Trans ns="views/settings">
|
||||
camera.review.detections
|
||||
</Trans>
|
||||
<MdCircle className="ml-3 size-2 text-severity_detection" />
|
||||
</FormLabel>
|
||||
{selectDetections && (
|
||||
<FormDescription>
|
||||
Select zones for Detections
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.selectDetectionsZones
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
)}
|
||||
</div>
|
||||
@@ -520,7 +560,9 @@ export default function CameraSettingsView({
|
||||
htmlFor="select-detections"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Limit detections to specific zones
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.limitDetections
|
||||
</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -528,22 +570,59 @@ export default function CameraSettingsView({
|
||||
)}
|
||||
|
||||
<div className="text-sm">
|
||||
All {detectionsLabels} objects{" "}
|
||||
<em>not classified as Alerts</em>{" "}
|
||||
{watchedDetectionsZones &&
|
||||
watchedDetectionsZones.length > 0
|
||||
? ` that are detected in ${watchedDetectionsZones.map((zone) => capitalizeFirstLetter(zone).replaceAll("_", " ")).join(", ")}`
|
||||
: ""}{" "}
|
||||
on{" "}
|
||||
{capitalizeFirstLetter(
|
||||
cameraConfig?.name ?? "",
|
||||
).replaceAll("_", " ")}{" "}
|
||||
will be shown as Detections
|
||||
{(!selectDetections ||
|
||||
(watchedDetectionsZones &&
|
||||
watchedDetectionsZones.length === 0)) &&
|
||||
", regardless of zone"}
|
||||
.
|
||||
watchedDetectionsZones.length > 0 ? (
|
||||
!selectDetections ? (
|
||||
<Trans
|
||||
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
zone: watchedDetectionsZones
|
||||
.map((zone) =>
|
||||
capitalizeFirstLetter(zone).replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
),
|
||||
)
|
||||
.join(", "),
|
||||
cameraName: capitalizeFirstLetter(
|
||||
cameraConfig?.name ?? "",
|
||||
).replaceAll("_", " "),
|
||||
}}
|
||||
ns="views/settings"
|
||||
></Trans>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
zone: watchedDetectionsZones
|
||||
.map((zone) =>
|
||||
capitalizeFirstLetter(zone).replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
),
|
||||
)
|
||||
.join(", "),
|
||||
cameraName: capitalizeFirstLetter(
|
||||
cameraConfig?.name ?? "",
|
||||
).replaceAll("_", " "),
|
||||
}}
|
||||
ns="views/settings"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="camera.reviewClassification.objectDetectionsTips"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
cameraName: capitalizeFirstLetter(
|
||||
cameraConfig?.name ?? "",
|
||||
).replaceAll("_", " "),
|
||||
}}
|
||||
ns="views/settings"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -554,26 +633,28 @@ export default function CameraSettingsView({
|
||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label="Cancel"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
<Trans>button.cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
disabled={isLoading}
|
||||
className="flex flex-1"
|
||||
aria-label="Save"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>Saving...</span>
|
||||
<span>
|
||||
<Trans>button.saving</Trans>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
"Save"
|
||||
<Trans>button.save</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
type ClassificationSettings = {
|
||||
search: {
|
||||
@@ -41,6 +42,7 @@ type ClassificationSettingsViewProps = {
|
||||
export default function ClassificationSettingsView({
|
||||
setUnsavedChanges,
|
||||
}: ClassificationSettingsViewProps) {
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
const [changedValue, setChangedValue] = useState(false);
|
||||
@@ -141,15 +143,18 @@ export default function ClassificationSettingsView({
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success("Classification settings have been saved.", {
|
||||
toast.success(t("classification.toast.success"), {
|
||||
position: "top-center",
|
||||
});
|
||||
setChangedValue(false);
|
||||
updateConfig();
|
||||
} else {
|
||||
toast.error(`Failed to save config changes: ${res.statusText}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("classification.toast.error", { errorMessage: res.statusText }),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -157,14 +162,14 @@ export default function ClassificationSettingsView({
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||
toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [updateConfig, classificationSettings.search]);
|
||||
}, [updateConfig, classificationSettings.search, t]);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
setClassificationSettings(origSearchSettings);
|
||||
@@ -188,8 +193,8 @@ export default function ClassificationSettingsView({
|
||||
}, [changedValue]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Classification Settings - Frigate";
|
||||
}, []);
|
||||
document.title = t("documentTitle.classification");
|
||||
}, [t]);
|
||||
|
||||
if (!config) {
|
||||
return <ActivityIndicator />;
|
||||
@@ -200,19 +205,15 @@ export default function ClassificationSettingsView({
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||
<Heading as="h3" className="my-2">
|
||||
Classification Settings
|
||||
{t("classification.title")}
|
||||
</Heading>
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
<Heading as="h4" className="my-2">
|
||||
Semantic Search
|
||||
{t("classification.semanticSearch.title")}
|
||||
</Heading>
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Semantic Search in Frigate allows you to find tracked objects
|
||||
within your review items using either the image itself, a
|
||||
user-defined text description, or an automatically generated one.
|
||||
</p>
|
||||
<p>{t("classification.semanticSearch.desc")}</p>
|
||||
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
@@ -221,7 +222,7 @@ export default function ClassificationSettingsView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the Documentation
|
||||
{t("classification.semanticSearch.readTheDocumentation")}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -242,7 +243,9 @@ export default function ClassificationSettingsView({
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enabled">Enabled</Label>
|
||||
<Label htmlFor="enabled">
|
||||
{t("button.enabled", { ns: "common" })}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -259,31 +262,38 @@ export default function ClassificationSettingsView({
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="reindex">Re-Index On Startup</Label>
|
||||
<Label htmlFor="reindex">
|
||||
{t("classification.semanticSearch.reindexOnStartup.label")}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
Re-indexing will reprocess all thumbnails and descriptions (if
|
||||
enabled) and apply the embeddings on each startup.{" "}
|
||||
<em>Don't forget to disable the option after restarting!</em>
|
||||
<Trans ns="views/settings">
|
||||
classification.semanticSearch.reindexOnStartup.desc
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col space-y-6">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">Model Size</div>
|
||||
<div className="text-md">
|
||||
{t("classification.semanticSearch.modelSize.label")}
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
The size of the model used for Semantic Search embeddings.
|
||||
<Trans ns="views/settings">
|
||||
classification.semanticSearch.modelSize.desc
|
||||
</Trans>
|
||||
</p>
|
||||
<ul className="list-disc pl-5 text-sm">
|
||||
<li>
|
||||
Using <em>small</em> employs a quantized version of the
|
||||
model that uses less RAM and runs faster on CPU with a very
|
||||
negligible difference in embedding quality.
|
||||
<Trans ns="views/settings">
|
||||
classification.semanticSearch.modelSize.small.desc
|
||||
</Trans>
|
||||
</li>
|
||||
<li>
|
||||
Using <em>large</em> employs the full Jina model and will
|
||||
automatically run on the GPU if applicable.
|
||||
<Trans ns="views/settings">
|
||||
classification.semanticSearch.modelSize.large.desc
|
||||
</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -309,7 +319,7 @@ export default function ClassificationSettingsView({
|
||||
className="cursor-pointer"
|
||||
value={size}
|
||||
>
|
||||
{size}
|
||||
{t("classification.semanticSearch.modelSize." + size)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
@@ -322,16 +332,11 @@ export default function ClassificationSettingsView({
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
Face Recognition
|
||||
{t("classification.faceRecognition.title")}
|
||||
</Heading>
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Face recognition allows people to be assigned names and when
|
||||
their face is recognized Frigate will assign the person's name
|
||||
as a sub label. This information is included in the UI, filters,
|
||||
as well as in notifications.
|
||||
</p>
|
||||
<p>{t("classification.faceRecognition.desc")}</p>
|
||||
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
@@ -340,7 +345,7 @@ export default function ClassificationSettingsView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the Documentation
|
||||
{t("classification.faceRecognition.readTheDocumentation")}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -361,7 +366,9 @@ export default function ClassificationSettingsView({
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enabled">Enabled</Label>
|
||||
<Label htmlFor="enabled">
|
||||
{t("button.enabled", { ns: "common" })}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -369,18 +376,11 @@ export default function ClassificationSettingsView({
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
License Plate Recognition
|
||||
{t("classification.licensePlateRecognition.title")}
|
||||
</Heading>
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Frigate can recognize license plates on vehicles and
|
||||
automatically add the detected characters to the
|
||||
recognized_license_plate field or a known name as a sub_label to
|
||||
objects that are of type car. A common use case may be to read
|
||||
the license plates of cars pulling into a driveway or cars
|
||||
passing by on a street.
|
||||
</p>
|
||||
<p>{t("classification.licensePlateRecognition.desc")}</p>
|
||||
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
@@ -389,7 +389,9 @@ export default function ClassificationSettingsView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the Documentation
|
||||
{t(
|
||||
"classification.licensePlateRecognition.readTheDocumentation",
|
||||
)}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -410,7 +412,9 @@ export default function ClassificationSettingsView({
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enabled">Enabled</Label>
|
||||
<Label htmlFor="enabled">
|
||||
{t("button.enabled", { ns: "common" })}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -420,10 +424,10 @@ export default function ClassificationSettingsView({
|
||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label="Reset"
|
||||
aria-label={t("button.reset", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Reset
|
||||
{t("button.reset", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
@@ -435,10 +439,10 @@ export default function ClassificationSettingsView({
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>Saving...</span>
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
"Save"
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,9 @@ import PolygonItem from "@/components/settings/PolygonItem";
|
||||
import { Link } from "react-router-dom";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
|
||||
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type MasksAndZoneViewProps = {
|
||||
selectedCamera: string;
|
||||
@@ -50,6 +52,7 @@ export default function MasksAndZonesView({
|
||||
selectedZoneMask,
|
||||
setUnsavedChanges,
|
||||
}: MasksAndZoneViewProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
|
||||
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
|
||||
@@ -182,8 +185,8 @@ export default function MasksAndZonesView({
|
||||
setActivePolygonIndex(undefined);
|
||||
setHoveredPolygonIndex(null);
|
||||
setUnsavedChanges(false);
|
||||
document.title = "Mask and Zone Editor - Frigate";
|
||||
}, [allPolygons, setUnsavedChanges]);
|
||||
document.title = t("documentTitle.masksAndZones");
|
||||
}, [allPolygons, setUnsavedChanges, t]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
setAllPolygons([...(editingPolygons ?? [])]);
|
||||
@@ -218,12 +221,16 @@ export default function MasksAndZonesView({
|
||||
.map((point) => `${point[0]},${point[1]}`)
|
||||
.join(","),
|
||||
);
|
||||
toast.success(`Copied coordinates for ${poly.name} to clipboard.`);
|
||||
toast.success(
|
||||
t("masksAndZones.toast.success.copyCoordinates", {
|
||||
polyName: poly.name,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error("Could not copy coordinates to clipboard.");
|
||||
toast.error(t("masksAndZones.toast.error.copyCoordinatesFailed"));
|
||||
}
|
||||
},
|
||||
[allPolygons, scaledHeight, scaledWidth],
|
||||
[allPolygons, scaledHeight, scaledWidth, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -418,8 +425,8 @@ export default function MasksAndZonesView({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Mask and Zone Editor - Frigate";
|
||||
}, []);
|
||||
document.title = t("documentTitle.masksAndZones");
|
||||
}, [t]);
|
||||
|
||||
if (!cameraConfig && !selectedCamera) {
|
||||
return <ActivityIndicator />;
|
||||
@@ -480,7 +487,7 @@ export default function MasksAndZonesView({
|
||||
{editPane === undefined && (
|
||||
<>
|
||||
<Heading as="h3" className="my-2">
|
||||
Masks / Zones
|
||||
{t("menu.masksAndZones")}
|
||||
</Heading>
|
||||
<div className="flex w-full flex-col">
|
||||
{(selectedZoneMask === undefined ||
|
||||
@@ -489,15 +496,13 @@ export default function MasksAndZonesView({
|
||||
<div className="my-3 flex flex-row items-center justify-between">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="text-md cursor-default">Zones</div>
|
||||
<div className="text-md cursor-default">
|
||||
{t("masksAndZones.zones.label")}
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className="my-2 flex flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Zones allow you to define a specific area of the
|
||||
frame so you can determine whether or not an
|
||||
object is within a particular area.
|
||||
</p>
|
||||
<p>{t("masksAndZones.zones.desc")}</p>
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/zones"
|
||||
@@ -505,7 +510,7 @@ export default function MasksAndZonesView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Documentation{" "}
|
||||
{t("masksAndZones.zones.desc.documentation")}{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -517,7 +522,7 @@ export default function MasksAndZonesView({
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
||||
aria-label="Add a new zone"
|
||||
aria-label={t("masksAndZones.zones.add")}
|
||||
onClick={() => {
|
||||
setEditPane("zone");
|
||||
handleNewPolygon("zone");
|
||||
@@ -526,7 +531,9 @@ export default function MasksAndZonesView({
|
||||
<LuPlus />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add Zone</TooltipContent>
|
||||
<TooltipContent>
|
||||
{t("masksAndZones.zones.add")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{allPolygons
|
||||
@@ -556,17 +563,12 @@ export default function MasksAndZonesView({
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="text-md cursor-default">
|
||||
Motion Masks
|
||||
{t("masksAndZones.motionMasks.label")}
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className="my-2 flex flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Motion masks are used to prevent unwanted types
|
||||
of motion from triggering detection. Over
|
||||
masking will make it more difficult for objects
|
||||
to be tracked.
|
||||
</p>
|
||||
<p>{t("masksAndZones.motionMasks.desc")}</p>
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/masks#motion-masks"
|
||||
@@ -574,7 +576,9 @@ export default function MasksAndZonesView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Documentation{" "}
|
||||
{t(
|
||||
"masksAndZones.motionMasks.desc.documentation",
|
||||
)}{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -586,7 +590,7 @@ export default function MasksAndZonesView({
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
||||
aria-label="Add a new motion mask"
|
||||
aria-label={t("masksAndZones.motionMasks.add")}
|
||||
onClick={() => {
|
||||
setEditPane("motion_mask");
|
||||
handleNewPolygon("motion_mask");
|
||||
@@ -595,7 +599,9 @@ export default function MasksAndZonesView({
|
||||
<LuPlus />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add Motion Mask</TooltipContent>
|
||||
<TooltipContent>
|
||||
{t("masksAndZones.motionMasks.add")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{allPolygons
|
||||
@@ -627,16 +633,12 @@ export default function MasksAndZonesView({
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="text-md cursor-default">
|
||||
Object Masks
|
||||
{t("masksAndZones.objectMasks.label")}
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className="my-2 flex flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Object filter masks are used to filter out false
|
||||
positives for a given object type based on
|
||||
location.
|
||||
</p>
|
||||
<p>{t("masksAndZones.objectMasks.desc")}</p>
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/masks#object-filter-masks"
|
||||
@@ -644,7 +646,7 @@ export default function MasksAndZonesView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Documentation{" "}
|
||||
{t("masksAndZones.objectMasks.documentation")}{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -656,7 +658,7 @@ export default function MasksAndZonesView({
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
||||
aria-label="Add a new object mask"
|
||||
aria-label={t("masksAndZones.objectMasks.add")}
|
||||
onClick={() => {
|
||||
setEditPane("object_mask");
|
||||
handleNewPolygon("object_mask");
|
||||
@@ -665,7 +667,9 @@ export default function MasksAndZonesView({
|
||||
<LuPlus />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add Object Mask</TooltipContent>
|
||||
<TooltipContent>
|
||||
{t("masksAndZones.objectMasks.add")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{allPolygons
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
type MotionTunerViewProps = {
|
||||
selectedCamera: string;
|
||||
@@ -37,6 +38,7 @@ export default function MotionTunerView({
|
||||
selectedCamera,
|
||||
setUnsavedChanges,
|
||||
}: MotionTunerViewProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
const [changedValue, setChangedValue] = useState(false);
|
||||
@@ -117,20 +119,29 @@ export default function MotionTunerView({
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success("Motion settings have been saved.", {
|
||||
toast.success(t("motionDetectionTuner.toast.success"), {
|
||||
position: "top-center",
|
||||
});
|
||||
setChangedValue(false);
|
||||
updateConfig();
|
||||
} else {
|
||||
toast.error(`Failed to save config changes: ${res.statusText}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("toast.save.error", {
|
||||
errorMessage: res.statusText,
|
||||
ns: "common",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
`Failed to save config changes: ${error.response.data.message}`,
|
||||
t("toast.save.error", {
|
||||
errorMessage: error.response.data.message,
|
||||
ns: "common",
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
})
|
||||
@@ -143,6 +154,7 @@ export default function MotionTunerView({
|
||||
motionSettings.contour_area,
|
||||
motionSettings.improve_contrast,
|
||||
selectedCamera,
|
||||
t,
|
||||
]);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
@@ -167,8 +179,8 @@ export default function MotionTunerView({
|
||||
}, [changedValue, selectedCamera]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Motion Tuner - Frigate";
|
||||
}, []);
|
||||
document.title = t("documentTitle.motionTuner");
|
||||
}, [t]);
|
||||
|
||||
if (!cameraConfig && !selectedCamera) {
|
||||
return <ActivityIndicator />;
|
||||
@@ -179,14 +191,10 @@ export default function MotionTunerView({
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-3/12">
|
||||
<Heading as="h3" className="my-2">
|
||||
Motion Detection Tuner
|
||||
{t("motionDetectionTuner.title")}
|
||||
</Heading>
|
||||
<div className="my-3 space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Frigate uses motion detection as a first line check to see if there
|
||||
is anything happening in the frame worth checking with object
|
||||
detection.
|
||||
</p>
|
||||
<p>{t("motionDetectionTuner.desc")}</p>
|
||||
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
@@ -195,7 +203,7 @@ export default function MotionTunerView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the Motion Tuning Guide{" "}
|
||||
{t("motionDetectionTuner.desc.documentation")}{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -205,14 +213,12 @@ export default function MotionTunerView({
|
||||
<div className="mt-2 space-y-6">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="motion-threshold" className="text-md">
|
||||
Threshold
|
||||
{t("motionDetectionTuner.Threshold")}
|
||||
</Label>
|
||||
<div className="my-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
The threshold value dictates how much of a change in a pixel's
|
||||
luminance is required to be considered motion.{" "}
|
||||
<em>Default: 30</em>
|
||||
</p>
|
||||
<Trans ns="views/settings">
|
||||
motionDetectionTuner.Threshold.desc
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between">
|
||||
@@ -236,12 +242,13 @@ export default function MotionTunerView({
|
||||
<div className="mt-2 space-y-6">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="motion-threshold" className="text-md">
|
||||
Contour Area
|
||||
{t("motionDetectionTuner.contourArea")}
|
||||
</Label>
|
||||
<div className="my-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
The contour area value is used to decide which groups of
|
||||
changed pixels qualify as motion. <em>Default: 10</em>
|
||||
<Trans ns="views/settings">
|
||||
motionDetectionTuner.contourArea.desc
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,9 +273,13 @@ export default function MotionTunerView({
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="improve-contrast">Improve Contrast</Label>
|
||||
<Label htmlFor="improve-contrast">
|
||||
{t("motionDetectionTuner.improveContrast")}
|
||||
</Label>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Improve contrast for darker scenes. <em>Default: ON</em>
|
||||
<Trans ns="views/settings">
|
||||
motionDetectionTuner.improveContrast.desc
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -286,25 +297,25 @@ export default function MotionTunerView({
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label="Reset"
|
||||
aria-label={t("button.reset", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Reset
|
||||
{t("button.reset", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
disabled={!changedValue || isLoading}
|
||||
className="flex flex-1"
|
||||
aria-label="Save"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
onClick={saveToConfig}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>Saving...</span>
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
"Save"
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -18,8 +18,10 @@ import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import axios from "axios";
|
||||
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
|
||||
import { CiCircleAlert } from "react-icons/ci";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
|
||||
|
||||
@@ -56,6 +59,8 @@ type NotificationsSettingsViewProps = {
|
||||
export default function NotificationView({
|
||||
setUnsavedChanges,
|
||||
}: NotificationsSettingsViewProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const { data: config, mutate: updateConfig } = useSWR<FrigateConfig>(
|
||||
"config",
|
||||
{
|
||||
@@ -138,23 +143,20 @@ export default function NotificationView({
|
||||
sub: pushSubscription,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to save notification registration.", {
|
||||
toast.error(t("notification.toast.error.registerFailed"), {
|
||||
position: "top-center",
|
||||
});
|
||||
pushSubscription.unsubscribe();
|
||||
registration.unregister();
|
||||
setRegistration(null);
|
||||
});
|
||||
toast.success(
|
||||
"Successfully registered for notifications. Restarting Frigate is required before any notifications (including a test notification) can be sent.",
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
toast.success(t("notification.toast.success.registered"), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
[publicKey, addMessage],
|
||||
[publicKey, addMessage, t],
|
||||
);
|
||||
|
||||
// notification state
|
||||
@@ -256,14 +258,20 @@ export default function NotificationView({
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success("Notification settings have been saved.", {
|
||||
toast.success(t("notification.toast.success.settingSaved"), {
|
||||
position: "top-center",
|
||||
});
|
||||
updateConfig();
|
||||
} else {
|
||||
toast.error(`Failed to save config changes: ${res.statusText}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("toast.save.error", {
|
||||
errorMessage: res.statusText,
|
||||
ns: "common",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -271,7 +279,7 @@ export default function NotificationView({
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||
toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
})
|
||||
@@ -279,7 +287,7 @@ export default function NotificationView({
|
||||
setIsLoading(false);
|
||||
});
|
||||
},
|
||||
[updateConfig, setIsLoading, allCameras],
|
||||
[updateConfig, setIsLoading, allCameras, t],
|
||||
);
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
@@ -293,14 +301,11 @@ export default function NotificationView({
|
||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<Heading as="h3" className="my-2">
|
||||
Notification Settings
|
||||
{t("notification.notificationSettings.title")}
|
||||
</Heading>
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Frigate can natively send push notifications to your device
|
||||
when it is running in the browser or installed as a PWA.
|
||||
</p>
|
||||
<p>{t("notification.notificationSettings.desc")}</p>
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/notifications"
|
||||
@@ -308,7 +313,9 @@ export default function NotificationView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the Documentation{" "}
|
||||
<p>
|
||||
{t("notification.notificationSettings.documentation")}
|
||||
</p>{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -316,12 +323,13 @@ export default function NotificationView({
|
||||
</div>
|
||||
<Alert variant="destructive">
|
||||
<CiCircleAlert className="size-5" />
|
||||
<AlertTitle>Notifications Unavailable</AlertTitle>
|
||||
|
||||
<AlertTitle>
|
||||
{t("notification.notificationUnavailable.title")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Web push notifications require a secure context (
|
||||
<code>https://...</code>). This is a browser limitation. Access
|
||||
Frigate securely to use notifications.
|
||||
<Trans ns="views/settings">
|
||||
notification.notificationUnavailable.desc
|
||||
</Trans>
|
||||
<div className="mt-3 flex items-center">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/authentication"
|
||||
@@ -329,7 +337,9 @@ export default function NotificationView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the Documentation{" "}
|
||||
<p>
|
||||
{t("notification.notificationUnavailable.documentation")}
|
||||
</p>{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -349,15 +359,12 @@ export default function NotificationView({
|
||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<Heading as="h3" className="my-2">
|
||||
Notification Settings
|
||||
{t("notification.notificationSettings.title")}
|
||||
</Heading>
|
||||
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Frigate can natively send push notifications to your device
|
||||
when it is running in the browser or installed as a PWA.
|
||||
</p>
|
||||
<p>{t("notification.notificationSettings.desc")}</p>
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/notifications"
|
||||
@@ -365,7 +372,7 @@ export default function NotificationView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the Documentation{" "}
|
||||
{t("notification.notificationSettings.documentation")}{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -382,17 +389,16 @@ export default function NotificationView({
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormLabel>{t("notification.email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
|
||||
placeholder="example@email.com"
|
||||
placeholder={t("notification.email.placeholder")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Entering a valid email is required, as this is used by
|
||||
the push server in case problems occur.
|
||||
{t("notification.email.desc")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -408,7 +414,7 @@ export default function NotificationView({
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<FormLabel className="flex flex-row items-center text-base">
|
||||
Cameras
|
||||
{t("notification.cameras")}
|
||||
</FormLabel>
|
||||
</div>
|
||||
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
|
||||
@@ -417,7 +423,9 @@ export default function NotificationView({
|
||||
name="allEnabled"
|
||||
render={({ field }) => (
|
||||
<FilterSwitch
|
||||
label="All Cameras"
|
||||
label={t("cameras.all", {
|
||||
ns: "components/filter",
|
||||
})}
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
setChangedValue(true);
|
||||
@@ -456,13 +464,13 @@ export default function NotificationView({
|
||||
</>
|
||||
) : (
|
||||
<div className="font-normal text-destructive">
|
||||
No cameras available.
|
||||
{t("notification.cameras.noCameras")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
Select the cameras to enable notifications for.
|
||||
{t("notification.cameras.desc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -471,26 +479,26 @@ export default function NotificationView({
|
||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[50%]">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label="Cancel"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
disabled={isLoading}
|
||||
className="flex flex-1"
|
||||
aria-label="Save"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>Saving...</span>
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
"Save"
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -503,7 +511,7 @@ export default function NotificationView({
|
||||
<div className="flex flex-col gap-2 md:max-w-[50%]">
|
||||
<Separator className="my-2 flex bg-secondary md:hidden" />
|
||||
<Heading as="h4" className="my-2">
|
||||
Device-Specific Settings
|
||||
{t("notification.deviceSpecific")}
|
||||
</Heading>
|
||||
<Button
|
||||
aria-label="Register or unregister notifications for this device"
|
||||
@@ -546,14 +554,16 @@ export default function NotificationView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{`${registration != null ? "Unregister" : "Register"} this device`}
|
||||
{registration != null
|
||||
? t("notification.unregisterDevice")
|
||||
: t("notification.registerDevice")}
|
||||
</Button>
|
||||
{registration != null && registration.active && (
|
||||
<Button
|
||||
aria-label="Send a test notification"
|
||||
aria-label={t("notification.sendTestNotification")}
|
||||
onClick={() => sendTestNotification("notification_test")}
|
||||
>
|
||||
Send a test notification
|
||||
{t("notification.sendTestNotification")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -563,14 +573,11 @@ export default function NotificationView({
|
||||
<div className="space-y-3">
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
<Heading as="h4" className="my-2">
|
||||
Global Settings
|
||||
{t("notification.globalSettings.title")}
|
||||
</Heading>
|
||||
<div className="max-w-xl">
|
||||
<div className="mb-5 mt-2 flex flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Temporarily suspend notifications for specific cameras
|
||||
on all registered devices.
|
||||
</p>
|
||||
<p>{t("notification.globalSettings.desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -606,6 +613,7 @@ export function CameraNotificationSwitch({
|
||||
config,
|
||||
camera,
|
||||
}: CameraNotificationSwitchProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { payload: notificationState, send: sendNotification } =
|
||||
useNotifications(camera);
|
||||
const { payload: notificationSuspendUntil, send: sendNotificationSuspend } =
|
||||
@@ -635,14 +643,19 @@ export function CameraNotificationSwitch({
|
||||
};
|
||||
|
||||
const formatSuspendedUntil = (timestamp: string) => {
|
||||
if (timestamp === "0") return "Frigate restarts.";
|
||||
// Some languages require a change in word order
|
||||
if (timestamp === "0") return t("time.untilForRestart", { ns: "common" });
|
||||
|
||||
return formatUnixTimestampToDateTime(parseInt(timestamp), {
|
||||
const time = formatUnixTimestampToDateTime(parseInt(timestamp), {
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
timezone: config?.ui.timezone,
|
||||
strftime_fmt: `%b %d, ${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`,
|
||||
strftime_fmt:
|
||||
config?.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
|
||||
: t("time.formattedTimestampExcludeSeconds", { ns: "common" }),
|
||||
});
|
||||
return t("time.untilForTime", { ns: "common", time });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -664,12 +677,13 @@ export function CameraNotificationSwitch({
|
||||
|
||||
{!isSuspended ? (
|
||||
<div className="flex flex-row items-center gap-2 text-sm text-success">
|
||||
Notifications Active
|
||||
{t("notification.active")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row items-center gap-2 text-sm text-danger">
|
||||
Notifications suspended until{" "}
|
||||
{formatSuspendedUntil(notificationSuspendUntil)}
|
||||
{t("notification.suspended", {
|
||||
time: formatSuspendedUntil(notificationSuspendUntil),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -682,13 +696,27 @@ export function CameraNotificationSwitch({
|
||||
<SelectValue placeholder="Suspend" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">Suspend for 5 minutes</SelectItem>
|
||||
<SelectItem value="10">Suspend for 10 minutes</SelectItem>
|
||||
<SelectItem value="30">Suspend for 30 minutes</SelectItem>
|
||||
<SelectItem value="60">Suspend for 1 hour</SelectItem>
|
||||
<SelectItem value="840">Suspend for 12 hours</SelectItem>
|
||||
<SelectItem value="1440">Suspend for 24 hours</SelectItem>
|
||||
<SelectItem value="off">Suspend until restart</SelectItem>
|
||||
<SelectItem value="5">
|
||||
{t("notification.suspendTime.5minutes")}
|
||||
</SelectItem>
|
||||
<SelectItem value="10">
|
||||
{t("notification.suspendTime.10minutes")}
|
||||
</SelectItem>
|
||||
<SelectItem value="30">
|
||||
{t("notification.suspendTime.30minutes")}
|
||||
</SelectItem>
|
||||
<SelectItem value="60">
|
||||
{t("notification.suspendTime.1hour")}
|
||||
</SelectItem>
|
||||
<SelectItem value="840">
|
||||
{t("notification.suspendTime.12hour")}
|
||||
</SelectItem>
|
||||
<SelectItem value="1440">
|
||||
{t("notification.suspendTime.24hour")}
|
||||
</SelectItem>
|
||||
<SelectItem value="off">
|
||||
{t("notification.suspendTime.untilRestart")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
@@ -697,7 +725,7 @@ export function CameraNotificationSwitch({
|
||||
size="sm"
|
||||
onClick={handleCancelSuspension}
|
||||
>
|
||||
Cancel Suspension
|
||||
{t("notification.cancelSuspension")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,9 +23,11 @@ import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { LuExternalLink, LuInfo } from "react-icons/lu";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
type ObjectSettingsViewProps = {
|
||||
selectedCamera?: string;
|
||||
@@ -38,6 +40,8 @@ const emptyObject = Object.freeze({});
|
||||
export default function ObjectSettingsView({
|
||||
selectedCamera,
|
||||
}: ObjectSettingsViewProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -45,80 +49,45 @@ export default function ObjectSettingsView({
|
||||
const DEBUG_OPTIONS = [
|
||||
{
|
||||
param: "bbox",
|
||||
title: "Bounding boxes",
|
||||
description: "Show bounding boxes around tracked objects",
|
||||
title: t("debug.boundingBoxes.title"),
|
||||
description: t("debug.boundingBoxes.desc"),
|
||||
info: (
|
||||
<>
|
||||
<p className="mb-2">
|
||||
<strong>Object Bounding Box Colors</strong>
|
||||
<strong>{t("debug.boundingBoxes.colors.label")}</strong>
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
At startup, different colors will be assigned to each object label
|
||||
</li>
|
||||
<li>
|
||||
A dark blue thin line indicates that object is not detected at
|
||||
this current point in time
|
||||
</li>
|
||||
<li>
|
||||
A gray thin line indicates that object is detected as being
|
||||
stationary
|
||||
</li>
|
||||
<li>
|
||||
A thick line indicates that object is the subject of autotracking
|
||||
(when enabled)
|
||||
</li>
|
||||
<Trans ns="views/settings">debug.boundingBoxes.colors.info</Trans>
|
||||
</ul>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
param: "timestamp",
|
||||
title: "Timestamp",
|
||||
description: "Overlay a timestamp on the image",
|
||||
title: t("debug.timestamp.title"),
|
||||
description: t("debug.timestamp.desc"),
|
||||
},
|
||||
{
|
||||
param: "zones",
|
||||
title: "Zones",
|
||||
description: "Show an outline of any defined zones",
|
||||
title: t("debug.zones.title"),
|
||||
description: t("debug.zones.desc"),
|
||||
},
|
||||
{
|
||||
param: "mask",
|
||||
title: "Motion masks",
|
||||
description: "Show motion mask polygons",
|
||||
title: t("debug.mask.title"),
|
||||
description: t("debug.mask.desc"),
|
||||
},
|
||||
{
|
||||
param: "motion",
|
||||
title: "Motion boxes",
|
||||
description: "Show boxes around areas where motion is detected",
|
||||
info: (
|
||||
<>
|
||||
<p className="mb-2">
|
||||
<strong>Motion Boxes</strong>
|
||||
</p>
|
||||
<p>
|
||||
Red boxes will be overlaid on areas of the frame where motion is
|
||||
currently being detected
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
title: t("debug.motion.title"),
|
||||
description: t("debug.motion.desc"),
|
||||
info: <Trans ns="views/settings">debug.motion.tips</Trans>,
|
||||
},
|
||||
{
|
||||
param: "regions",
|
||||
title: "Regions",
|
||||
description:
|
||||
"Show a box of the region of interest sent to the object detector",
|
||||
info: (
|
||||
<>
|
||||
<p className="mb-2">
|
||||
<strong>Region Boxes</strong>
|
||||
</p>
|
||||
<p>
|
||||
Bright green boxes will be overlaid on areas of interest in the
|
||||
frame that are being sent to the object detector.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
title: t("debug.regions.title"),
|
||||
description: t("debug.regions.desc"),
|
||||
info: <Trans ns="views/settings">debug.regions.tips</Trans>,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -167,8 +136,8 @@ export default function ObjectSettingsView({
|
||||
}, [options, optionsLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Object Settings - Frigate";
|
||||
}, []);
|
||||
document.title = t("documentTitle.object");
|
||||
}, [t]);
|
||||
|
||||
if (!cameraConfig) {
|
||||
return <ActivityIndicator />;
|
||||
@@ -179,25 +148,19 @@ export default function ObjectSettingsView({
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-3/12">
|
||||
<Heading as="h3" className="my-2">
|
||||
Debug
|
||||
{t("debug.title")}
|
||||
</Heading>
|
||||
<div className="mb-5 space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Frigate uses your detectors{" "}
|
||||
{config
|
||||
? "(" +
|
||||
Object.keys(config?.detectors)
|
||||
.map((detector) => capitalizeFirstLetter(detector))
|
||||
.join(",") +
|
||||
")"
|
||||
: ""}{" "}
|
||||
to detect objects in your camera's video stream.
|
||||
</p>
|
||||
<p>
|
||||
Debugging view shows a real-time view of tracked objects and their
|
||||
statistics. The object list shows a time-delayed summary of detected
|
||||
objects.
|
||||
{t("debug.detectorDesc", {
|
||||
detectors: config
|
||||
? Object.keys(config?.detectors)
|
||||
.map((detector) => capitalizeFirstLetter(detector))
|
||||
.join(",")
|
||||
: "",
|
||||
})}
|
||||
</p>
|
||||
<p>{t("debug.desc")}</p>
|
||||
</div>
|
||||
{config?.cameras[cameraConfig.name]?.webui_url && (
|
||||
<div className="mb-5 text-sm text-muted-foreground">
|
||||
@@ -217,8 +180,10 @@ export default function ObjectSettingsView({
|
||||
|
||||
<Tabs defaultValue="debug" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="debug">Debugging</TabsTrigger>
|
||||
<TabsTrigger value="objectlist">Object List</TabsTrigger>
|
||||
<TabsTrigger value="debug">{t("debug.debugging")}</TabsTrigger>
|
||||
<TabsTrigger value="objectlist">
|
||||
{t("debug.objectList")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="debug">
|
||||
<div className="flex w-full flex-col space-y-6">
|
||||
@@ -277,21 +242,20 @@ export default function ObjectSettingsView({
|
||||
className="mb-0 cursor-pointer capitalize text-primary"
|
||||
htmlFor="debugdraw"
|
||||
>
|
||||
Object Shape Filter Drawing
|
||||
{t("debug.objectShapeFilterDrawing.title")}
|
||||
</Label>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-sm">
|
||||
Enable this option to draw a rectangle on the
|
||||
camera image to show its area and ratio. These
|
||||
values can then be used to set object shape filter
|
||||
parameters in your config.
|
||||
{t("debug.objectShapeFilterDrawing.tips")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/object_filters#object-shape"
|
||||
@@ -299,7 +263,7 @@ export default function ObjectSettingsView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
{t("debug.objectShapeFilterDrawing.document")}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -307,8 +271,7 @@ export default function ObjectSettingsView({
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Draw a rectangle on the image to view area and ratio
|
||||
details
|
||||
{t("debug.objectShapeFilterDrawing.desc")}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -364,6 +327,7 @@ type ObjectListProps = {
|
||||
};
|
||||
|
||||
function ObjectList({ cameraConfig, objects }: ObjectListProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const colormap = useMemo(() => {
|
||||
@@ -409,7 +373,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
|
||||
<div className="text-md mr-2 w-1/3">
|
||||
<div className="flex flex-col items-end justify-end">
|
||||
<p className="mb-1.5 text-sm text-primary-variant">
|
||||
Score
|
||||
{t("debug.objectShapeFilterDrawing.score")}
|
||||
</p>
|
||||
{obj.score
|
||||
? (obj.score * 100).toFixed(1).toString()
|
||||
@@ -420,7 +384,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
|
||||
<div className="text-md mr-2 w-1/3">
|
||||
<div className="flex flex-col items-end justify-end">
|
||||
<p className="mb-1.5 text-sm text-primary-variant">
|
||||
Ratio
|
||||
{t("debug.objectShapeFilterDrawing.ratio")}
|
||||
</p>
|
||||
{obj.ratio ? obj.ratio.toFixed(2).toString() : "-"}
|
||||
</div>
|
||||
@@ -428,7 +392,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
|
||||
<div className="text-md mr-2 w-1/3">
|
||||
<div className="flex flex-col items-end justify-end">
|
||||
<p className="mb-1.5 text-sm text-primary-variant">
|
||||
Area
|
||||
{t("debug.objectShapeFilterDrawing.area")}
|
||||
</p>
|
||||
{obj.area ? (
|
||||
<>
|
||||
@@ -457,7 +421,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="p-3 text-center">No objects</div>
|
||||
<div className="p-3 text-center">{t("debug.noObjects")}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,13 +18,14 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "../../components/ui/select";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
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 clearStoredLayouts = useCallback(() => {
|
||||
if (!config) {
|
||||
return [];
|
||||
@@ -33,21 +34,29 @@ export default function UiSettingsView() {
|
||||
Object.entries(config.camera_groups).forEach(async (value) => {
|
||||
await delData(`${value[0]}-draggable-layout`)
|
||||
.then(() => {
|
||||
toast.success(`Cleared stored layout for ${value[0]}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
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(`Failed to clear stored layout: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("general.toast.error.clearStoredLayoutFailed", { errorMessage }),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}, [config]);
|
||||
}, [config, t]);
|
||||
|
||||
const clearStreamingSettings = useCallback(async () => {
|
||||
if (!config) {
|
||||
@@ -56,7 +65,7 @@ export default function UiSettingsView() {
|
||||
|
||||
await delData(`streaming-settings`)
|
||||
.then(() => {
|
||||
toast.success(`Cleared streaming settings for all camera groups.`, {
|
||||
toast.success(t("general.toast.success.clearStreamingSettings"), {
|
||||
position: "top-center",
|
||||
});
|
||||
})
|
||||
@@ -65,15 +74,20 @@ export default function UiSettingsView() {
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to clear streaming settings: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("general.toast.error.clearStreamingSettingsFailed", {
|
||||
errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
});
|
||||
}, [config]);
|
||||
}, [config, t]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "General Settings - Frigate";
|
||||
}, []);
|
||||
document.title = t("documentTitle.general");
|
||||
}, [t]);
|
||||
|
||||
// settings
|
||||
|
||||
@@ -88,13 +102,13 @@ export default function UiSettingsView() {
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||
<Heading as="h3" className="my-2">
|
||||
General Settings
|
||||
{t("general.title")}
|
||||
</Heading>
|
||||
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
Live Dashboard
|
||||
{t("general.liveDashboard.title")}
|
||||
</Heading>
|
||||
|
||||
<div className="mt-2 space-y-6">
|
||||
@@ -106,19 +120,11 @@ export default function UiSettingsView() {
|
||||
onCheckedChange={setAutoLive}
|
||||
/>
|
||||
<Label className="cursor-pointer" htmlFor="auto-live">
|
||||
Automatic Live View
|
||||
{t("general.liveDashboard.automaticLiveView.label")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
|
||||
<p>
|
||||
Automatically switch to a camera's live view when activity is
|
||||
detected. Disabling this option causes static camera images on
|
||||
the your dashboards to only update once per minute.{" "}
|
||||
<em>
|
||||
This is a global setting but can be overridden on each
|
||||
camera <strong>in camera groups only</strong>.
|
||||
</em>
|
||||
</p>
|
||||
<p>{t("general.liveDashboard.automaticLiveView.desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
@@ -129,15 +135,11 @@ export default function UiSettingsView() {
|
||||
onCheckedChange={setAlertVideos}
|
||||
/>
|
||||
<Label className="cursor-pointer" htmlFor="images-only">
|
||||
Play Alert Videos
|
||||
{t("general.liveDashboard.playAlertVideos.label")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
|
||||
<p>
|
||||
By default, recent alerts on the Live dashboard play as small
|
||||
looping videos. Disable this option to only show a static
|
||||
image of recent alerts on this device/browser.
|
||||
</p>
|
||||
<p>{t("general.liveDashboard.playAlertVideos.desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,52 +147,53 @@ export default function UiSettingsView() {
|
||||
<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">Stored Layouts</div>
|
||||
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
|
||||
<p>
|
||||
The layout of cameras in a camera group can be
|
||||
dragged/resized. The positions are stored in your browser's
|
||||
local storage.
|
||||
</p>
|
||||
<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="Clear all saved layouts"
|
||||
aria-label={t("general.storedLayouts.clearAll")}
|
||||
onClick={clearStoredLayouts}
|
||||
>
|
||||
Clear All Layouts
|
||||
{t("general.storedLayouts.clearAll")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">Camera Group Streaming Settings</div>
|
||||
<div className="text-md">
|
||||
{t("general.cameraGroupStreaming.title")}
|
||||
</div>
|
||||
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
|
||||
<p>
|
||||
Streaming settings for each camera group are stored in your
|
||||
browser's local storage.
|
||||
</p>
|
||||
<p>{t("general.cameraGroupStreaming.desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Clear all group streaming settings"
|
||||
aria-label={t("general.cameraGroupStreaming.clearAll")}
|
||||
onClick={clearStreamingSettings}
|
||||
>
|
||||
Clear All Streaming Settings
|
||||
{t("general.cameraGroupStreaming.clearAll")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
Recordings Viewer
|
||||
{t("general.recordingsViewer.title")}
|
||||
</Heading>
|
||||
|
||||
<div className="mt-2 space-y-6">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">Default Playback Rate</div>
|
||||
<div className="text-md">
|
||||
{t("general.recordingsViewer.defaultPlaybackRate.label")}
|
||||
</div>
|
||||
<div className="my-2 text-sm text-muted-foreground">
|
||||
<p>Default playback rate for recordings playback.</p>
|
||||
<p>
|
||||
{t("general.recordingsViewer.defaultPlaybackRate.desc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,14 +221,16 @@ export default function UiSettingsView() {
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
Calendar
|
||||
{t("general.calendar.title")}
|
||||
</Heading>
|
||||
|
||||
<div className="mt-2 space-y-6">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">First Weekday</div>
|
||||
<div className="text-md">
|
||||
{t("general.calendar.firstWeekday.label")}
|
||||
</div>
|
||||
<div className="my-2 text-sm text-muted-foreground">
|
||||
<p>The day that the weeks of the review calendar begin on.</p>
|
||||
<p>{t("general.calendar.firstWeekday.desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,7 +239,10 @@ export default function UiSettingsView() {
|
||||
onValueChange={(value) => setWeekStartsOn(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
{WEEK_STARTS_ON[weekStartsOn ?? 0]}
|
||||
{t(
|
||||
"general.calendar.firstWeekday." +
|
||||
WEEK_STARTS_ON[weekStartsOn ?? 0].toLowerCase(),
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
@@ -244,7 +252,7 @@ export default function UiSettingsView() {
|
||||
className="cursor-pointer"
|
||||
value={index.toString()}
|
||||
>
|
||||
{day}
|
||||
{t("general.calendar.firstWeekday." + day.toLowerCase())}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
|
||||
Reference in New Issue
Block a user