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
@@ -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>