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:
GuoQing Liu
2025-03-16 10:36:20 -05:00
committed by GitHub
co-authored by Josh Hawkins
parent db541abed4
commit d34533981f
150 changed files with 6809 additions and 1926 deletions
+81 -49
View File
@@ -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>
)}
+148 -67
View File
@@ -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>
+40 -36
View File
@@ -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
+41 -30
View File
@@ -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>
+47 -83
View File
@@ -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>
);
+65 -57
View File
@@ -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>