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
+43 -21
View File
@@ -15,6 +15,7 @@ import { useEffect, useState } from "react";
import axios from "axios";
import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
import { Trans, useTranslation } from "react-i18next";
type CameraInfoDialogProps = {
camera: CameraConfig;
@@ -26,6 +27,7 @@ export default function CameraInfoDialog({
showCameraInfoDialog,
setShowCameraInfoDialog,
}: CameraInfoDialogProps) {
const { t } = useTranslation(["views/system"]);
const [ffprobeInfo, setFfprobeInfo] = useState<Ffprobe[]>();
useEffect(() => {
@@ -39,15 +41,25 @@ export default function CameraInfoDialog({
if (res.status === 200) {
setFfprobeInfo(res.data);
} else {
toast.error(`Unable to probe camera: ${res.statusText}`, {
position: "top-center",
});
toast.error(
t("cameras.toast.success.copyToClipboard", {
errorMessage: res.statusText,
}),
{
position: "top-center",
},
);
}
})
.catch((error) => {
toast.error(`Unable to probe camera: ${error.response.data.message}`, {
position: "top-center",
});
toast.error(
t("cameras.toast.success.copyToClipboard", {
errorMessage: error.response.data.message,
}),
{
position: "top-center",
},
);
});
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -55,7 +67,7 @@ export default function CameraInfoDialog({
const onCopyFfprobe = async () => {
copy(JSON.stringify(ffprobeInfo));
toast.success("Copied probe data to clipboard.");
toast.success(t("cameras.toast.success.copyToClipboard"));
};
function gcd(a: number, b: number): number {
@@ -72,11 +84,13 @@ export default function CameraInfoDialog({
<DialogContent>
<DialogHeader>
<DialogTitle className="capitalize">
{camera.name.replaceAll("_", " ")} Camera Probe Info
{t("cameras.info.cameraProbeInfo", {
camera: camera.name.replaceAll("_", " "),
})}
</DialogTitle>
</DialogHeader>
<DialogDescription>
Stream data is obtained with <code>ffprobe</code>.
<Trans ns="views/system">cameras.info.streamDataFromFFPROBE</Trans>
</DialogDescription>
<div className="mb-2 p-4">
@@ -85,7 +99,9 @@ export default function CameraInfoDialog({
{ffprobeInfo.map((stream, idx) => (
<div key={idx} className="mb-5">
<div className="mb-1 rounded-md bg-secondary p-2 text-lg text-primary">
Stream {idx + 1}
{t("cameras.info.stream", {
idx: idx + 1,
})}
</div>
{stream.return_code == 0 ? (
<div>
@@ -93,10 +109,12 @@ export default function CameraInfoDialog({
<div className="" key={idx}>
{codec.width ? (
<div className="text-muted-foreground">
<div className="ml-2">Video:</div>
<div className="ml-2">
{t("cameras.info.video")}
</div>
<div className="ml-5">
<div>
Codec:
{t("cameras.info.codec")}
<span className="text-primary">
{" "}
{codec.codec_long_name}
@@ -105,7 +123,7 @@ export default function CameraInfoDialog({
<div>
{codec.width && codec.height ? (
<>
Resolution:{" "}
{t("cameras.info.resolution")}{" "}
<span className="text-primary">
{" "}
{codec.width}x{codec.height} (
@@ -119,7 +137,7 @@ export default function CameraInfoDialog({
</>
) : (
<span>
Resolution:{" "}
{t("cameras.info.resolution")}{" "}
<span className="text-primary">
Unknown
</span>
@@ -127,10 +145,10 @@ export default function CameraInfoDialog({
)}
</div>
<div>
FPS:{" "}
{t("cameras.info.fps")}{" "}
<span className="text-primary">
{codec.avg_frame_rate == "0/0"
? "Unknown"
? t("cameras.info.unknown")
: codec.avg_frame_rate}
</span>
</div>
@@ -140,7 +158,7 @@ export default function CameraInfoDialog({
<div className="text-muted-foreground">
<div className="ml-2 mt-1">Audio:</div>
<div className="ml-4">
Codec:{" "}
{t("cameras.info.codec")}{" "}
<span className="text-primary">
{codec.codec_long_name}
</span>
@@ -152,7 +170,11 @@ export default function CameraInfoDialog({
</div>
) : (
<div className="px-2">
<div>Error: {stream.stderr}</div>
<div>
{t("cameras.info.error", {
error: stream.stderr,
})}
</div>
</div>
)}
</div>
@@ -161,7 +183,7 @@ export default function CameraInfoDialog({
) : (
<div className="flex flex-col items-center">
<ActivityIndicator />
<div className="mt-2">Fetching Camera Data</div>
<div className="mt-2">{t("cameras.info.fetching")}</div>
</div>
)}
</div>
@@ -169,10 +191,10 @@ export default function CameraInfoDialog({
<DialogFooter>
<Button
variant="select"
aria-label="Copy"
aria-label={t("button.copy", { ns: "common" })}
onClick={() => onCopyFfprobe()}
>
Copy
{t("button.copy", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>
+30 -26
View File
@@ -22,6 +22,7 @@ import {
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import {
Select,
SelectContent,
@@ -31,6 +32,7 @@ import {
} from "../ui/select";
import { Shield, User } from "lucide-react";
import { LuCheck, LuX } from "react-icons/lu";
import { useTranslation } from "react-i18next";
type CreateUserOverlayProps = {
show: boolean;
@@ -43,22 +45,23 @@ export default function CreateUserDialog({
onCreate,
onCancel,
}: CreateUserOverlayProps) {
const { t } = useTranslation(["views/settings"]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const formSchema = z
.object({
user: z
.string()
.min(1, "Username is required")
.min(1, t("users.dialog.form.usernameIsRequired"))
.regex(/^[A-Za-z0-9._]+$/, {
message: "Username may only include letters, numbers, . or _",
message: t("users.dialog.createUser.usernameOnlyInclude"),
}),
password: z.string().min(1, "Password is required"),
confirmPassword: z.string().min(1, "Please confirm your password"),
role: z.enum(["admin", "viewer"]),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
message: t("users.dialog.form.password.notMatch"),
path: ["confirmPassword"],
});
@@ -109,10 +112,9 @@ export default function CreateUserDialog({
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create New User</DialogTitle>
<DialogTitle>{t("users.dialog.createUser.title")}</DialogTitle>
<DialogDescription>
Add a new user account and specify an role for access to areas of
the Frigate UI.
{t("users.dialog.createUser.desc")}
</DialogDescription>
</DialogHeader>
@@ -126,17 +128,17 @@ export default function CreateUserDialog({
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
Username
{t("users.dialog.form.user")}
</FormLabel>
<FormControl>
<Input
placeholder="Enter username"
placeholder={t("users.dialog.form.user.placeholder")}
className="h-10"
{...field}
/>
</FormControl>
<FormDescription className="text-xs text-muted-foreground">
Only letters, numbers, periods and underscores allowed.
{t("users.dialog.form.user.desc")}
</FormDescription>
<FormMessage />
</FormItem>
@@ -148,11 +150,11 @@ export default function CreateUserDialog({
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
Password
{t("users.dialog.form.password")}
</FormLabel>
<FormControl>
<Input
placeholder="Enter password"
placeholder={t("users.dialog.form.password.placeholder")}
type="password"
className="h-10"
{...field}
@@ -168,11 +170,13 @@ export default function CreateUserDialog({
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
Confirm Password
{t("users.dialog.form.password.confirm")}
</FormLabel>
<FormControl>
<Input
placeholder="Confirm password"
placeholder={t(
"users.dialog.form.password.confirm.placeholder",
)}
type="password"
className="h-10"
{...field}
@@ -184,14 +188,14 @@ export default function CreateUserDialog({
<>
<LuCheck className="size-3.5 text-green-500" />
<span className="text-green-600">
Passwords match
{t("users.dialog.form.password.match")}
</span>
</>
) : (
<>
<LuX className="size-3.5 text-red-500" />
<span className="text-red-600">
Passwords don't match
{t("users.dialog.form.password.notMatch")}
</span>
</>
)}
@@ -206,7 +210,9 @@ export default function CreateUserDialog({
name="role"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">Role</FormLabel>
<FormLabel className="text-sm font-medium">
{t("role.title", { ns: "common" })}
</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
@@ -223,7 +229,7 @@ export default function CreateUserDialog({
>
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-primary" />
<span>Admin</span>
<span>{t("role.admin", { ns: "common" })}</span>
</div>
</SelectItem>
<SelectItem
@@ -232,15 +238,13 @@ export default function CreateUserDialog({
>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>Viewer</span>
<span>{t("role.viewer", { ns: "common" })}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormDescription className="text-xs text-muted-foreground">
Admins have full access to all features in the Frigate UI.
Viewers are limited to viewing cameras, review items, and
historical footage in the UI.
{t("role.desc", { ns: "common" })}
</FormDescription>
<FormMessage />
</FormItem>
@@ -252,16 +256,16 @@ export default function CreateUserDialog({
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label="Cancel"
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
Cancel
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label="Save"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
@@ -269,10 +273,10 @@ export default function CreateUserDialog({
{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>
@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { DialogDescription } from "@radix-ui/react-dialog";
@@ -20,23 +20,22 @@ export default function DeleteUserDialog({
onDelete,
onCancel,
}: DeleteUserDialogProps) {
const { t } = useTranslation(["views/settings"]);
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="flex flex-col items-center gap-2 sm:items-start">
<div className="space-y-1 text-center sm:text-left">
<DialogTitle>Delete User</DialogTitle>
{t("users.dialog.deleteUser.title")}
<DialogDescription>
This action cannot be undone. This will permanently delete the
user account and remove all associated data.
{t("users.dialog.deleteUser.desc")}
</DialogDescription>
</div>
</DialogHeader>
<div className="my-4 rounded-md border border-destructive/20 bg-destructive/5 p-4 text-center text-sm">
<p className="font-medium text-destructive">
Are you sure you want to delete{" "}
<span className="font-bold">{username}</span>?
{t("users.dialog.deleteUser.warn", { username })}
</p>
</div>
@@ -45,19 +44,19 @@ export default function DeleteUserDialog({
<div className="flex flex-row gap-2 pt-5">
<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="destructive"
aria-label="Delete"
aria-label={t("button.delete", { ns: "common" })}
className="flex flex-1"
onClick={onDelete}
>
Delete User
{t("button.delete", { ns: "common" })}
</Button>
</div>
</div>
+45 -28
View File
@@ -30,6 +30,7 @@ import { getUTCOffset } from "@/utils/dateUtil";
import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
import { GenericVideoPlayer } from "../player/GenericVideoPlayer";
import { useTranslation } from "react-i18next";
const EXPORT_OPTIONS = [
"1",
@@ -64,16 +65,19 @@ export default function ExportDialog({
setMode,
setShowPreview,
}: ExportDialogProps) {
const { t } = useTranslation(["components/dialog"]);
const [name, setName] = useState("");
const onStartExport = useCallback(() => {
if (!range) {
toast.error("No valid time range selected", { position: "top-center" });
toast.error(t("export.toast.error.noVaildTimeSelected"), {
position: "top-center",
});
return;
}
if (range.before < range.after) {
toast.error("End time must be after start time", {
toast.error(t("export.toast.error.endTimeMustAfterStartTime"), {
position: "top-center",
});
return;
@@ -89,10 +93,9 @@ export default function ExportDialog({
)
.then((response) => {
if (response.status == 200) {
toast.success(
"Successfully started export. View the file in the /exports folder.",
{ position: "top-center" },
);
toast.success(t("export.toast.success"), {
position: "top-center",
});
setName("");
setRange(undefined);
setMode("none");
@@ -103,11 +106,14 @@ export default function ExportDialog({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to start export: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("export.toast.error.failed", {
error: errorMessage,
}),
{ position: "top-center" },
);
});
}, [camera, name, range, setRange, setName, setMode]);
}, [camera, name, range, setRange, setName, setMode, t]);
const handleCancel = useCallback(() => {
setName("");
@@ -145,7 +151,7 @@ export default function ExportDialog({
<Trigger asChild>
<Button
className="flex items-center gap-2"
aria-label="Export"
aria-label={t("menu.export", { ns: "common" })}
size="sm"
onClick={() => {
const now = new Date(latestTime * 1000);
@@ -160,7 +166,11 @@ export default function ExportDialog({
}}
>
<FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" />
{isDesktop && <div className="text-primary">Export</div>}
{isDesktop && (
<div className="text-primary">
{t("menu.export", { ns: "common" })}
</div>
)}
</Button>
</Trigger>
<Content
@@ -209,6 +219,7 @@ export function ExportContent({
setMode,
onCancel,
}: ExportContentProps) {
const { t } = useTranslation(["components/dialog"]);
const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
const onSelectTime = useCallback(
@@ -256,7 +267,7 @@ export function ExportContent({
{isDesktop && (
<>
<DialogHeader>
<DialogTitle>Export</DialogTitle>
<DialogTitle>{t("menu.export")}</DialogTitle>
</DialogHeader>
<SelectSeparator className="my-4 bg-secondary" />
</>
@@ -280,9 +291,11 @@ export function ExportContent({
<Label className="cursor-pointer capitalize" htmlFor={opt}>
{isNaN(parseInt(opt))
? opt == "timeline"
? "Select from Timeline"
: `${opt}`
: `Last ${opt > "1" ? `${opt} Hours` : "Hour"}`}
? t("export.time.fromTimeline")
: t("export.time." + opt)
: t("export.time.lastHour", {
count: parseInt(opt),
})}
</Label>
</div>
);
@@ -298,7 +311,7 @@ export function ExportContent({
<Input
className="text-md my-6"
type="search"
placeholder="Name the Export"
placeholder={t("export.name.placeholder")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
@@ -310,11 +323,11 @@ export function ExportContent({
className={`cursor-pointer p-2 text-center ${isDesktop ? "" : "w-full"}`}
onClick={onCancel}
>
Cancel
{t("button.cancel", { ns: "common" })}
</div>
<Button
className={isDesktop ? "" : "w-full"}
aria-label="Select or export"
aria-label={t("export.selectOrExport")}
variant="select"
size="sm"
onClick={() => {
@@ -328,7 +341,9 @@ export function ExportContent({
}
}}
>
{selectedOption == "timeline" ? "Select" : "Export"}
{selectedOption == "timeline"
? t("export.select")
: t("export.export")}
</Button>
</DialogFooter>
</div>
@@ -345,6 +360,7 @@ function CustomTimeSelector({
range,
setRange,
}: CustomTimeSelectorProps) {
const { t } = useTranslation(["components/dialog"]);
const { data: config } = useSWR<FrigateConfig>("config");
// times
@@ -388,14 +404,14 @@ function CustomTimeSelector({
const formattedStart = useFormattedTimestamp(
startTime,
config?.ui.time_format == "24hour"
? "%b %-d, %H:%M:%S"
: "%b %-d, %I:%M:%S %p",
? t("time.formattedTimestamp.24hour")
: t("time.formattedTimestamp"),
);
const formattedEnd = useFormattedTimestamp(
endTime,
config?.ui.time_format == "24hour"
? "%b %-d, %H:%M:%S"
: "%b %-d, %I:%M:%S %p",
? t("time.formattedTimestamp.24hour")
: t("time.formattedTimestamp"),
);
const startClock = useMemo(() => {
@@ -428,7 +444,7 @@ function CustomTimeSelector({
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label="Start time"
aria-label={t("export.time.start")}
variant={startOpen ? "select" : "default"}
size="sm"
onClick={() => {
@@ -494,7 +510,7 @@ function CustomTimeSelector({
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label="End time"
aria-label={t("export.time.end")}
variant={endOpen ? "select" : "default"}
size="sm"
onClick={() => {
@@ -565,6 +581,7 @@ export function ExportPreviewDialog({
showPreview,
setShowPreview,
}: ExportPreviewDialogProps) {
const { t } = useTranslation(["components/dialog"]);
if (!range) {
return null;
}
@@ -582,9 +599,9 @@ export function ExportPreviewDialog({
)}
>
<DialogHeader>
<DialogTitle>Preview Export</DialogTitle>
<DialogTitle>{t("export.fromTimeline.previewExport")}</DialogTitle>
<DialogDescription className="sr-only">
Preview Export
{t("export.fromTimeline.previewExport")}
</DialogDescription>
</DialogHeader>
<GenericVideoPlayer source={source} />
+48 -17
View File
@@ -11,6 +11,7 @@ import { GpuInfo, Nvinfo, Vainfo } from "@/types/stats";
import { Button } from "../ui/button";
import copy from "copy-to-clipboard";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
type GPUInfoDialogProps = {
showGpuInfo: boolean;
@@ -22,6 +23,8 @@ export default function GPUInfoDialog({
gpuType,
setShowGpuInfo,
}: GPUInfoDialogProps) {
const { t } = useTranslation(["views/system"]);
const { data: vainfo } = useSWR<Vainfo>(
showGpuInfo && gpuType == "vainfo" ? "vainfo" : null,
);
@@ -35,7 +38,7 @@ export default function GPUInfoDialog({
.replace(/\\t/g, "\t")
.replace(/\\n/g, "\n"),
);
toast.success("Copied GPU info to clipboard.");
toast.success(t("general.hardwareInfo.gpuInfo.toast.success"));
};
if (gpuType == "vainfo") {
@@ -43,13 +46,23 @@ export default function GPUInfoDialog({
<Dialog open={showGpuInfo} onOpenChange={setShowGpuInfo}>
<DialogContent>
<DialogHeader>
<DialogTitle>Vainfo Output</DialogTitle>
<DialogTitle>
{t("general.hardwareInfo.gpuInfo.vainfoOutput.title")}
</DialogTitle>
</DialogHeader>
{vainfo ? (
<div className="scrollbar-container mb-2 max-h-96 overflow-y-scroll whitespace-pre-line">
<div>Return Code: {vainfo.return_code}</div>
<div>
{t("general.hardwareInfo.gpuInfo.vainfoOutput.returnCode", {
code: vainfo.return_code,
})}
</div>
<br />
<div>Process {vainfo.return_code == 0 ? "Output" : "Error"}:</div>
<div>
{vainfo.return_code == 0
? t("general.hardwareInfo.gpuInfo.vainfoOutput.processOutput")
: t("general.hardwareInfo.gpuInfo.vainfoOutput.processError")}
</div>
<br />
<div>
{vainfo.return_code == 0 ? vainfo.stdout : vainfo.stderr}
@@ -60,17 +73,17 @@ export default function GPUInfoDialog({
)}
<DialogFooter>
<Button
aria-label="Close GPU info"
aria-label={t("general.hardwareInfo.gpuInfo.closeInfo.label")}
onClick={() => setShowGpuInfo(false)}
>
Close
{t("button.close", { ns: "common" })}
</Button>
<Button
aria-label="Copy GPU info"
aria-label={t("general.hardwareInfo.gpuInfo.copyInfo.label")}
variant="select"
onClick={() => onCopyInfo()}
>
Copy
{t("button.copy", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>
@@ -81,34 +94,52 @@ export default function GPUInfoDialog({
<Dialog open={showGpuInfo} onOpenChange={setShowGpuInfo}>
<DialogContent>
<DialogHeader>
<DialogTitle>Nvidia SMI Output</DialogTitle>
<DialogTitle>
{t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.title")}
</DialogTitle>
</DialogHeader>
{nvinfo ? (
<div className="scrollbar-container mb-2 max-h-96 overflow-y-scroll whitespace-pre-line">
<div>Name: {nvinfo["0"].name}</div>
<div>
{t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.name", {
name: nvinfo["0"].name,
})}
</div>
<br />
<div>Driver: {nvinfo["0"].driver}</div>
<div>
{t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.name", {
name: nvinfo["0"].driver,
})}
</div>
<br />
<div>Cuda Compute Capability: {nvinfo["0"].cuda_compute}</div>
<div>
{t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.name", {
name: nvinfo["0"].cuda_compute,
})}
</div>
<br />
<div>VBios Info: {nvinfo["0"].vbios}</div>
<div>
{t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.name", {
name: nvinfo["0"].vbios,
})}
</div>
</div>
) : (
<ActivityIndicator />
)}
<DialogFooter>
<Button
aria-label="Close GPU info"
aria-label={t("general.hardwareInfo.gpuInfo.closeInfo.label")}
onClick={() => setShowGpuInfo(false)}
>
Close
{t("button.close", { ns: "common" })}
</Button>
<Button
aria-label="Copy GPU info"
aria-label={t("general.hardwareInfo.gpuInfo.copyInfo.label")}
variant="select"
onClick={() => onCopyInfo()}
>
Copy
{t("button.copy", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>
@@ -3,6 +3,7 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Button } from "../ui/button";
import { FaVideo } from "react-icons/fa";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
type MobileCameraDrawerProps = {
allCameras: string[];
@@ -14,6 +15,7 @@ export default function MobileCameraDrawer({
selected,
onSelectCamera,
}: MobileCameraDrawerProps) {
const { t } = useTranslation(["common"]);
const [cameraDrawer, setCameraDrawer] = useState(false);
if (!isMobile) {
@@ -25,7 +27,7 @@ export default function MobileCameraDrawer({
<DrawerTrigger asChild>
<Button
className="rounded-lg capitalize"
aria-label="Cameras"
aria-label={t("menu.live.cameras")}
size="sm"
>
<FaVideo className="text-secondary-foreground" />
@@ -20,6 +20,8 @@ import axios from "axios";
import SaveExportOverlay from "./SaveExportOverlay";
import { isIOS, isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
type DrawerMode = "none" | "select" | "export" | "calendar" | "filter";
const DRAWER_FEATURES = ["export", "calendar", "filter"] as const;
@@ -68,6 +70,7 @@ export default function MobileReviewSettingsDrawer({
setMode,
setShowExportPreview,
}: MobileReviewSettingsDrawerProps) {
const { t } = useTranslation(["views/recording"]);
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
// exports
@@ -75,12 +78,14 @@ export default function MobileReviewSettingsDrawer({
const [name, setName] = useState("");
const onStartExport = useCallback(() => {
if (!range) {
toast.error("No valid time range selected", { position: "top-center" });
toast.error(t("toast.error.noValidTimeSelected"), {
position: "top-center",
});
return;
}
if (range.before < range.after) {
toast.error("End time must be after start time", {
toast.error(t("toast.error.endTimeMustAfterStartTime"), {
position: "top-center",
});
return;
@@ -97,8 +102,10 @@ export default function MobileReviewSettingsDrawer({
.then((response) => {
if (response.status == 200) {
toast.success(
"Successfully started export. View the file in the /exports folder.",
{ position: "top-center" },
t("export.toast.success", { ns: "components/dialog" }),
{
position: "top-center",
},
);
setName("");
setRange(undefined);
@@ -110,11 +117,17 @@ export default function MobileReviewSettingsDrawer({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to start export: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("export.toast.error.failed", {
ns: "components/dialog",
errorMessage,
}),
{
position: "top-center",
},
);
});
}, [camera, name, range, setRange, setName, setMode]);
}, [camera, name, range, setRange, setName, setMode, t]);
// filters
@@ -136,40 +149,40 @@ export default function MobileReviewSettingsDrawer({
{features.includes("export") && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label="Export"
aria-label={t("export")}
onClick={() => {
setDrawerMode("export");
setMode("select");
}}
>
<FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" />
Export
{t("export")}
</Button>
)}
{features.includes("calendar") && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label="Calendar"
aria-label={t("calendar")}
variant={filter?.after ? "select" : "default"}
onClick={() => setDrawerMode("calendar")}
>
<FaCalendarAlt
className={`${filter?.after ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
Calendar
{t("calendar")}
</Button>
)}
{features.includes("filter") && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label="Filter"
aria-label={t("filter")}
variant={filter?.labels || filter?.zones ? "select" : "default"}
onClick={() => setDrawerMode("filter")}
>
<FaFilter
className={`${filter?.labels || filter?.zones ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
Filter
{t("filter")}
</Button>
)}
</div>
@@ -206,10 +219,10 @@ export default function MobileReviewSettingsDrawer({
className="absolute left-0 text-selected"
onClick={() => setDrawerMode("select")}
>
Back
{t("button.back", { ns: "common" })}
</div>
<div className="absolute left-1/2 -translate-x-1/2 text-muted-foreground">
Calendar
{t("calendar")}
</div>
</div>
<div className="flex w-full flex-row justify-center">
@@ -234,7 +247,7 @@ export default function MobileReviewSettingsDrawer({
<SelectSeparator />
<div className="flex items-center justify-center p-2">
<Button
aria-label="Reset"
aria-label={t("button.reset", { ns: "common" })}
onClick={() => {
onUpdateFilter({
...filter,
@@ -243,7 +256,7 @@ export default function MobileReviewSettingsDrawer({
});
}}
>
Reset
{t("button.reset", { ns: "common" })}
</Button>
</div>
</div>
@@ -256,10 +269,10 @@ export default function MobileReviewSettingsDrawer({
className="absolute left-0 text-selected"
onClick={() => setDrawerMode("select")}
>
Back
{t("button.back", { ns: "common" })}
</div>
<div className="absolute left-1/2 -translate-x-1/2 text-muted-foreground">
Filter
{t("filter")}
</div>
</div>
<GeneralFilterContent
@@ -313,7 +326,7 @@ export default function MobileReviewSettingsDrawer({
<DrawerTrigger asChild>
<Button
className="rounded-lg capitalize"
aria-label="Filters"
aria-label={t("filters")}
variant={
filter?.labels || filter?.after || filter?.zones
? "select"
+11 -20
View File
@@ -1,3 +1,4 @@
import { Trans, useTranslation } from "react-i18next";
import { Button } from "../ui/button";
import {
Dialog,
@@ -32,6 +33,7 @@ export default function RoleChangeDialog({
onSave,
onCancel,
}: RoleChangeDialogProps) {
const { t } = useTranslation(["views/settings"]);
const [selectedRole, setSelectedRole] = useState<"admin" | "viewer">(
currentRole,
);
@@ -41,27 +43,16 @@ export default function RoleChangeDialog({
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">
Change User Role
{t("users.dialog.changeRole.title")}
</DialogTitle>
<DialogDescription>
Update permissions for{" "}
<span className="font-medium">{username}</span>
{t("users.dialog.changeRole.desc", { username })}
</DialogDescription>
</DialogHeader>
<div className="py-6">
<div className="mb-4 text-sm text-muted-foreground">
<p>Select the appropriate role for this user:</p>
<ul className="mt-2 space-y-1 pl-5">
<li>
<span className="font-medium">Admin:</span> Full access to all
features.
</li>
<li>
<span className="font-medium">Viewer:</span> Limited to Live
dashboards, Review, Explore, and Exports only.
</li>
</ul>
<Trans ns="views/settings">users.dialog.changeRole.roleInfo</Trans>
</div>
<Select
@@ -77,13 +68,13 @@ export default function RoleChangeDialog({
<SelectItem value="admin" className="flex items-center gap-2">
<div className="flex items-center gap-2">
<LuShield className="size-4 text-primary" />
<span>Admin</span>
<span>{t("role.admin")}</span>
</div>
</SelectItem>
<SelectItem value="viewer" className="flex items-center gap-2">
<div className="flex items-center gap-2">
<LuUser className="size-4 text-primary" />
<span>Viewer</span>
<span>{t("role.viewer")}</span>
</div>
</SelectItem>
</SelectContent>
@@ -95,20 +86,20 @@ export default function RoleChangeDialog({
<div className="flex flex-row gap-2 pt-5">
<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"
aria-label="Save"
aria-label={t("button.save", { ns: "common" })}
className="flex flex-1"
onClick={() => onSave(selectedRole)}
disabled={selectedRole === currentRole}
>
Save
{t("button.save", { ns: "common" })}
</Button>
</div>
</div>
@@ -2,6 +2,7 @@ import { LuVideo, LuX } from "react-icons/lu";
import { Button } from "../ui/button";
import { FaCompactDisc } from "react-icons/fa";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type SaveExportOverlayProps = {
className: string;
@@ -17,6 +18,7 @@ export default function SaveExportOverlay({
onSave,
onCancel,
}: SaveExportOverlayProps) {
const { t } = useTranslation("components/dialog");
return (
<div className={className}>
<div
@@ -28,31 +30,31 @@ export default function SaveExportOverlay({
>
<Button
className="flex items-center gap-1 text-primary"
aria-label="Cancel"
aria-label={t("button.cancel", { ns: "common" })}
size="sm"
onClick={onCancel}
>
<LuX />
Cancel
{t("button.cancel", { ns: "common" })}
</Button>
<Button
className="flex items-center gap-1"
aria-label="Preview export"
aria-label={t("export.fromTimeline.previewExport")}
size="sm"
onClick={onPreview}
>
<LuVideo />
Preview Export
{t("export.fromTimeline.previewExport")}
</Button>
<Button
className="flex items-center gap-1"
aria-label="Save export"
aria-label={t("export.fromTimeline.saveExport")}
variant="select"
size="sm"
onClick={onSave}
>
<FaCompactDisc />
Save Export
{t("export.fromTimeline.saveExport")}
</Button>
</div>
</div>
@@ -11,8 +11,10 @@ import {
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Label } from "../ui/label";
import { LuCheck, LuX } from "react-icons/lu";
import { useTranslation } from "react-i18next";
type SetPasswordProps = {
show: boolean;
@@ -27,6 +29,7 @@ export default function SetPasswordDialog({
onCancel,
username,
}: SetPasswordProps) {
const { t } = useTranslation(["views/settings"]);
const [password, setPassword] = useState<string>("");
const [confirmPassword, setConfirmPassword] = useState<string>("");
const [passwordStrength, setPasswordStrength] = useState<number>(0);
@@ -77,10 +80,13 @@ export default function SetPasswordDialog({
const getStrengthLabel = () => {
if (!password) return "";
if (passwordStrength <= 1) return "Weak";
if (passwordStrength === 2) return "Medium";
if (passwordStrength === 3) return "Strong";
return "Very Strong";
if (passwordStrength <= 1)
return t("users.dialog.form.password.strength.weak");
if (passwordStrength === 2)
return t("users.dialog.form.password.strength.medium");
if (passwordStrength === 3)
return t("users.dialog.form.password.strength.strong");
return t("users.dialog.form.password.strength.veryStrong");
};
const getStrengthColor = () => {
@@ -96,16 +102,23 @@ export default function SetPasswordDialog({
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="space-y-2">
<DialogTitle>
{username ? `Update Password for ${username}` : "Set Password"}
{username
? t("users.dialog.passwordSetting.updatePassword", {
username,
ns: "views/settings",
})
: t("users.dialog.passwordSetting.setPassword")}
</DialogTitle>
<DialogDescription>
Create a strong password to secure this account.
{t("users.dialog.passwordSetting.desc")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<Label htmlFor="password">
{t("users.dialog.form.newPassword")}
</Label>
<Input
id="password"
className="h-10"
@@ -115,7 +128,7 @@ export default function SetPasswordDialog({
setPassword(event.target.value);
setError(null);
}}
placeholder="Enter new password"
placeholder={t("users.dialog.form.newPassword.placeholder")}
autoFocus
/>
@@ -129,7 +142,7 @@ export default function SetPasswordDialog({
/>
</div>
<p className="text-xs text-muted-foreground">
Password strength:{" "}
{t("users.dialog.form.password.strength")}
<span className="font-medium">{getStrengthLabel()}</span>
</p>
</div>
@@ -137,7 +150,9 @@ export default function SetPasswordDialog({
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm Password</Label>
<Label htmlFor="confirm-password">
{t("users.dialog.form.password.confirm")}
</Label>
<Input
id="confirm-password"
className="h-10"
@@ -147,7 +162,9 @@ export default function SetPasswordDialog({
setConfirmPassword(event.target.value);
setError(null);
}}
placeholder="Confirm new password"
placeholder={t(
"users.dialog.form.newPassword.confirm.placeholder",
)}
/>
{/* Password match indicator */}
@@ -156,12 +173,16 @@ export default function SetPasswordDialog({
{password === confirmPassword ? (
<>
<LuCheck className="size-3.5 text-green-500" />
<span className="text-green-600">Passwords match</span>
<span className="text-green-600">
{t("users.dialog.form.password.match")}
</span>
</>
) : (
<>
<LuX className="size-3.5 text-red-500" />
<span className="text-red-600">Passwords don't match</span>
<span className="text-red-600">
{t("users.dialog.form.password.notMatch")}
</span>
</>
)}
</div>
@@ -180,20 +201,20 @@ export default function SetPasswordDialog({
<div className="flex flex-row gap-2 pt-5">
<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"
aria-label="Save"
aria-label={t("button.save", { ns: "common" })}
className="flex flex-1"
onClick={handleSave}
disabled={!password || password !== confirmPassword}
>
Save
{t("button.save", { ns: "common" })}
</Button>
</div>
</div>
@@ -26,6 +26,7 @@ import { Button } from "@/components/ui/button";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Trans, useTranslation } from "react-i18next";
type AnnotationSettingsPaneProps = {
event: Event;
@@ -41,6 +42,8 @@ export function AnnotationSettingsPane({
annotationOffset,
setAnnotationOffset,
}: AnnotationSettingsPaneProps) {
const { t } = useTranslation(["views/explore"]);
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
@@ -81,9 +84,15 @@ export function AnnotationSettingsPane({
);
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) => {
@@ -91,7 +100,7 @@ export function AnnotationSettingsPane({
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",
});
})
@@ -99,7 +108,7 @@ export function AnnotationSettingsPane({
setIsLoading(false);
});
},
[updateConfig, config, event],
[updateConfig, config, event, t],
);
function onSubmit(values: z.infer<typeof formSchema>) {
@@ -126,7 +135,7 @@ export function AnnotationSettingsPane({
return (
<div className="mb-3 space-y-3 rounded-lg border border-secondary-foreground bg-background_alt p-2">
<Heading as="h4" className="my-2">
Annotation Settings
{t("objectLifecycle.annotationSettings.title")}
</Heading>
<div className="flex flex-col">
<div className="flex flex-row items-center justify-start gap-2 p-3">
@@ -136,11 +145,11 @@ export function AnnotationSettingsPane({
onCheckedChange={setShowZones}
/>
<Label className="cursor-pointer" htmlFor="show-zones">
Show All Zones
{t("objectLifecycle.annotationSettings.showAllZones")}
</Label>
</div>
<div className="text-sm text-muted-foreground">
Always show zones on frames where objects have entered a zone.
{t("objectLifecycle.annotationSettings.showAllZones.desc")}
</div>
</div>
<Separator className="my-2 flex bg-secondary" />
@@ -154,17 +163,16 @@ export function AnnotationSettingsPane({
name="annotationOffset"
render={({ field }) => (
<FormItem>
<FormLabel>Annotation Offset</FormLabel>
<FormLabel>
{t("objectLifecycle.annotationSettings.offset.label")}
</FormLabel>
<div className="flex flex-col gap-3 md:flex-row-reverse md:gap-8">
<div className="flex flex-row items-center gap-3 rounded-lg bg-destructive/50 p-3 text-sm text-primary-variant md:my-0 md:my-5">
<PiWarningCircle className="size-24" />
<div>
This data comes from your camera's detect feed but is
overlayed on images from the the record feed. It is
unlikely that the two streams are perfectly in sync. As a
result, the bounding box and the footage will not line up
perfectly. However, the <code>annotation_offset</code>{" "}
field can be used to adjust this.
<Trans ns="views/explore">
objectLifecycle.annotationSettings.offset.desc
</Trans>
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/reference"
@@ -172,7 +180,9 @@ export function AnnotationSettingsPane({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t(
"objectLifecycle.annotationSettings.offset.documentation",
)}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -187,16 +197,11 @@ export function AnnotationSettingsPane({
/>
</FormControl>
<FormDescription>
Milliseconds to offset detect annotations by.{" "}
<em>Default: 0</em>
{t(
"objectLifecycle.annotationSettings.offset.millisecondsToOffset",
)}
<div className="mt-2">
TIP: Imagine there is an event clip with a person
walking from left to right. If the event timeline
bounding box is consistently to the left of the person
then the value should be decreased. Similarly, if a
person is walking from left to right and the bounding
box is consistently ahead of the person then the value
should be increased.
{t("objectLifecycle.annotationSettings.offset.tips")}
</div>
</FormDescription>
</div>
@@ -210,14 +215,14 @@ export function AnnotationSettingsPane({
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label="Apply"
aria-label={t("button.apply", { ns: "common" })}
onClick={form.handleSubmit(onApply)}
>
Apply
{t("button.apply", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label="Save"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading}
className="flex flex-1"
type="submit"
@@ -225,10 +230,10 @@ export function AnnotationSettingsPane({
{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>
@@ -54,6 +54,7 @@ import { useNavigate } from "react-router-dom";
import { ObjectPath } from "./ObjectPath";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { IoPlayCircleOutline } from "react-icons/io5";
import { useTranslation } from "react-i18next";
type ObjectLifecycleProps = {
className?: string;
@@ -68,6 +69,8 @@ export default function ObjectLifecycle({
fullscreen = false,
setPane,
}: ObjectLifecycleProps) {
const { t } = useTranslation(["views/explore"]);
const { data: eventSequence } = useSWR<ObjectLifecycleSequence[]>([
"timeline",
{
@@ -334,12 +337,16 @@ export default function ObjectLifecycle({
<div className={cn("flex items-center gap-2")}>
<Button
className="mb-2 mt-3 flex items-center gap-2.5 rounded-lg md:mt-0"
aria-label="Go back"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => setPane("overview")}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && <div className="text-primary">Back</div>}
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
</div>
)}
@@ -361,7 +368,7 @@ export default function ObjectLifecycle({
<div className="relative aspect-video">
<div className="flex flex-col items-center justify-center p-20 text-center">
<LuFolderX className="size-16" />
No image found for this timestamp.
{t("objectLifecycle.noImageFound")}
</div>
</div>
)}
@@ -464,11 +471,13 @@ export default function ObjectLifecycle({
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={() =>
navigate(
`/settings?page=masks%20/%20zones&camera=${event.camera}&object_mask=${eventSequence?.[current].data.box}`,
`/settings?page=masksAndZones&camera=${event.camera}&object_mask=${eventSequence?.[current].data.box}`,
)
}
>
<div className="text-primary">Create Object Mask</div>
<div className="text-primary">
{t("objectLifecycle.createObjectMask")}
</div>
</div>
</ContextMenuItem>
</ContextMenuContent>
@@ -477,7 +486,7 @@ export default function ObjectLifecycle({
</div>
<div className="mt-3 flex flex-row items-center justify-between">
<Heading as="h4">Object Lifecycle</Heading>
<Heading as="h4">{t("objectLifecycle.title")}</Heading>
<div className="flex flex-row gap-2">
<Tooltip>
@@ -485,7 +494,7 @@ export default function ObjectLifecycle({
<Button
variant={showControls ? "select" : "default"}
className="size-7 p-1.5"
aria-label="Adjust annotation settings"
aria-label={t("objectLifecycle.adjustAnnotationSettings")}
>
<LuSettings
className="size-5"
@@ -494,14 +503,16 @@ export default function ObjectLifecycle({
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Adjust annotation settings</TooltipContent>
<TooltipContent>
{t("objectLifecycle.adjustAnnotationSettings")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</div>
<div className="flex flex-row items-center justify-between">
<div className="mb-2 text-sm text-muted-foreground">
Scroll to view the significant moments of this object's lifecycle.
{t("objectLifecycle.scrollViewTips")}
</div>
<div className="min-w-20 text-right text-sm text-muted-foreground">
{current + 1} of {eventSequence.length}
@@ -509,7 +520,7 @@ export default function ObjectLifecycle({
</div>
{config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config && (
<div className="-mt-2 mb-2 text-sm text-danger">
Bounding box positions will be inaccurate for autotracking cameras.
{t("objectLifecycle.autoTrackingTips")}
</div>
)}
{showControls && (
@@ -559,8 +570,8 @@ export default function ObjectLifecycle({
timezone: config.ui.timezone,
strftime_fmt:
config.ui.time_format == "24hour"
? "%d %b %H:%M:%S"
: "%m/%d %I:%M:%S%P",
? t("time.formattedTimestamp2.24hour")
: t("time.formattedTimestamp2"),
time_style: "medium",
date_style: "medium",
})}
@@ -42,6 +42,7 @@ import { DownloadVideoButton } from "@/components/button/DownloadVideoButton";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { LuSearch } from "react-icons/lu";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Trans, useTranslation } from "react-i18next";
type ReviewDetailDialogProps = {
review?: ReviewSegment;
@@ -51,6 +52,7 @@ export default function ReviewDetailDialog({
review,
setReview,
}: ReviewDetailDialogProps) {
const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
@@ -95,8 +97,8 @@ export default function ReviewDetailDialog({
const formattedDate = useFormattedTimestamp(
review?.start_time ?? 0,
config?.ui.time_format == "24hour"
? "%b %-d %Y, %H:%M"
: "%b %-d %Y, %I:%M %p",
? t("time.formattedTimestampWithYear.24hour", { ns: "common" })
: t("time.formattedTimestampWithYear", { ns: "common" }),
config?.ui.timezone,
);
@@ -177,8 +179,10 @@ export default function ReviewDetailDialog({
<span tabIndex={0} className="sr-only" />
{pane == "overview" && (
<Header className="justify-center">
<Title>Review Item Details</Title>
<Description className="sr-only">Review item details</Description>
<Title>{t("details.item.title")}</Title>
<Description className="sr-only">
{t("details.item.desc")}
</Description>
<div
className={cn(
"absolute flex gap-2 lg:flex-col",
@@ -189,7 +193,7 @@ export default function ReviewDetailDialog({
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Share this review item"
aria-label={t("details.item.button.share")}
size="sm"
onClick={() =>
shareOrCopy(`${baseUrl}review?id=${review.id}`)
@@ -199,7 +203,9 @@ export default function ReviewDetailDialog({
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Share this review item</TooltipContent>
<TooltipContent>
{t("details.item.button.share")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
@@ -211,7 +217,9 @@ export default function ReviewDetailDialog({
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Download</TooltipContent>
<TooltipContent>
{t("button.download", { ns: "common" })}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
@@ -222,19 +230,25 @@ export default function ReviewDetailDialog({
<div className="flex w-full flex-row">
<div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">Camera</div>
<div className="text-sm text-primary/40">
{t("details.camera")}
</div>
<div className="text-sm capitalize">
{review.camera.replaceAll("_", " ")}
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">Timestamp</div>
<div className="text-sm text-primary/40">
{t("details.timestamp")}
</div>
<div className="text-sm">{formattedDate}</div>
</div>
</div>
<div className="flex w-full flex-col items-center gap-2">
<div className="flex w-full flex-col gap-1.5 lg:pr-8">
<div className="text-sm text-primary/40">Objects</div>
<div className="text-sm text-primary/40">
{t("details.objects")}
</div>
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm capitalize">
{events?.map((event) => {
return (
@@ -260,7 +274,9 @@ export default function ReviewDetailDialog({
</div>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>View in Explore</TooltipContent>
<TooltipContent>
{t("details.item.button.viewInExplore")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
@@ -270,7 +286,9 @@ export default function ReviewDetailDialog({
</div>
{review.data.zones.length > 0 && (
<div className="scrollbar-container flex max-h-32 w-full flex-col gap-1.5">
<div className="text-sm text-primary/40">Zones</div>
<div className="text-sm text-primary/40">
{t("details.zones")}
</div>
<div className="flex flex-col items-start gap-2 text-sm capitalize">
{review.data.zones.map((zone) => {
return (
@@ -294,18 +312,23 @@ export default function ReviewDetailDialog({
(events?.length ?? 0) -
(review?.data.detections.length ?? 0),
);
const objectLabel =
detectedCount === 1 ? "object was" : "objects were";
return `${detectedCount} unavailable ${objectLabel} detected and included in this review item.`;
})()}{" "}
Those objects either did not qualify as an alert or detection
or have already been cleaned up/deleted.
return t("details.item.tips.mismatch", {
count: detectedCount,
});
})()}
{missingObjects.length > 0 && (
<div className="mt-2">
Adjust your configuration if you want Frigate to save
tracked objects for the following labels:{" "}
{missingObjects.join(", ")}
<Trans
ns="views/explore"
values={{
objects: missingObjects
.map((x) => t(x, { ns: "objects" }))
.join(", "),
}}
>
details.item.tips.hasMissingObjects
</Trans>
</div>
)}
</div>
@@ -348,6 +371,8 @@ function EventItem({
setSelectedEvent,
setUpload,
}: EventItemProps) {
const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
@@ -417,7 +442,9 @@ function EventItem({
</Chip>
</a>
</TooltipTrigger>
<TooltipContent>Download</TooltipContent>
<TooltipContent>
{t("button.download", { ns: "common" })}
</TooltipContent>
</Tooltip>
{event.has_snapshot &&
@@ -435,7 +462,9 @@ function EventItem({
<FrigatePlusIcon className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>Submit to Frigate+</TooltipContent>
<TooltipContent>
{t("itemMenu.submitToPlus.label")}
</TooltipContent>
</Tooltip>
)}
@@ -452,7 +481,9 @@ function EventItem({
<FaArrowsRotate className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>View Object Lifecycle</TooltipContent>
<TooltipContent>
{t("itemMenu.viewObjectLifecycle.label")}
</TooltipContent>
</Tooltip>
)}
@@ -470,7 +501,9 @@ function EventItem({
<FaImages className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>Find Similar</TooltipContent>
<TooltipContent>
{t("itemMenu.findSimilar.label")}
</TooltipContent>
</Tooltip>
)}
</div>
@@ -73,12 +73,13 @@ import { LuInfo } from "react-icons/lu";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { FaPencilAlt } from "react-icons/fa";
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
import { useTranslation } from "react-i18next";
const SEARCH_TABS = [
"details",
"snapshot",
"video",
"object lifecycle",
"object_lifecycle",
] as const;
export type SearchTab = (typeof SEARCH_TABS)[number];
@@ -98,6 +99,7 @@ export default function SearchDetailDialog({
setSimilarity,
setInputFocused,
}: SearchDetailDialogProps) {
const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
@@ -152,7 +154,7 @@ export default function SearchDetailDialog({
}
if (search.data.type != "object" || !search.has_clip) {
const index = views.indexOf("object lifecycle");
const index = views.indexOf("object_lifecycle");
views.splice(index, 1);
}
@@ -192,8 +194,8 @@ export default function SearchDetailDialog({
)}
>
<Header>
<Title>Tracked Object Details</Title>
<Description className="sr-only">Tracked object details</Description>
<Title>{t("trackedObjectDetails")}</Title>
<Description className="sr-only">{t("details")}</Description>
</Header>
<ScrollArea
className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
@@ -221,10 +223,10 @@ export default function SearchDetailDialog({
{item == "details" && <FaRegListAlt className="size-4" />}
{item == "snapshot" && <FaImage className="size-4" />}
{item == "video" && <FaVideo className="size-4" />}
{item == "object lifecycle" && (
{item == "object_lifecycle" && (
<FaRotate className="size-4" />
)}
<div className="capitalize">{item}</div>
<div className="capitalize">{t("type.{item}")}</div>
</ToggleGroupItem>
))}
</ToggleGroup>
@@ -254,7 +256,7 @@ export default function SearchDetailDialog({
/>
)}
{page == "video" && <VideoTab search={search} />}
{page == "object lifecycle" && (
{page == "object_lifecycle" && (
<ObjectLifecycle
className="w-full overflow-x-hidden"
event={search as unknown as Event}
@@ -281,6 +283,8 @@ function ObjectDetailsTab({
setSimilarity,
setInputFocused,
}: ObjectDetailsTabProps) {
const { t } = useTranslation(["views/explore"]);
const apiHost = useApiHost();
// mutation / revalidation
@@ -306,8 +310,8 @@ function ObjectDetailsTab({
const formattedDate = useFormattedTimestamp(
search?.start_time ?? 0,
config?.ui.time_format == "24hour"
? "%b %-d %Y, %H:%M"
: "%b %-d %Y, %I:%M %p",
? t("time.formattedTimestampWithYear.24hour")
: t("time.formattedTimestampWithYear"),
config?.ui.timezone,
);
@@ -383,7 +387,7 @@ function ObjectDetailsTab({
.post(`events/${search.id}/description`, { description: desc })
.then((resp) => {
if (resp.status == 200) {
toast.success("Successfully saved description", {
toast.success(t("details.tips.descriptionSaved"), {
position: "top-center",
});
}
@@ -416,12 +420,17 @@ function ObjectDetailsTab({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to update the description: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("details.tips.saveDescriptionFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
setDesc(search.data.description);
});
}, [desc, search, mutate]);
}, [desc, search, mutate, t]);
const regenerateDescription = useCallback(
(source: "snapshot" | "thumbnails") => {
@@ -434,7 +443,12 @@ function ObjectDetailsTab({
.then((resp) => {
if (resp.status == 200) {
toast.success(
`A new description has been requested from ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")}. Depending on the speed of your provider, the new description may take some time to regenerate.`,
t("details.item.toast.success.regenerate", {
provider: capitalizeAll(
config?.genai.provider.replaceAll("_", " ") ??
t("generativeAI"),
),
}),
{
position: "top-center",
duration: 7000,
@@ -448,12 +462,18 @@ function ObjectDetailsTab({
error.response?.data?.detail ||
"Unknown error";
toast.error(
`Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${errorMessage}`,
t("details.item.toast.error.regenerate", {
provider: capitalizeAll(
config?.genai.provider.replaceAll("_", " ") ??
t("generativeAI"),
),
errorMessage,
}),
{ position: "top-center" },
);
});
},
[search, config],
[search, config, t],
);
const handleSubLabelSave = useCallback(
@@ -472,7 +492,7 @@ function ObjectDetailsTab({
})
.then((response) => {
if (response.status === 200) {
toast.success("Successfully updated sub label.", {
toast.success(t("details.item.toast.success.updatedSublabel"), {
position: "top-center",
});
@@ -520,12 +540,17 @@ function ObjectDetailsTab({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to update sub label: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("details.item.toast.error.updatedSublabelFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
});
},
[search, apiHost, mutate, setSearch],
[search, apiHost, mutate, setSearch, t],
);
return (
@@ -533,10 +558,10 @@ function ObjectDetailsTab({
<div className="flex w-full flex-row">
<div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">Label</div>
<div className="text-sm text-primary/40">{t("details.label")}</div>
<div className="flex flex-row items-center gap-2 text-sm capitalize">
{getIconForLabel(search.label, "size-4 text-primary")}
{search.label}
{t("{search.label}", { ns: "objects" })}
{search.sub_label && ` (${search.sub_label})`}
<Tooltip>
<TooltipTrigger asChild>
@@ -550,7 +575,7 @@ function ObjectDetailsTab({
</span>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Edit sub label</TooltipContent>
<TooltipContent>{t("details.editSubLable")}</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
@@ -572,7 +597,7 @@ function ObjectDetailsTab({
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">
<div className="flex flex-row items-center gap-1">
Top Score
{t("details.topScore")}
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
@@ -581,9 +606,7 @@ function ObjectDetailsTab({
</div>
</PopoverTrigger>
<PopoverContent className="w-80">
The top score is the highest median score for the tracked
object, so this may differ from the score shown on the
search result thumbnail.
{t("details.topScore.info")}
</PopoverContent>
</Popover>
</div>
@@ -594,12 +617,16 @@ function ObjectDetailsTab({
</div>
{averageEstimatedSpeed && (
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">Estimated Speed</div>
<div className="text-sm text-primary/40">
{t("details.estimatedSpeed")}
</div>
<div className="flex flex-col space-y-0.5 text-sm">
{averageEstimatedSpeed && (
<div className="flex flex-row items-center gap-2">
{averageEstimatedSpeed}{" "}
{config?.ui.unit_system == "imperial" ? "mph" : "kph"}{" "}
{config?.ui.unit_system == "imperial"
? t("unit.speed.mph", { ns: "common" })
: t("unit.speed.kph", { ns: "common" })}{" "}
{velocityAngle != undefined && (
<span className="text-primary/40">
<FaArrowRight
@@ -616,13 +643,15 @@ function ObjectDetailsTab({
</div>
)}
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">Camera</div>
<div className="text-sm text-primary/40">{t("details.camera")}</div>
<div className="text-sm capitalize">
{search.camera.replaceAll("_", " ")}
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">Timestamp</div>
<div className="text-sm text-primary/40">
{t("details.timestamp")}
</div>
<div className="text-sm">{formattedDate}</div>
</div>
</div>
@@ -642,7 +671,7 @@ function ObjectDetailsTab({
/>
{config?.semantic_search.enabled && search.data.type == "object" && (
<Button
aria-label="Find similar tracked objects"
aria-label={t("itemMenu.findSimilar.aria")}
onClick={() => {
setSearch(undefined);
@@ -651,7 +680,7 @@ function ObjectDetailsTab({
}
}}
>
Find Similar
{t("itemMenu.findSimilar.label")}
</Button>
)}
</div>
@@ -673,18 +702,15 @@ function ObjectDetailsTab({
<div className="flex">
<ActivityIndicator />
</div>
<div className="flex">
Frigate will not request a description from your Generative AI
provider until the tracked object's lifecycle has ended.
</div>
<div className="flex">{t("details.description.aiTips")}</div>
</div>
</>
) : (
<>
<div className="text-sm text-primary/40">Description</div>
<div className="text-sm text-primary/40"></div>
<Textarea
className="h-64"
placeholder="Description of the tracked object"
placeholder={t("details.description.placeholder")}
value={desc}
onChange={(e) => setDesc(e.target.value)}
onFocus={handleDescriptionFocus}
@@ -698,17 +724,17 @@ function ObjectDetailsTab({
<div className="flex items-start">
<Button
className="rounded-r-none border-r-0"
aria-label="Regenerate tracked object description"
aria-label={t("details.button.regenerate.label")}
onClick={() => regenerateDescription("thumbnails")}
>
Regenerate
{t("details.button.regenerate")}
</Button>
{search.has_snapshot && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="rounded-l-none border-l-0 px-2"
aria-label="Expand regeneration menu"
aria-label={t("details.expandRegenerationMenu")}
>
<FaChevronDown className="size-3" />
</Button>
@@ -716,17 +742,17 @@ function ObjectDetailsTab({
<DropdownMenuContent>
<DropdownMenuItem
className="cursor-pointer"
aria-label="Regenerate from snapshot"
aria-label={t("details.regenerateFromSnapshot")}
onClick={() => regenerateDescription("snapshot")}
>
Regenerate from Snapshot
{t("details.regenerateFromSnapshot")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
aria-label="Regenerate from thumbnails"
aria-label={t("details.regenerateFromThumbnails")}
onClick={() => regenerateDescription("thumbnails")}
>
Regenerate from Thumbnails
{t("details.regenerateFromThumbnails")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -737,17 +763,23 @@ function ObjectDetailsTab({
!config?.cameras[search.camera].genai.enabled) && (
<Button
variant="select"
aria-label="Save"
aria-label={t("button.save", { ns: "common" })}
onClick={updateDescription}
>
Save
{t("button.save", { ns: "common" })}
</Button>
)}
<TextEntryDialog
open={isSubLabelDialogOpen}
setOpen={setIsSubLabelDialogOpen}
title="Edit Sub Label"
description={`Enter a new sub label for this ${search.label ?? "tracked object"}.`}
title={t("details.editSubLable")}
description={
search.label
? t("details.editSubLable.desc", {
label: t(search.label, { ns: "objects" }),
})
: t("details.editSubLable.desc.noLabel")
}
onSave={handleSubLabelSave}
defaultValue={search?.sub_label || ""}
allowEmpty={true}
@@ -766,6 +798,7 @@ export function ObjectSnapshotTab({
search,
onEventUploaded,
}: ObjectSnapshotTabProps) {
const { t } = useTranslation(["components/dialog"]);
type SubmissionState = "reviewing" | "uploading" | "submitted";
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
@@ -848,7 +881,9 @@ export function ObjectSnapshotTab({
</a>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Download</TooltipContent>
<TooltipContent>
{t("button.download", { ns: "common" })}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
@@ -867,12 +902,10 @@ export function ObjectSnapshotTab({
"text-lg font-semibold leading-none tracking-tight"
}
>
Submit To Frigate+
{t("explore.submitToPlus.label")}
</div>
<div className="text-sm text-muted-foreground">
Objects in locations you want to avoid are not false
positives. Submitting them as false positives will
confuse the model.
{t("explore.submitToPlus.desc")}
</div>
</div>
@@ -881,28 +914,36 @@ export function ObjectSnapshotTab({
<>
<Button
className="bg-success"
aria-label="Confirm this label for Frigate Plus"
aria-label={t("explore.plus.review.true.label")}
onClick={() => {
setState("uploading");
onSubmitToPlus(false);
}}
>
This is{" "}
{/^[aeiou]/i.test(search?.label || "") ? "an" : "a"}{" "}
{search?.label}
{/^[aeiou]/i.test(search?.label || "")
? t("explore.plus.review.true_other", {
label: search?.label,
})
: t("explore.plus.review.true_one", {
label: search?.label,
})}
</Button>
<Button
className="text-white"
aria-label="Do not confirm this label for Frigate Plus"
aria-label={t("explore.plus.review.false.label")}
variant="destructive"
onClick={() => {
setState("uploading");
onSubmitToPlus(true);
}}
>
This is not{" "}
{/^[aeiou]/i.test(search?.label || "") ? "an" : "a"}{" "}
{search?.label}
{/^[aeiou]/i.test(search?.label || "")
? t("explore.plus.review.false_other", {
label: search?.label,
})
: t("explore.plus.review.false_one", {
label: search?.label,
})}
</Button>
</>
)}
@@ -910,7 +951,7 @@ export function ObjectSnapshotTab({
{state == "submitted" && (
<div className="flex flex-row items-center justify-center gap-2">
<FaCheckCircle className="text-success" />
Submitted
{t("explore.plus.review.state.submitted")}
</div>
)}
</div>
@@ -929,6 +970,7 @@ type VideoTabProps = {
};
export function VideoTab({ search }: VideoTabProps) {
const { t } = useTranslation(["views/explore"]);
const navigate = useNavigate();
const { data: reviewItem } = useSWR<ReviewSegment>([
`review/event/${search.id}`,
@@ -963,7 +1005,9 @@ export function VideoTab({ search }: VideoTabProps) {
</Chip>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>View in History</TooltipContent>
<TooltipContent>
{t("itemMenu.viewInHistory.label")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
@@ -978,7 +1022,9 @@ export function VideoTab({ search }: VideoTabProps) {
</a>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Download</TooltipContent>
<TooltipContent>
{t("button.download", { ns: "common" })}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
@@ -19,6 +19,8 @@ import { Button } from "@/components/ui/button";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { baseUrl } from "@/api/baseUrl";
import { useTranslation } from "react-i18next";
type RestartDialogProps = {
isOpen: boolean;
onClose: () => void;
@@ -30,6 +32,7 @@ export default function RestartDialog({
onClose,
onRestart,
}: RestartDialogProps) {
const { t } = useTranslation("components/dialog");
const [restartDialogOpen, setRestartDialogOpen] = useState(isOpen);
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
const [countdown, setCountdown] = useState(60);
@@ -78,14 +81,14 @@ export default function RestartDialog({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to restart Frigate?
</AlertDialogTitle>
<AlertDialogTitle>{t("restart.title")}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction onClick={handleRestart}>
Restart
{t("restart.button")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -100,19 +103,23 @@ export default function RestartDialog({
<ActivityIndicator />
<SheetHeader className="mt-5 text-center">
<SheetTitle className="text-center">
Frigate is Restarting
{t("restart.restarting.title")}
</SheetTitle>
<SheetDescription className="text-center">
<div>This page will reload in {countdown} seconds.</div>
<div>
{t("restart.restarting.content", {
countdown,
})}
</div>
</SheetDescription>
</SheetHeader>
<Button
size="lg"
className="mt-5"
aria-label="Force reload now"
aria-label={t("restart.restarting.button")}
onClick={handleForceReload}
>
Force Reload Now
{t("restart.restarting.button")}
</Button>
</div>
</SheetContent>
@@ -33,6 +33,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Trans, useTranslation } from "react-i18next";
import {
Command,
CommandEmpty,
@@ -60,7 +61,7 @@ export default function SearchFilterDialog({
onUpdateFilter,
}: SearchFilterDialogProps) {
// data
const { t } = useTranslation(["components/filter"]);
const [currentFilter, setCurrentFilter] = useState(filter ?? {});
const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]);
@@ -93,7 +94,7 @@ export default function SearchFilterDialog({
const trigger = (
<Button
className="flex items-center gap-2"
aria-label="More Filters"
aria-label={t("more")}
size="sm"
variant={moreFiltersSelected ? "select" : "default"}
>
@@ -102,7 +103,7 @@ export default function SearchFilterDialog({
moreFiltersSelected ? "text-white" : "text-secondary-foreground",
)}
/>
More Filters
{t("more")}
</Button>
);
const content = (
@@ -184,7 +185,7 @@ export default function SearchFilterDialog({
<div className="flex items-center justify-evenly p-2">
<Button
variant="select"
aria-label="Apply"
aria-label={t("button.apply", { ns: "common" })}
onClick={() => {
if (currentFilter != filter) {
onUpdateFilter(currentFilter);
@@ -193,10 +194,10 @@ export default function SearchFilterDialog({
setOpen(false);
}}
>
Apply
{t("button.apply", { ns: "common" })}
</Button>
<Button
aria-label="Reset filters to default values"
aria-label={t("reset.label")}
onClick={() => {
setCurrentFilter((prevFilter) => ({
...prevFilter,
@@ -214,7 +215,7 @@ export default function SearchFilterDialog({
}));
}}
>
Reset
{t("button.reset", { ns: "common" })}
</Button>
</div>
</div>
@@ -250,6 +251,7 @@ function TimeRangeFilterContent({
timeRange,
updateTimeRange,
}: TimeRangeFilterContentProps) {
const { t } = useTranslation(["components/filter"]);
const [startOpen, setStartOpen] = useState(false);
const [endOpen, setEndOpen] = useState(false);
@@ -293,7 +295,7 @@ function TimeRangeFilterContent({
return (
<div className="overflow-x-hidden">
<div className="text-lg">Time Range</div>
<div className="text-lg">{t("timeRange")}</div>
<div className="mt-3 flex flex-row items-center justify-center gap-2">
<Popover
open={startOpen}
@@ -306,7 +308,9 @@ function TimeRangeFilterContent({
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"} `}
aria-label="Select Start Time"
aria-label={t("export.time.start.label", {
ns: "components/dialog",
})}
variant={startOpen ? "select" : "default"}
size="sm"
onClick={() => {
@@ -344,7 +348,9 @@ function TimeRangeFilterContent({
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label="Select End Time"
aria-label={t("export.time.end.label", {
ns: "components/dialog",
})}
variant={endOpen ? "select" : "default"}
size="sm"
onClick={() => {
@@ -387,11 +393,12 @@ export function ZoneFilterContent({
zones,
updateZones,
}: ZoneFilterContentProps) {
const { t } = useTranslation(["components/filter"]);
return (
<>
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<div className="text-lg">Zones</div>
<div className="text-lg">{t("zones.label")}</div>
{allZones && (
<>
<div className="mb-5 mt-2.5 flex items-center justify-between">
@@ -399,7 +406,7 @@ export function ZoneFilterContent({
className="mx-2 cursor-pointer text-primary"
htmlFor="allZones"
>
All Zones
{t("zones.all")}
</Label>
<Switch
className="ml-1"
@@ -454,13 +461,14 @@ export function SubFilterContent({
subLabels,
setSubLabels,
}: SubFilterContentProps) {
const { t } = useTranslation(["components/filter"]);
return (
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<div className="text-lg">Sub Labels</div>
<div className="text-lg">{t("subLabels.label")}</div>
<div className="mb-5 mt-2.5 flex items-center justify-between">
<Label className="mx-2 cursor-pointer text-primary" htmlFor="allLabels">
All Sub Labels
{t("subLabels.all")}
</Label>
<Switch
className="ml-1"
@@ -512,10 +520,11 @@ export function ScoreFilterContent({
maxScore,
setScoreRange,
}: ScoreFilterContentProps) {
const { t } = useTranslation(["components/filter"]);
return (
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<div className="mb-3 text-lg">Score</div>
<div className="mb-3 text-lg">{t("score")}</div>
<div className="flex items-center gap-1">
<Input
className="w-14 text-center"
@@ -566,11 +575,17 @@ export function SpeedFilterContent({
maxSpeed,
setSpeedRange,
}: SpeedFilterContentProps) {
const { t } = useTranslation(["components/filter"]);
return (
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<div className="mb-3 text-lg">
Estimated Speed ({config?.ui.unit_system == "metric" ? "kph" : "mph"})
{t("estimatedSpeed", {
unit:
config?.ui.unit_system == "metric"
? t("unit.speed.kph", { ns: "common" })
: t("unit.speed.mph", { ns: "common" }),
})}
</div>
<div className="flex items-center gap-1">
<Input
@@ -629,6 +644,7 @@ export function SnapshotClipFilterContent({
submittedToFrigatePlus,
setSnapshotClip,
}: SnapshotClipContentProps) {
const { t } = useTranslation(["components/filter"]);
const [isSnapshotFilterActive, setIsSnapshotFilterActive] = useState(
hasSnapshot !== undefined,
);
@@ -657,7 +673,7 @@ export function SnapshotClipFilterContent({
return (
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<div className="mb-3 text-lg">Features</div>
<div className="mb-3 text-lg">{t("features.label")}</div>
<div className="my-2.5 space-y-1">
<div className="flex items-center justify-between">
@@ -679,7 +695,7 @@ export function SnapshotClipFilterContent({
htmlFor="snapshot-filter"
className="cursor-pointer text-sm font-medium leading-none"
>
Has a snapshot
{t("features.hasSnapshot")}
</Label>
</div>
<ToggleGroup
@@ -697,17 +713,17 @@ export function SnapshotClipFilterContent({
>
<ToggleGroupItem
value="yes"
aria-label="Yes"
aria-label={t("button.yes", { ns: "common" })}
className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white"
>
Yes
{t("button.yes", { ns: "common" })}
</ToggleGroupItem>
<ToggleGroupItem
value="no"
aria-label="No"
aria-label={t("button.no", { ns: "common" })}
className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white"
>
No
{t("button.no", { ns: "common" })}
</ToggleGroupItem>
</ToggleGroup>
</div>
@@ -741,12 +757,9 @@ export function SnapshotClipFilterContent({
side="left"
sideOffset={5}
>
You must first filter on tracked objects that have a
snapshot.
<br />
<br />
Tracked objects without a snapshot cannot be submitted to
Frigate+.
<Trans ns="components/filter">
features.submittedToFrigatePlus.tips
</Trans>
</TooltipContent>
)}
</Tooltip>
@@ -755,7 +768,7 @@ export function SnapshotClipFilterContent({
htmlFor="plus-filter"
className="cursor-pointer text-sm font-medium leading-none"
>
Submitted to Frigate+
{t("features.submittedToFrigatePlus.label")}
</Label>
</div>
<ToggleGroup
@@ -778,17 +791,17 @@ export function SnapshotClipFilterContent({
>
<ToggleGroupItem
value="yes"
aria-label="Yes"
aria-label={t("button.yes", { ns: "common" })}
className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white"
>
Yes
{t("button.yes", { ns: "common" })}
</ToggleGroupItem>
<ToggleGroupItem
value="no"
aria-label="No"
aria-label={t("button.no", { ns: "common" })}
className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white"
>
No
{t("button.no", { ns: "common" })}
</ToggleGroupItem>
</ToggleGroup>
</div>
@@ -817,7 +830,7 @@ export function SnapshotClipFilterContent({
htmlFor="clip-filter"
className="cursor-pointer text-sm font-medium leading-none"
>
Has a video clip
{t("features.hasVideoClip")}
</Label>
</div>
<ToggleGroup
@@ -833,17 +846,17 @@ export function SnapshotClipFilterContent({
>
<ToggleGroupItem
value="yes"
aria-label="Yes"
aria-label={t("button.yes", { ns: "common" })}
className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white"
>
Yes
{t("button.yes", { ns: "common" })}
</ToggleGroupItem>
<ToggleGroupItem
value="no"
aria-label="No"
aria-label={t("button.no", { ns: "common" })}
className="data-[state=on]:bg-selected data-[state=on]:text-white data-[state=on]:hover:bg-selected data-[state=on]:hover:text-white"
>
No
{t("button.no", { ns: "common" })}
</ToggleGroupItem>
</ToggleGroup>
</div>
@@ -863,6 +876,8 @@ export function RecognizedLicensePlatesFilterContent({
recognizedLicensePlates,
setRecognizedLicensePlates,
}: RecognizedLicensePlatesFilterContentProps) {
const { t } = useTranslation(["components/filter"]);
const { data: allRecognizedLicensePlates, error } = useSWR<string[]>(
"recognized_license_plates",
{
@@ -911,26 +926,28 @@ export function RecognizedLicensePlatesFilterContent({
return (
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<div className="mb-3 text-lg">Recognized License Plates</div>
<div className="mb-3 text-lg">{t("recognizedLicensePlates.title")}</div>
{error ? (
<p className="text-sm text-red-500">
Failed to load recognized license plates.
{t("recognizedLicensePlates.loadFailed")}
</p>
) : !allRecognizedLicensePlates ? (
<p className="text-sm text-muted-foreground">
Loading recognized license plates...
{t("recognizedLicensePlates.loading")}
</p>
) : (
<>
<Command className="border border-input bg-background">
<CommandInput
placeholder="Type to search license plates..."
placeholder={t("recognizedLicensePlates.placeholder")}
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList className="max-h-[200px] overflow-auto">
{filteredRecognizedLicensePlates.length === 0 && inputValue && (
<CommandEmpty>No license plates found.</CommandEmpty>
<CommandEmpty>
{t("recognizedLicensePlates.noLicensePlatesFound")}
</CommandEmpty>
)}
{filteredRecognizedLicensePlates.map((plate) => (
<CommandItem
@@ -973,7 +990,7 @@ export function RecognizedLicensePlatesFilterContent({
</>
)}
<p className="mt-1 text-sm text-muted-foreground">
Select one or more plates from the list.
{t("recognizedLicensePlates.selectPlatesFromList")}
</p>
</div>
);
@@ -12,6 +12,8 @@ import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback, useEffect } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
type TextEntryDialogProps = {
@@ -37,6 +39,8 @@ export default function TextEntryDialog({
text: z.string(),
});
const { t } = useTranslation("components/dialog");
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { text: defaultValue },
@@ -87,10 +91,10 @@ export default function TextEntryDialog({
/>
<DialogFooter className="pt-4">
<Button type="button" onClick={() => setOpen(false)}>
Cancel
{t("button.cancel", { ns: "common" })}
</Button>
<Button variant="select" type="submit">
Save
{t("button.save", { ns: "common" })}
</Button>
</DialogFooter>
</form>