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
+12 -3
View File
@@ -5,12 +5,16 @@ import {
} from "@/context/statusbar-provider";
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
import { useContext, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { FaCheck } from "react-icons/fa";
import { IoIosWarning } from "react-icons/io";
import { MdCircle } from "react-icons/md";
import { Link } from "react-router-dom";
export default function Statusbar() {
const { t } = useTranslation(["views/system"]);
const { messages, addMessage, clearMessages } = useContext(
StatusBarMessagesContext,
)!;
@@ -50,14 +54,19 @@ export default function Statusbar() {
clearMessages("embeddings-reindex");
addMessage(
"embeddings-reindex",
`Reindexing embeddings (${Math.floor((reindexState.processed_objects / reindexState.total_objects) * 100)}% complete)`,
t("stats.reindexingEmbeddings", {
processed: Math.floor(
(reindexState.processed_objects / reindexState.total_objects) *
100,
),
}),
);
}
if (reindexState.status === "completed") {
clearMessages("embeddings-reindex");
}
}
}, [reindexState, addMessage, clearMessages]);
}, [reindexState, addMessage, clearMessages, t]);
return (
<div className="absolute bottom-0 left-0 right-0 z-10 flex h-8 w-full items-center justify-between border-t border-secondary-highlight bg-background_alt px-4 dark:text-secondary-foreground">
@@ -129,7 +138,7 @@ export default function Statusbar() {
{Object.entries(messages).length === 0 ? (
<div className="flex items-center gap-2 text-sm">
<FaCheck className="size-3 text-green-500" />
System is healthy
{t("stats.healthy")}
</div>
) : (
Object.entries(messages).map(([key, messageArray]) => (
+12 -10
View File
@@ -21,16 +21,18 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { AuthContext } from "@/context/auth-context";
import { useTranslation } from "react-i18next";
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {}
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const { t } = useTranslation(["components/auth"]);
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const { login } = React.useContext(AuthContext);
const formSchema = z.object({
user: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
user: z.string().min(1, t("form.errors.usernameRequired")),
password: z.string().min(1, t("form.errors.passwordRequired")),
});
const form = useForm<z.infer<typeof formSchema>>({
@@ -62,20 +64,20 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
if (axios.isAxiosError(error)) {
const err = error as AxiosError;
if (err.response?.status === 429) {
toast.error("Exceeded rate limit. Try again later.", {
toast.error(t("form.errors.rateLimit"), {
position: "top-center",
});
} else if (err.response?.status === 401) {
toast.error("Login failed", {
toast.error(t("form.errors.loginFailed"), {
position: "top-center",
});
} else {
toast.error("Unknown error. Check logs.", {
toast.error(t("form.errors.unknownError"), {
position: "top-center",
});
}
} else {
toast.error("Unknown error. Check console logs.", {
toast.error(t("form.errors.webUnkownError"), {
position: "top-center",
});
}
@@ -92,7 +94,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
name="user"
render={({ field }) => (
<FormItem>
<FormLabel>User</FormLabel>
<FormLabel>{t("form.user")}</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]"
@@ -107,7 +109,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>{t("form.password")}</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]"
@@ -123,10 +125,10 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label="Login"
aria-label={t("form.login")}
>
{isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />}
Login
{t("form.login")}
</Button>
</div>
</form>
@@ -3,6 +3,7 @@ import { toast } from "sonner";
import { FaDownload } from "react-icons/fa";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type DownloadVideoButtonProps = {
source: string;
@@ -17,6 +18,7 @@ export function DownloadVideoButton({
startTime,
className,
}: DownloadVideoButtonProps) {
const { t } = useTranslation(["components/input"]);
const formattedDate = formatUnixTimestampToDateTime(startTime, {
strftime_fmt: "%D-%T",
time_style: "medium",
@@ -25,7 +27,7 @@ export function DownloadVideoButton({
const filename = `${camera}_${formattedDate}.mp4`;
const handleDownloadStart = () => {
toast.success("Your review item video has started downloading.", {
toast.success(t("button.downloadVideo.toast.success"), {
position: "top-center",
});
};
@@ -36,7 +38,7 @@ export function DownloadVideoButton({
asChild
className="flex items-center gap-2"
size="sm"
aria-label="Download Video"
aria-label={t("button.downloadVideo.label")}
>
<a href={source} download={filename} onClick={handleDownloadStart}>
<FaDownload
+16 -9
View File
@@ -7,6 +7,7 @@ import { useCallback, useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { usePersistence } from "@/hooks/use-persistence";
import AutoUpdatingCameraImage from "./AutoUpdatingCameraImage";
import { useTranslation } from "react-i18next";
type Options = { [key: string]: boolean };
@@ -21,6 +22,7 @@ export default function DebugCameraImage({
className,
cameraConfig,
}: DebugCameraImageProps) {
const { t } = useTranslation(["components/camera"]);
const [showSettings, setShowSettings] = useState(false);
const [options, setOptions] = usePersistence<Options>(
`${cameraConfig?.name}-feed`,
@@ -59,17 +61,21 @@ export default function DebugCameraImage({
onClick={handleToggleSettings}
variant="link"
size="sm"
aria-label="Settings"
aria-label={t("debug.options.label")}
>
<span className="h-5 w-5">
<LuSettings />
</span>{" "}
<span>{showSettings ? "Hide" : "Show"} Options</span>
<span>
{showSettings
? t("debug.options.hideOptions")
: t("debug.options.showOptions")}
</span>
</Button>
{showSettings ? (
<Card>
<CardHeader>
<CardTitle>Options</CardTitle>
<CardTitle>{t("debug.options.title")}</CardTitle>
</CardHeader>
<CardContent>
<DebugSettings
@@ -89,6 +95,7 @@ type DebugSettingsProps = {
};
function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
const { t } = useTranslation(["components/camera"]);
return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
<div className="flex items-center space-x-2">
@@ -99,7 +106,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("bbox", isChecked);
}}
/>
<Label htmlFor="bbox">Bounding Box</Label>
<Label htmlFor="bbox">{t("debug.boundingBox")}</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
@@ -109,7 +116,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("timestamp", isChecked);
}}
/>
<Label htmlFor="timestamp">Timestamp</Label>
<Label htmlFor="timestamp">{t("debug.timestamp")}</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
@@ -119,7 +126,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("zones", isChecked);
}}
/>
<Label htmlFor="zones">Zones</Label>
<Label htmlFor="zones">{t("debug.zones")}</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
@@ -129,7 +136,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("mask", isChecked);
}}
/>
<Label htmlFor="mask">Mask</Label>
<Label htmlFor="mask">{t("debug.mask")}</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
@@ -139,7 +146,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("motion", isChecked);
}}
/>
<Label htmlFor="motion">Motion</Label>
<Label htmlFor="motion">{t("debug.motion")}</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
@@ -149,7 +156,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("regions", isChecked);
}}
/>
<Label htmlFor="regions">Regions</Label>
<Label htmlFor="regions">{t("debug.regions")}</Label>
</div>
</div>
);
@@ -18,6 +18,7 @@ import { Skeleton } from "../ui/skeleton";
import { Button } from "../ui/button";
import { FaCircleCheck } from "react-icons/fa6";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type AnimatedEventCardProps = {
event: ReviewSegment;
@@ -29,6 +30,7 @@ export function AnimatedEventCard({
selectedGroup,
updateEvents,
}: AnimatedEventCardProps) {
const { t } = useTranslation(["views/events"]);
const { data: config } = useSWR<FrigateConfig>("config");
const apiHost = useApiHost();
@@ -121,7 +123,7 @@ export function AnimatedEventCard({
<Button
className="absolute right-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
size="xs"
aria-label="Mark as Reviewed"
aria-label={t("markAsReviewed")}
onClick={async () => {
await axios.post(`reviews/viewed`, { ids: [event.id] });
updateEvents();
@@ -130,7 +132,7 @@ export function AnimatedEventCard({
<FaCircleCheck className="size-3 text-white" />
</Button>
</TooltipTrigger>
<TooltipContent>Mark as Reviewed</TooltipContent>
<TooltipContent>{t("markAsReviewed")}</TooltipContent>
</Tooltip>
)}
{previews != undefined && (
+7 -7
View File
@@ -20,6 +20,7 @@ import { MdEditSquare } from "react-icons/md";
import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
import { shareOrCopy } from "@/utils/browserUtil";
import { useTranslation } from "react-i18next";
type ExportProps = {
className: string;
@@ -36,6 +37,7 @@ export default function ExportCard({
onRename,
onDelete,
}: ExportProps) {
const { t } = useTranslation(["views/exports"]);
const [hovered, setHovered] = useState(false);
const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0,
@@ -89,10 +91,8 @@ export default function ExportCard({
}
}}
>
<DialogTitle>Rename Export</DialogTitle>
<DialogDescription>
Enter a new name for this export.
</DialogDescription>
<DialogTitle>{t("editExport.title")}</DialogTitle>
<DialogDescription>{t("editExport.desc")}</DialogDescription>
{editName && (
<>
<Input
@@ -113,13 +113,13 @@ export default function ExportCard({
/>
<DialogFooter>
<Button
aria-label="Save Export"
aria-label={t("editExport.saveExport")}
size="sm"
variant="select"
disabled={(editName?.update?.length ?? 0) == 0}
onClick={() => submitRename()}
>
Save
{t("button.save", { ns: "common" })}
</Button>
</DialogFooter>
</>
@@ -207,7 +207,7 @@ export default function ExportCard({
{!exportedRecording.in_progress && (
<Button
className="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 cursor-pointer text-white hover:bg-transparent hover:text-white"
aria-label="Play"
aria-label={t("button.play", { ns: "common" })}
variant="ghost"
onClick={() => {
onSelect(exportedRecording);
+41 -37
View File
@@ -35,6 +35,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { buttonVariants } from "../ui/button";
import { Trans, useTranslation } from "react-i18next";
type ReviewCardProps = {
event: ReviewSegment;
@@ -46,6 +47,7 @@ export default function ReviewCard({
currentTime,
onClick,
}: ReviewCardProps) {
const { t } = useTranslation(["components/dialog"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
const formattedDate = useFormattedTimestamp(
@@ -82,26 +84,20 @@ export default function ReviewCard({
)
.then((response) => {
if (response.status == 200) {
toast.success(
"Successfully started export. View the file in the /exports folder.",
{ position: "top-center" },
);
}
})
.catch((error) => {
if (error.response?.data?.message) {
toast.error(
`Failed to start export: ${error.response.data.message}`,
{ position: "top-center" },
);
} else {
toast.error(`Failed to start export: ${error.message}`, {
toast.success(t("export.toast.success"), {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message || error.message || "Unknown error";
toast.error(t("export.toast.error.failed", { error: errorMessage }), {
position: "top-center",
});
});
setOptionsOpen(false);
}, [event]);
}, [event, t]);
const onDelete = useCallback(async () => {
await axios.post(`reviews/delete`, { ids: [event.id] });
@@ -216,24 +212,24 @@ export default function ReviewCard({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
<AlertDialogTitle>
{t("recording.confirmDelete.title")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete all recorded video associated with
this review item?
<br />
<br />
Hold the <em>Shift</em> key to bypass this dialog in the future.
<Trans ns="components/dialog">
recording.confirmDelete.title
</Trans>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setOptionsOpen(false)}>
Cancel
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onDelete}
>
Delete
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -247,7 +243,9 @@ export default function ReviewCard({
onClick={onExport}
>
<FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">Export</div>
<div className="text-primary">
{t("recording.button.export")}
</div>
</div>
</ContextMenuItem>
{!event.has_been_reviewed && (
@@ -257,7 +255,9 @@ export default function ReviewCard({
onClick={onMarkAsReviewed}
>
<FaCircleCheck className="text-secondary-foreground" />
<div className="text-primary">Mark as reviewed</div>
<div className="text-primary">
{t("recording.button.markAsReviewed")}
</div>
</div>
</ContextMenuItem>
)}
@@ -268,7 +268,9 @@ export default function ReviewCard({
>
<HiTrash className="text-secondary-foreground" />
<div className="text-primary">
{bypassDialogRef.current ? "Delete Now" : "Delete"}
{bypassDialogRef.current
? t("recording.button.deleteNow")
: t("button.delete", { ns: "common" })}
</div>
</div>
</ContextMenuItem>
@@ -286,24 +288,22 @@ export default function ReviewCard({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
<AlertDialogTitle>
{t("recording.confirmDelete.title")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete all recorded video associated with
this review item?
<br />
<br />
Hold the <em>Shift</em> key to bypass this dialog in the future.
<Trans ns="components/dialog">recording.confirmDelete.desc</Trans>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setOptionsOpen(false)}>
Cancel
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onDelete}
>
Delete
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -316,7 +316,7 @@ export default function ReviewCard({
onClick={onExport}
>
<FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">Export</div>
<div className="text-primary">{t("recording.button.export")}</div>
</div>
{!event.has_been_reviewed && (
<div
@@ -324,7 +324,9 @@ export default function ReviewCard({
onClick={onMarkAsReviewed}
>
<FaCircleCheck className="text-secondary-foreground" />
<div className="text-primary">Mark as reviewed</div>
<div className="text-primary">
{t("recording.button.markAsReviewed")}
</div>
</div>
)}
<div
@@ -333,7 +335,9 @@ export default function ReviewCard({
>
<HiTrash className="text-secondary-foreground" />
<div className="text-primary">
{bypassDialogRef.current ? "Delete Now" : "Delete"}
{bypassDialogRef.current
? t("recording.button.deleteNow")
: t("button.delete", { ns: "common" })}
</div>
</div>
</DrawerContent>
+3 -2
View File
@@ -8,11 +8,11 @@ import Chip from "@/components/indicators/Chip";
import useImageLoaded from "@/hooks/use-image-loaded";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { SearchResult } from "@/types/search";
import { cn } from "@/lib/utils";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import useContextMenu from "@/hooks/use-contextmenu";
import { useTranslation } from "react-i18next";
type SearchThumbnailProps = {
searchResult: SearchResult;
@@ -23,6 +23,7 @@ export default function SearchThumbnail({
searchResult,
onClick,
}: SearchThumbnailProps) {
const { t } = useTranslation(["views/search"]);
const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
@@ -113,7 +114,7 @@ export default function SearchThumbnail({
.filter(
(item) => item !== undefined && !item.includes("-verified"),
)
.map((text) => capitalizeFirstLetter(text))
.map((text) => t(text, { ns: "objects" }))
.sort()
.join(", ")
.replaceAll("-verified", "")}
@@ -6,6 +6,7 @@ import { SearchResult } from "@/types/search";
import ActivityIndicator from "../indicators/activity-indicator";
import SearchResultActions from "../menu/SearchResultActions";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type SearchThumbnailProps = {
searchResult: SearchResult;
@@ -24,12 +25,15 @@ export default function SearchThumbnailFooter({
showObjectLifecycle,
showSnapshot,
}: SearchThumbnailProps) {
const { t } = useTranslation(["views/search"]);
const { data: config } = useSWR<FrigateConfig>("config");
// date
const formattedDate = useFormattedTimestamp(
searchResult.start_time,
config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p",
config?.ui.time_format == "24hour"
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
: t("time.formattedTimestampExcludeSeconds", { ns: "common" }),
config?.ui.timezone,
);
+4 -2
View File
@@ -3,6 +3,7 @@ import { Button } from "../ui/button";
import { LuRefreshCcw } from "react-icons/lu";
import { MutableRefObject, useMemo } from "react";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type NewReviewDataProps = {
className: string;
@@ -18,6 +19,7 @@ export default function NewReviewData({
itemsToReview,
pullLatestData,
}: NewReviewDataProps) {
const { t } = useTranslation(["views/events"]);
const hasUpdate = useMemo(() => {
if (!reviewItems || !itemsToReview) {
return false;
@@ -36,7 +38,7 @@ export default function NewReviewData({
: "invisible",
"mx-auto bg-gray-400 text-center text-white",
)}
aria-label="View new review items"
aria-label={t("newReviewItems.label")}
onClick={() => {
pullLatestData();
if (contentRef.current) {
@@ -48,7 +50,7 @@ export default function NewReviewData({
}}
>
<LuRefreshCcw className="mr-2 h-4 w-4" />
New Items To Review
{t("newReviewItems.button")}
</Button>
</div>
</div>
+12 -3
View File
@@ -1,3 +1,4 @@
import { t } from "i18next";
import { FunctionComponent, useEffect, useMemo, useState } from "react";
interface IProp {
@@ -40,7 +41,7 @@ const timeAgo = ({
const elapsed: number = elapsedTime / 1000;
if (elapsed < 10) {
return "just now";
return t("time.justNow");
}
for (let i = 0; i < timeUnits.length; i++) {
@@ -64,11 +65,19 @@ const timeAgo = ({
if (monthDiff > 0) {
const unitAmount = monthDiff;
return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? "" : "s"} ago`;
return t("time.ago", {
timeAgo: t(`time.${dense ? timeUnits[i].unit : timeUnits[i].full}`, {
time: unitAmount,
}),
});
}
} else if (elapsed >= timeUnits[i].value) {
const unitAmount: number = Math.floor(elapsed / timeUnits[i].value);
return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? "" : "s"} ago`;
return t("time.ago", {
timeAgo: t(`time.${dense ? timeUnits[i].unit : timeUnits[i].full}`, {
time: unitAmount,
}),
});
}
}
return "Invalid Time";
@@ -14,6 +14,7 @@ import { DateRangePicker } from "../ui/calendar-range";
import { DateRange } from "react-day-picker";
import { useState } from "react";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import { useTranslation } from "react-i18next";
type CalendarFilterButtonProps = {
reviewSummary?: ReviewSummary;
@@ -27,16 +28,17 @@ export default function CalendarFilterButton({
day,
updateSelectedDay,
}: CalendarFilterButtonProps) {
const { t } = useTranslation(["components/filter"]);
const [open, setOpen] = useState(false);
const selectedDate = useFormattedTimestamp(
day == undefined ? 0 : day?.getTime() / 1000 + 1,
"%b %-d",
t("time.formattedTimestampOnlyMonthAndDay", { ns: "common" }),
);
const trigger = (
<Button
className="flex items-center gap-2"
aria-label="Select a date to filter by"
aria-label={t("date.selectDateBy.label")}
variant={day == undefined ? "default" : "select"}
size="sm"
>
@@ -46,7 +48,9 @@ export default function CalendarFilterButton({
<div
className={`hidden md:block ${day == undefined ? "text-primary" : "text-selected-foreground"}`}
>
{day == undefined ? "Last 24 Hours" : selectedDate}
{day == undefined
? t("calendarFilter.last24Hours", { ns: "views/events" })
: selectedDate}
</div>
</Button>
);
@@ -61,12 +65,12 @@ export default function CalendarFilterButton({
<DropdownMenuSeparator />
<div className="flex items-center justify-center p-2">
<Button
aria-label="Reset"
aria-label={t("button.reset", { ns: "common" })}
onClick={() => {
updateSelectedDay(undefined);
}}
>
Reset
{t("button.reset", { ns: "common" })}
</Button>
</div>
</>
@@ -93,18 +97,19 @@ export function CalendarRangeFilterButton({
defaultText,
updateSelectedRange,
}: CalendarRangeFilterButtonProps) {
const { t } = useTranslation(["components/filter"]);
const [open, setOpen] = useState(false);
const selectedDate = useFormattedRange(
range?.from == undefined ? 0 : range.from.getTime() / 1000 + 1,
range?.to == undefined ? 0 : range.to.getTime() / 1000 - 1,
"%b %-d",
t("time.formattedTimestampOnlyMonthAndDay", { ns: "common" }),
);
const trigger = (
<Button
className="flex items-center gap-2"
aria-label="Select a date to filter by"
aria-label={t("date.selectDateBy.label")}
variant={range == undefined ? "default" : "select"}
size="sm"
>
@@ -70,16 +70,20 @@ import {
MobilePageHeader,
MobilePageTitle,
} from "../mobile/MobilePage";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
import { DialogTrigger } from "@radix-ui/react-dialog";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { Trans, useTranslation } from "react-i18next";
type CameraGroupSelectorProps = {
className?: string;
};
export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
const { t } = useTranslation(["components/camera"]);
const { data: config } = useSWR<FrigateConfig>("config");
// tooltip
@@ -150,7 +154,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60"
: "bg-secondary text-secondary-foreground focus:bg-secondary focus:text-secondary-foreground"
}
aria-label="All Cameras"
aria-label={t("menu.live.allCameras", { ns: "common" })}
size="xs"
onClick={() => (group ? setGroup("default", true) : null)}
onMouseEnter={() => (isDesktop ? showTooltip("default") : null)}
@@ -160,8 +164,8 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent className="capitalize" side="right">
All Cameras
<TooltipContent className="" side="right">
{t("menu.live.allCameras", { ns: "common" })}
</TooltipContent>
</TooltipPortal>
</Tooltip>
@@ -175,7 +179,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60"
: "bg-secondary text-secondary-foreground"
}
aria-label="Camera Group"
aria-label={t("group.label")}
size="xs"
onClick={() => setGroup(name, group != "default")}
onMouseEnter={() => (isDesktop ? showTooltip(name) : null)}
@@ -202,7 +206,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
<Button
className="bg-secondary text-muted-foreground"
aria-label="Add camera group"
aria-label={t("group.add")}
size="xs"
onClick={() => setAddGroup(true)}
>
@@ -231,6 +235,7 @@ function NewGroupDialog({
setGroup,
deleteGroup,
}: NewGroupDialogProps) {
const { t } = useTranslation(["components/camera"]);
const { mutate: updateConfig } = useSWR<FrigateConfig>("config");
// editing group and state
@@ -273,9 +278,15 @@ function NewGroupDialog({
} else {
setOpen(false);
setEditState("none");
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) => {
@@ -285,7 +296,7 @@ function NewGroupDialog({
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",
});
})
@@ -300,6 +311,7 @@ function NewGroupDialog({
setOpen,
deleteGroup,
deleteGridLayout,
t,
],
);
@@ -352,10 +364,8 @@ function NewGroupDialog({
className={cn(isDesktop && "mt-5", "justify-center")}
onClose={() => setOpen(false)}
>
<Title>Camera Groups</Title>
<Description className="sr-only">
Edit camera groups
</Description>
<Title>{t("group.label")}</Title>
<Description className="sr-only">{t("group.edit")}</Description>
<div
className={cn(
"absolute",
@@ -370,7 +380,7 @@ function NewGroupDialog({
"size-6 rounded-md bg-secondary-foreground p-1 text-background",
isMobile && "text-secondary-foreground",
)}
aria-label="Add camera group"
aria-label={t("group.add")}
onClick={() => {
setEditState("add");
}}
@@ -402,11 +412,9 @@ function NewGroupDialog({
}}
>
<Title>
{editState == "add" ? "Add" : "Edit"} Camera Group
{editState == "add" ? t("group.add") : t("group.edit")}
</Title>
<Description className="sr-only">
Edit camera groups
</Description>
<Description className="sr-only">{t("group.edit")}</Description>
</Header>
<CameraGroupEdit
currentGroups={currentGroups}
@@ -436,6 +444,7 @@ export function EditGroupDialog({
currentGroups,
activeGroup,
}: EditGroupDialogProps) {
const { t } = useTranslation(["components/camera"]);
const Overlay = isDesktop ? Dialog : MobilePage;
const Content = isDesktop ? DialogContent : MobilePageContent;
const Header = isDesktop ? DialogHeader : MobilePageHeader;
@@ -475,8 +484,10 @@ export function EditGroupDialog({
>
<div className="scrollbar-container flex flex-col overflow-y-auto md:my-4">
<Header className="mt-2" onClose={() => setOpen(false)}>
<Title>Edit Camera Group</Title>
<Description className="sr-only">Edit camera group</Description>
<Title>{t("group.edit")}</Title>
<Description className="sr-only">
{t("group.edit.desc")}
</Description>
</Header>
<CameraGroupEdit
@@ -505,6 +516,7 @@ export function CameraGroupRow({
onDeleteGroup,
onEditGroup,
}: CameraGroupRowProps) {
const { t } = useTranslation(["components/camera"]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
if (!group) {
@@ -526,19 +538,22 @@ export function CameraGroupRow({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
<AlertDialogTitle>{t("group.delete.confirm")}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete the camera group{" "}
<em>{group[0]}</em>?
<Trans ns="components/camera" values={{ name: group[0] }}>
group.delete.confirm.desc
</Trans>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onDeleteGroup}
>
Delete
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -553,16 +568,16 @@ export function CameraGroupRow({
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuItem
aria-label="Edit group"
aria-label={t("group.edit")}
onClick={onEditGroup}
>
Edit
{t("button.edit", { ns: "common" })}
</DropdownMenuItem>
<DropdownMenuItem
aria-label="Delete group"
aria-label={t("group.delete.label")}
onClick={() => setDeleteDialogOpen(true)}
>
Delete
{t("button.delete", { ns: "common" })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
@@ -579,7 +594,9 @@ export function CameraGroupRow({
onClick={onEditGroup}
/>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
<TooltipContent>
{t("button.edit", { ns: "common" })}
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -590,7 +607,9 @@ export function CameraGroupRow({
onClick={() => setDeleteDialogOpen(true)}
/>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
<TooltipContent>
{t("button.delete", { ns: "common" })}
</TooltipContent>
</Tooltip>
</div>
)}
@@ -616,6 +635,7 @@ export function CameraGroupEdit({
onSave,
onCancel,
}: CameraGroupEditProps) {
const { t } = useTranslation(["components/camera"]);
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
@@ -635,7 +655,7 @@ export function CameraGroupEdit({
name: z
.string()
.min(2, {
message: "Camera group name must be at least 2 characters.",
message: t("group.name.errorMessage.mustLeastCharacters"),
})
.transform((val: string) => val.trim().replace(/\s+/g, "_"))
.refine(
@@ -646,7 +666,7 @@ export function CameraGroupEdit({
);
},
{
message: "Camera group name already exists.",
message: t("group.name.errorMessage.exists"),
},
)
.refine(
@@ -654,11 +674,11 @@ export function CameraGroupEdit({
return !value.includes(".");
},
{
message: "Camera group name must not contain a period.",
message: t("group.name.errorMessage.nameMustNotPeriod"),
},
)
.refine((value: string) => value.toLowerCase() !== "default", {
message: "Invalid camera group name.",
message: t("group.name.errorMessage.invalid"),
}),
cameras: z.array(z.string()),
@@ -713,18 +733,29 @@ export function CameraGroupEdit({
)
.then(async (res) => {
if (res.status === 200) {
toast.success(`Camera group (${values.name}) has been saved.`, {
position: "top-center",
});
toast.success(
t("group.toast.success", {
name: values.name,
}),
{
position: "top-center",
},
);
updateConfig();
if (onSave) {
onSave();
}
setAllGroupsStreamingSettings(updatedSettings);
} 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) => {
@@ -732,9 +763,13 @@ export function CameraGroupEdit({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("toast.save.error", {
errorMessage,
ns: "common",
}),
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
@@ -749,6 +784,7 @@ export function CameraGroupEdit({
groupStreamingSettings,
allGroupsStreamingSettings,
setAllGroupsStreamingSettings,
t,
],
);
@@ -773,11 +809,11 @@ export function CameraGroupEdit({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>{t("group.name.label")}</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]"
placeholder="Enter a name..."
placeholder={t("group.name.placeholder")}
{...field}
/>
</FormControl>
@@ -793,10 +829,8 @@ export function CameraGroupEdit({
name="cameras"
render={({ field }) => (
<FormItem>
<FormLabel>Cameras</FormLabel>
<FormDescription>
Select cameras for this group.
</FormDescription>
<FormLabel>{t("group.cameras.label")}</FormLabel>
<FormDescription>{t("group.cameras.desc")}</FormDescription>
<FormMessage />
{[
...(birdseyeConfig?.enabled ? ["birdseye"] : []),
@@ -826,7 +860,7 @@ export function CameraGroupEdit({
<DialogTrigger asChild>
<Button
className="flex h-auto items-center gap-1"
aria-label="Camera streaming settings"
aria-label={t("group.camera.setting.label")}
size="icon"
variant="ghost"
disabled={
@@ -880,7 +914,7 @@ export function CameraGroupEdit({
name="icon"
render={({ field }) => (
<FormItem className="flex flex-col space-y-2">
<FormLabel>Icon</FormLabel>
<FormLabel>{t("group.icon")}</FormLabel>
<FormControl>
<IconPicker
selectedIcon={{
@@ -905,25 +939,25 @@ export function CameraGroupEdit({
<Button
type="button"
className="flex flex-1"
aria-label="Cancel"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
>
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>
@@ -12,6 +12,7 @@ import { isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import FilterSwitch from "./FilterSwitch";
import { FaVideo } from "react-icons/fa";
import { useTranslation } from "react-i18next";
type CameraFilterButtonProps = {
allCameras: string[];
@@ -29,6 +30,7 @@ export function CamerasFilterButton({
mainCamera,
updateCameraFilter,
}: CameraFilterButtonProps) {
const { t } = useTranslation(["components/filter"]);
const [open, setOpen] = useState(false);
const [currentCameras, setCurrentCameras] = useState<string[] | undefined>(
selectedCameras,
@@ -36,15 +38,19 @@ export function CamerasFilterButton({
const buttonText = useMemo(() => {
if (isMobile) {
return "Cameras";
return t("menu.live.cameras", { ns: "common" });
}
if (!selectedCameras || selectedCameras.length == 0) {
return "All Cameras";
return t("menu.live.allCameras", { ns: "common" });
}
return `${selectedCameras.includes("birdseye") ? selectedCameras.length - 1 : selectedCameras.length} Camera${selectedCameras.length !== 1 ? "s" : ""}`;
}, [selectedCameras]);
return t("menu.live.cameras.count", {
count: selectedCameras.includes("birdseye")
? selectedCameras.length - 1
: selectedCameras.length,
ns: "common",
});
}, [selectedCameras, t]);
// ui
@@ -57,7 +63,7 @@ export function CamerasFilterButton({
const trigger = (
<Button
className="flex items-center gap-2 capitalize"
aria-label="Cameras Filter"
aria-label={t("cameras.label")}
variant={selectedCameras?.length == undefined ? "default" : "select"}
size="sm"
>
@@ -138,12 +144,13 @@ export function CamerasFilterContent({
setOpen,
updateCameraFilter,
}: CamerasFilterContentProps) {
const { t } = useTranslation(["components/filter"]);
return (
<>
{isMobile && (
<>
<DropdownMenuLabel className="flex justify-center">
Cameras
{t("cameras.all.short")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
@@ -151,7 +158,7 @@ export function CamerasFilterContent({
<div className="scrollbar-container flex h-auto max-h-[80dvh] flex-col gap-2 overflow-y-auto overflow-x-hidden p-4">
<FilterSwitch
isChecked={currentCameras == undefined}
label="All Cameras"
label={t("cameras.all")}
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentCameras(undefined);
@@ -225,7 +232,7 @@ export function CamerasFilterContent({
<DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2">
<Button
aria-label="Apply"
aria-label={t("button.apply", { ns: "common" })}
variant="select"
disabled={currentCameras?.length === 0}
onClick={() => {
@@ -233,16 +240,16 @@ export function CamerasFilterContent({
setOpen(false);
}}
>
Apply
{t("button.apply", { ns: "common" })}
</Button>
<Button
aria-label="Reset"
aria-label={t("button.reset", { ns: "common" })}
onClick={() => {
setCurrentCameras(undefined);
updateCameraFilter(undefined);
}}
>
Reset
{t("button.reset", { ns: "common" })}
</Button>
</div>
</>
@@ -9,6 +9,7 @@ import { Switch } from "../ui/switch";
import { DropdownMenuSeparator } from "../ui/dropdown-menu";
import { cn } from "@/lib/utils";
import FilterSwitch from "./FilterSwitch";
import { useTranslation } from "react-i18next";
type LogSettingsButtonProps = {
selectedLabels?: LogSeverity[];
@@ -22,23 +23,26 @@ export function LogSettingsButton({
logSettings,
setLogSettings,
}: LogSettingsButtonProps) {
const { t } = useTranslation(["components/filter"]);
const trigger = (
<Button
size="sm"
className="flex items-center gap-2"
aria-label="Filter log level"
aria-label={t("logSettings.label")}
>
<FaCog className="text-secondary-foreground" />
<div className="hidden text-primary md:block">Settings</div>
<div className="hidden text-primary md:block">
{t("menu.settings", { ns: "common" })}
</div>
</Button>
);
const content = (
<div className={cn("my-3 space-y-3 py-3 md:mt-0 md:py-0")}>
<div className="space-y-4">
<div className="space-y-0.5">
<div className="text-md">Filter</div>
<div className="text-md">{t("filter")}</div>
<div className="space-y-1 text-xs text-muted-foreground">
Filter logs by severity.
{t("logSettings.filterBySeverity")}
</div>
</div>
<GeneralFilterContent
@@ -49,14 +53,13 @@ export function LogSettingsButton({
<DropdownMenuSeparator />
<div className="space-y-4">
<div className="space-y-0.5">
<div className="text-md">Loading</div>
<div className="text-md">{t("logSettings.loading")}</div>
<div className="mt-2.5 flex flex-col gap-2.5">
<div className="space-y-1 text-xs text-muted-foreground">
When the log pane is scrolled to the bottom, new logs
automatically stream as they are added.
{t("logSettings.loading.desc")}
</div>
<FilterSwitch
label="Disable log streaming"
label={t("logSettings.disableLogStreaming")}
isChecked={logSettings?.disableStreaming ?? false}
onCheckedChange={(isChecked) => {
setLogSettings({
@@ -97,6 +100,7 @@ export function GeneralFilterContent({
selectedLabels,
updateLabelFilter,
}: GeneralFilterContentProps) {
const { t } = useTranslation(["components/filter"]);
return (
<>
<div className="scrollbar-container h-auto overflow-y-auto overflow-x-hidden">
@@ -105,7 +109,7 @@ export function GeneralFilterContent({
className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels"
>
All Logs
{t("logSettings.allLogs")}
</Label>
<Switch
className="ml-1"
+29 -15
View File
@@ -16,6 +16,7 @@ import {
AlertDialogTitle,
} from "../ui/alert-dialog";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Trans, useTranslation } from "react-i18next";
type ReviewActionGroupProps = {
selectedReviews: string[];
@@ -29,6 +30,7 @@ export default function ReviewActionGroup({
onExport,
pullLatestData,
}: ReviewActionGroupProps) {
const { t } = useTranslation(["components/dialog"]);
const onClearSelected = useCallback(() => {
setSelectedReviews([]);
}, [setSelectedReviews]);
@@ -68,22 +70,24 @@ export default function ReviewActionGroup({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
<AlertDialogTitle>
{t("recording.confirmDelete.title")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete all recorded video associated with
the selected review items?
<br />
<br />
Hold the <em>Shift</em> key to bypass this dialog in the future.
<Trans ns="components/dialog">
recording.confirmDelete.desc.selected
</Trans>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onDelete}
>
Delete
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -97,14 +101,14 @@ export default function ReviewActionGroup({
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={onClearSelected}
>
Unselect
{t("button.unselect", { ns: "common" })}
</div>
</div>
<div className="flex items-center gap-1 md:gap-2">
{selectedReviews.length == 1 && (
<Button
className="flex items-center gap-2 p-2"
aria-label="Export"
aria-label={t("recording.button.export")}
size="sm"
onClick={() => {
onExport(selectedReviews[0]);
@@ -112,28 +116,38 @@ export default function ReviewActionGroup({
}}
>
<FaCompactDisc className="text-secondary-foreground" />
{isDesktop && <div className="text-primary">Export</div>}
{isDesktop && (
<div className="text-primary">
{t("recording.button.export")}
</div>
)}
</Button>
)}
<Button
className="flex items-center gap-2 p-2"
aria-label="Mark as reviewed"
aria-label={t("recording.button.markAsReviewed")}
size="sm"
onClick={onMarkAsReviewed}
>
<FaCircleCheck className="text-secondary-foreground" />
{isDesktop && <div className="text-primary">Mark as reviewed</div>}
{isDesktop && (
<div className="text-primary">
{t("recording.button.markAsReviewed")}
</div>
)}
</Button>
<Button
className="flex items-center gap-2 p-2"
aria-label="Delete"
aria-label={t("button.delete", { ns: "common" })}
size="sm"
onClick={handleDelete}
>
<HiTrash className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{bypassDialog ? "Delete Now" : "Delete"}
{bypassDialog
? t("recording.button.deleteNow")
: t("button.delete", { ns: "common" })}
</div>
)}
</Button>
+22 -14
View File
@@ -23,6 +23,7 @@ import { FilterList, GeneralFilter } from "@/types/filter";
import CalendarFilterButton from "./CalendarFilterButton";
import { CamerasFilterButton } from "./CamerasFilterButton";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import { useTranslation } from "react-i18next";
const REVIEW_FILTERS = [
"cameras",
@@ -263,6 +264,7 @@ function ShowReviewFilter({
showReviewed,
setShowReviewed,
}: ShowReviewedFilterProps) {
const { t } = useTranslation(["components/filter"]);
const [showReviewedSwitch, setShowReviewedSwitch] = useOptimisticState(
showReviewed,
setShowReviewed,
@@ -278,13 +280,13 @@ function ShowReviewFilter({
}
/>
<Label className="ml-2 cursor-pointer text-primary" htmlFor="reviewed">
Show Reviewed
{t("review.showReviewed")}
</Label>
</div>
<Button
className="block duration-0 md:hidden"
aria-label="Show reviewed"
aria-label={t("review.showReviewed")}
variant={showReviewedSwitch ? "select" : "default"}
size="sm"
onClick={() =>
@@ -320,6 +322,7 @@ function GeneralFilterButton({
selectedZones,
onUpdateFilter,
}: GeneralFilterButtonProps) {
const { t } = useTranslation(["components/filter"]);
const [open, setOpen] = useState(false);
const [currentFilter, setCurrentFilter] = useState<GeneralFilter>({
labels: selectedLabels,
@@ -348,7 +351,7 @@ function GeneralFilterButton({
selectedLabels?.length || selectedZones?.length ? "select" : "default"
}
className="flex items-center gap-2 capitalize"
aria-label="Filter"
aria-label={t("filter")}
>
<FaFilter
className={`${
@@ -364,7 +367,7 @@ function GeneralFilterButton({
: "text-primary"
}`}
>
Filter
{t("filter")}
</div>
</Button>
);
@@ -439,13 +442,14 @@ export function GeneralFilterContent({
onReset,
onClose,
}: GeneralFilterContentProps) {
const { t } = useTranslation(["components/filter"]);
return (
<>
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
{currentSeverity && (
<div className="my-2.5 flex flex-col gap-2.5">
<FilterSwitch
label="Alerts"
label={t("alerts", { ns: "views/events" })}
disabled={currentSeverity == "alert"}
isChecked={
currentSeverity == "alert" ? true : filter.showAll === true
@@ -455,7 +459,7 @@ export function GeneralFilterContent({
}
/>
<FilterSwitch
label="Detections"
label={t("detections", { ns: "views/events" })}
disabled={currentSeverity == "detection"}
isChecked={
currentSeverity == "detection" ? true : filter.showAll === true
@@ -472,7 +476,7 @@ export function GeneralFilterContent({
className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels"
>
All Labels
{t("labels.all")}
</Label>
<Switch
className="ml-1"
@@ -519,7 +523,7 @@ export function GeneralFilterContent({
className="mx-2 cursor-pointer text-primary"
htmlFor="allZones"
>
All Zones
{t("zones.all")}
</Label>
<Switch
className="ml-1"
@@ -568,17 +572,20 @@ export function GeneralFilterContent({
<DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2">
<Button
aria-label="Apply"
aria-label={t("button.apply", { ns: "common" })}
variant="select"
onClick={() => {
onApply();
onClose();
}}
>
Apply
{t("button.apply", { ns: "common" })}
</Button>
<Button aria-label="Reset" onClick={onReset}>
Reset
<Button
aria-label={t("button.reset", { ns: "common" })}
onClick={onReset}
>
{t("button.reset", { ns: "common" })}
</Button>
</div>
</>
@@ -593,6 +600,7 @@ function ShowMotionOnlyButton({
motionOnly,
setMotionOnly,
}: ShowMotionOnlyButtonProps) {
const { t } = useTranslation(["views/events"]);
const [motionOnlyButton, setMotionOnlyButton] = useOptimisticState(
motionOnly,
setMotionOnly,
@@ -611,7 +619,7 @@ function ShowMotionOnlyButton({
className="mx-2 cursor-pointer text-primary"
htmlFor="collapse-motion"
>
Motion only
{t("motion.only")}
</Label>
</div>
@@ -619,7 +627,7 @@ function ShowMotionOnlyButton({
<Button
size="sm"
className="duration-0"
aria-label="Show Motion Only"
aria-label={t("motion.showMotionOnly", { ns: "components/filter" })}
variant={motionOnlyButton ? "select" : "default"}
onClick={() => setMotionOnlyButton(!motionOnlyButton)}
>
+23 -19
View File
@@ -15,6 +15,7 @@ import {
} from "../ui/alert-dialog";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { toast } from "sonner";
import { Trans, useTranslation } from "react-i18next";
type SearchActionGroupProps = {
selectedObjects: string[];
@@ -26,6 +27,7 @@ export default function SearchActionGroup({
setSelectedObjects,
pullLatestData,
}: SearchActionGroupProps) {
const { t } = useTranslation(["views/filter"]);
const onClearSelected = useCallback(() => {
setSelectedObjects([]);
}, [setSelectedObjects]);
@@ -37,7 +39,7 @@ export default function SearchActionGroup({
})
.then((resp) => {
if (resp.status == 200) {
toast.success("Tracked objects deleted successfully.", {
toast.success(t("trackedObjectDelete.toast.success"), {
position: "top-center",
});
setSelectedObjects([]);
@@ -49,11 +51,11 @@ export default function SearchActionGroup({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to delete tracked objects.: ${errorMessage}`, {
toast.error(t("trackedObjectDelete.toast.error", { errorMessage }), {
position: "top-center",
});
});
}, [selectedObjects, setSelectedObjects, pullLatestData]);
}, [selectedObjects, setSelectedObjects, pullLatestData, t]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [bypassDialog, setBypassDialog] = useState(false);
@@ -78,27 +80,27 @@ export default function SearchActionGroup({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
<AlertDialogTitle>
{t("trackedObjectDelete.title")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Deleting these {selectedObjects.length} tracked objects removes the
snapshot, any saved embeddings, and any associated object lifecycle
entries. Recorded footage of these tracked objects in History view
will <em>NOT</em> be deleted.
<br />
<br />
Are you sure you want to proceed?
<br />
<br />
Hold the <em>Shift</em> key to bypass this dialog in the future.
<Trans
ns="components/filter"
values={{ objectLength: selectedObjects.length }}
>
trackedObjectDelete.desc
</Trans>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onDelete}
>
Delete
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -112,20 +114,22 @@ export default function SearchActionGroup({
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={onClearSelected}
>
Unselect
{t("button.unselect", { ns: "common" })}
</div>
</div>
<div className="flex items-center gap-1 md:gap-2">
<Button
className="flex items-center gap-2 p-2"
aria-label="Delete"
aria-label={t("button.delete", { ns: "common" })}
size="sm"
onClick={handleDelete}
>
<HiTrash className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{bypassDialog ? "Delete Now" : "Delete"}
{bypassDialog
? t("button.deleteNow", { ns: "common" })
: t("button.delete", { ns: "common" })}
</div>
)}
</Button>
+35 -27
View File
@@ -25,6 +25,8 @@ import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
import { CalendarRangeFilterButton } from "./CalendarFilterButton";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useTranslation } from "react-i18next";
type SearchFilterGroupProps = {
className: string;
filters?: SearchFilters[];
@@ -39,6 +41,7 @@ export default function SearchFilterGroup({
filterList,
onUpdateFilter,
}: SearchFilterGroupProps) {
const { t } = useTranslation(["components/filter"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
@@ -195,7 +198,7 @@ export default function SearchFilterGroup({
to: new Date(filter.before * 1000),
}
}
defaultText={isMobile ? "Dates" : "All Dates"}
defaultText={isMobile ? t("dates.all.short") : t("dates.all")}
updateSelectedRange={onUpdateSelectedRange}
/>
)}
@@ -229,6 +232,7 @@ function GeneralFilterButton({
selectedLabels,
updateLabelFilter,
}: GeneralFilterButtonProps) {
const { t } = useTranslation(["components/filter"]);
const [open, setOpen] = useState(false);
const [currentLabels, setCurrentLabels] = useState<string[] | undefined>(
selectedLabels,
@@ -236,19 +240,21 @@ function GeneralFilterButton({
const buttonText = useMemo(() => {
if (isMobile) {
return "Labels";
return t("labels.all.short");
}
if (!selectedLabels || selectedLabels.length == 0) {
return "All Labels";
return t("labels.all");
}
if (selectedLabels.length == 1) {
return selectedLabels[0];
return t(selectedLabels[0], { ns: "objects" });
}
return `${selectedLabels.length} Labels`;
}, [selectedLabels]);
return t("labels.count", {
count: selectedLabels.length,
});
}, [selectedLabels, t]);
// ui
@@ -263,7 +269,7 @@ function GeneralFilterButton({
size="sm"
variant={selectedLabels?.length ? "select" : "default"}
className="flex items-center gap-2 capitalize"
aria-label="Labels"
aria-label={t("labels.label")}
>
<MdLabel
className={`${selectedLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
@@ -323,6 +329,7 @@ export function GeneralFilterContent({
setCurrentLabels,
onClose,
}: GeneralFilterContentProps) {
const { t } = useTranslation(["components/filter"]);
return (
<>
<div className="overflow-x-hidden">
@@ -331,7 +338,7 @@ export function GeneralFilterContent({
className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels"
>
All Labels
{t("labels.all")}
</Label>
<Switch
className="ml-1"
@@ -348,7 +355,7 @@ export function GeneralFilterContent({
{allLabels.map((item) => (
<FilterSwitch
key={item}
label={item.replaceAll("_", " ")}
label={t(item, { ns: "objects" })}
isChecked={currentLabels?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {
@@ -373,7 +380,7 @@ export function GeneralFilterContent({
<DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2">
<Button
aria-label="Apply"
aria-label={t("button.apply", { ns: "common" })}
variant="select"
onClick={() => {
if (selectedLabels != currentLabels) {
@@ -383,16 +390,16 @@ export function GeneralFilterContent({
onClose();
}}
>
Apply
{t("button.apply", { ns: "common" })}
</Button>
<Button
aria-label="Reset"
aria-label={t("button.reset", { ns: "common" })}
onClick={() => {
setCurrentLabels(undefined);
updateLabelFilter(undefined);
}}
>
Reset
{t("button.reset", { ns: "common" })}
</Button>
</div>
</>
@@ -411,6 +418,7 @@ function SortTypeButton({
selectedSortType,
updateSortType,
}: SortTypeButtonProps) {
const { t } = useTranslation(["components/filter"]);
const [open, setOpen] = useState(false);
const [currentSortType, setCurrentSortType] = useState<
SearchSortType | undefined
@@ -433,7 +441,7 @@ function SortTypeButton({
: "default"
}
className="flex items-center gap-2 capitalize"
aria-label="Labels"
aria-label={t("labels.label")}
>
<MdSort
className={`${selectedSortType != defaultSortType && selectedSortType != undefined ? "text-selected-foreground" : "text-secondary-foreground"}`}
@@ -441,7 +449,7 @@ function SortTypeButton({
<div
className={`${selectedSortType != defaultSortType && selectedSortType != undefined ? "text-selected-foreground" : "text-primary"}`}
>
Sort
{t("sort.label")}
</div>
</Button>
);
@@ -496,16 +504,16 @@ export function SortTypeContent({
setCurrentSortType,
onClose,
}: SortTypeContentProps) {
const { t } = useTranslation(["components/filter"]);
const sortLabels = {
date_asc: "Date (Ascending)",
date_desc: "Date (Descending)",
score_asc: "Object Score (Ascending)",
score_desc: "Object Score (Descending)",
speed_asc: "Estimated Speed (Ascending)",
speed_desc: "Estimated Speed (Descending)",
relevance: "Relevance",
date_asc: t("sort.dateAsc"),
date_desc: t("sort.dateDesc"),
score_asc: t("sort.scoreAsc"),
score_desc: t("sort.scoreDesc"),
speed_asc: t("sort.speedAsc"),
speed_desc: t("sort.speedDesc"),
relevance: t("sort.relevance"),
};
return (
<>
<div className="overflow-x-hidden">
@@ -548,7 +556,7 @@ export function SortTypeContent({
<DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2">
<Button
aria-label="Apply"
aria-label={t("button.apply", { ns: "common" })}
variant="select"
onClick={() => {
if (selectedSortType != currentSortType) {
@@ -558,16 +566,16 @@ export function SortTypeContent({
onClose();
}}
>
Apply
{t("button.apply", { ns: "common" })}
</Button>
<Button
aria-label="Reset"
aria-label={t("button.reset", { ns: "common" })}
onClick={() => {
setCurrentSortType(undefined);
updateSortType(undefined);
}}
>
Reset
{t("button.reset", { ns: "common" })}
</Button>
</div>
</>
+14 -6
View File
@@ -7,6 +7,7 @@ import { PolygonType } from "@/types/canvas";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { DropdownMenuSeparator } from "../ui/dropdown-menu";
import { useTranslation } from "react-i18next";
type ZoneMaskFilterButtonProps = {
selectedZoneMask?: PolygonType[];
@@ -16,12 +17,13 @@ export function ZoneMaskFilterButton({
selectedZoneMask,
updateZoneMaskFilter,
}: ZoneMaskFilterButtonProps) {
const { t } = useTranslation(["components/filter"]);
const trigger = (
<Button
size="sm"
variant={selectedZoneMask?.length ? "select" : "default"}
className="flex items-center gap-2 capitalize"
aria-label="Filter by zone mask"
aria-label={t("zoneMask.filterBy")}
>
<FaFilter
className={`${selectedZoneMask?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
@@ -29,7 +31,7 @@ export function ZoneMaskFilterButton({
<div
className={`hidden md:block ${selectedZoneMask?.length ? "text-selected-foreground" : "text-primary"}`}
>
Filter
{t("filter")}
</div>
</Button>
);
@@ -67,6 +69,7 @@ export function GeneralFilterContent({
selectedZoneMask,
updateZoneMaskFilter,
}: GeneralFilterContentProps) {
const { t } = useTranslation(["components/filter"]);
return (
<>
<div className="h-auto overflow-y-auto overflow-x-hidden">
@@ -75,7 +78,7 @@ export function GeneralFilterContent({
className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels"
>
All Masks and Zones
{t("labels.all")}
</Label>
<Switch
className="ml-1"
@@ -96,9 +99,14 @@ export function GeneralFilterContent({
className="mx-2 w-full cursor-pointer capitalize text-primary"
htmlFor={item}
>
{item
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase()) + "s"}
{t(
"masksAndZones." +
item
.replace(/_([a-z])/g, (letter) => letter.toUpperCase())
.replace("_", "") +
"s.label",
{ ns: "views/settings" },
)}
</Label>
<Switch
key={item}
+5 -1
View File
@@ -4,6 +4,7 @@ import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { useCallback, useEffect, useMemo } from "react";
import Chart from "react-apexcharts";
import { isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { MdCircle } from "react-icons/md";
import useSWR from "swr";
@@ -23,6 +24,7 @@ export function CameraLineGraph({
updateTimes,
data,
}: CameraLineGraphProps) {
const { t } = useTranslation(["views/system"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
@@ -126,7 +128,9 @@ export function CameraLineGraph({
className="size-2"
style={{ color: GRAPH_COLORS[labelIdx] }}
/>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-xs text-muted-foreground">
{t("cameras.label." + label)}
</div>
<div className="text-xs text-primary">
{lastValues[labelIdx]}
{unit}
@@ -16,7 +16,9 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { getUnitSize } from "@/utils/storageUtil";
import { CiCircleAlert } from "react-icons/ci";
import { useTranslation } from "react-i18next";
type CameraStorage = {
[key: string]: {
@@ -41,6 +43,8 @@ export function CombinedStorageGraph({
cameraStorage,
totalStorage,
}: CombinedStorageGraphProps) {
const { t } = useTranslation(["views/system"]);
const { theme, systemTheme } = useTheme();
const entities = Object.keys(cameraStorage);
@@ -176,10 +180,12 @@ export function CombinedStorageGraph({
<Table>
<TableHeader>
<TableRow>
<TableHead>Camera</TableHead>
<TableHead>Storage Used</TableHead>
<TableHead>Percentage of Total Used</TableHead>
<TableHead>Bandwidth</TableHead>
<TableHead>{t("storage.cameraStorage.camera")}</TableHead>
<TableHead>{t("storage.cameraStorage.storageUsed")}</TableHead>
<TableHead>
{t("storage.cameraStorage.percentageOfTotalUsed")}
</TableHead>
<TableHead>{t("storage.cameraStorage.bandwidth")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -191,26 +197,29 @@ export function CombinedStorageGraph({
className="size-3 rounded-md"
style={{ backgroundColor: item.color }}
></div>
{item.name.replaceAll("_", " ")}
{item.name === "Unused"
? t("storage.cameraStorage.unused")
: item.name.replaceAll("_", " ")}
{item.name === "Unused" && (
<Popover>
<PopoverTrigger asChild>
<button
className="focus:outline-none"
aria-label="Unused Storage Information"
aria-label={t(
"storage.cameraStorage.unusedStorageInformation",
)}
>
<CiCircleAlert
className="size-5"
aria-label="Unused Storage Information"
aria-label={t(
"storage.cameraStorage.unusedStorageInformation",
)}
/>
</button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-2">
This value may not accurately represent the free space
available to Frigate if you have other files stored on
your drive beyond Frigate's recordings. Frigate does
not track storage usage outside of its recordings.
{t("storage.cameraStorage.unused.tips")}
</div>
</PopoverContent>
</Popover>
+9 -4
View File
@@ -12,6 +12,8 @@ import Heading from "../ui/heading";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
import { useTranslation } from "react-i18next";
export type IconName = keyof typeof LuIcons;
export type IconElement = {
@@ -30,6 +32,7 @@ export default function IconPicker({
selectedIcon,
setSelectedIcon,
}: IconPickerProps) {
const { t } = useTranslation(["components/icons"]);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [searchTerm, setSearchTerm] = useState("");
@@ -68,9 +71,9 @@ export default function IconPicker({
{!selectedIcon?.name || !selectedIcon?.Icon ? (
<Button
className="mt-2 w-full text-muted-foreground"
aria-label="Select an icon"
aria-label={t("iconPicker.selectIcon")}
>
Select an icon
{t("iconPicker.selectIcon")}
</Button>
) : (
<div className="hover:cursor-pointer">
@@ -101,7 +104,7 @@ export default function IconPicker({
className="flex max-h-[50dvh] flex-col overflow-y-hidden md:max-h-[30dvh]"
>
<div className="mb-3 flex flex-row items-center justify-between">
<Heading as="h4">Select an icon</Heading>
<Heading as="h4">{t("iconPicker.selectIcon")}</Heading>
<span tabIndex={0} className="sr-only" />
<IoClose
size={15}
@@ -113,7 +116,9 @@ export default function IconPicker({
</div>
<Input
type="text"
placeholder="Search for an icon..."
placeholder={t("iconPicker.search.placeholder", {
ns: "components/icons",
})}
className="text-md mb-3 md:text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
+73 -73
View File
@@ -51,6 +51,7 @@ import { toast } from "sonner";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { MdImageSearch } from "react-icons/md";
import { Trans, useTranslation } from "react-i18next";
type InputWithTagsProps = {
inputFocused: boolean;
@@ -73,6 +74,7 @@ export default function InputWithTags({
setSearch,
allSuggestions,
}: InputWithTagsProps) {
const { t } = useTranslation(["views/search"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
@@ -236,12 +238,9 @@ export default function InputWithTags({
filters.after &&
timestamp <= filters.after * 1000
) {
toast.error(
"The 'before' date must be later than the 'after' date.",
{
position: "top-center",
},
);
toast.error(t("filter.toast.error.beforeDateBeLaterAfter"), {
position: "top-center",
});
return;
}
if (
@@ -249,12 +248,9 @@ export default function InputWithTags({
filters.before &&
timestamp >= filters.before * 1000
) {
toast.error(
"The 'after' date must be earlier than the 'before' date.",
{
position: "top-center",
},
);
toast.error(t("afterDatebeEarlierBefore"), {
position: "top-center",
});
return;
}
if (type === "before") {
@@ -274,7 +270,7 @@ export default function InputWithTags({
score > filters.max_score * 100
) {
toast.error(
"The 'min_score' must be less than or equal to the 'max_score'.",
t("filter.toast.error.minScoreMustBeLessOrEqualMaxScore"),
{
position: "top-center",
},
@@ -287,7 +283,7 @@ export default function InputWithTags({
score < filters.min_score * 100
) {
toast.error(
"The 'max_score' must be greater than or equal to the 'min_score'.",
t("filter.toast.error.maxScoreMustBeGreaterOrEqualMinScore"),
{
position: "top-center",
},
@@ -308,7 +304,7 @@ export default function InputWithTags({
speed > filters.max_speed
) {
toast.error(
"The 'min_speed' must be less than or equal to the 'max_speed'.",
t("filter.toast.error.minSpeedMustBeLessOrEqualMaxSpeed"),
{
position: "top-center",
},
@@ -321,7 +317,7 @@ export default function InputWithTags({
speed < filters.min_speed
) {
toast.error(
"The 'max_speed' must be greater than or equal to the 'min_speed'.",
t("filter.toast.error.maxSpeedMustBeGreaterOrEqualMinSpeed"),
{
position: "top-center",
},
@@ -380,7 +376,7 @@ export default function InputWithTags({
setCurrentFilterType(null);
}
},
[filters, setFilters, allSuggestions],
[filters, setFilters, allSuggestions, t],
);
function formatFilterValues(
@@ -408,16 +404,26 @@ export default function InputWithTags({
return Math.round(Number(filterValues) * 100).toString() + "%";
} else if (filterType === "min_speed" || filterType === "max_speed") {
return (
filterValues + (config?.ui.unit_system == "metric" ? " kph" : " mph")
filterValues +
" " +
(config?.ui.unit_system == "metric"
? t("unit.speed.kph", { ns: "common" })
: t("unit.speed.mph", { ns: "common" }))
);
} else if (
filterType === "has_clip" ||
filterType === "has_snapshot" ||
filterType === "is_submitted"
) {
return filterValues ? "Yes" : "No";
return filterValues
? t("button.yes", { ns: "common" })
: t("button.no", { ns: "common" });
} else if (filterType === "labels") {
return t(filterValues as string, { ns: "objects" });
} else if (filterType === "search_type") {
return t("filter.searchType." + (filterValues as string));
} else {
return filterValues as string;
return (filterValues as string).replaceAll("_", " ");
}
}
@@ -653,7 +659,7 @@ export default function InputWithTags({
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
className="text-md h-9 pr-32"
placeholder="Search..."
placeholder={t("placeholder.search")}
/>
<div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5">
{(search || Object.keys(filters).length > 0) && (
@@ -665,7 +671,7 @@ export default function InputWithTags({
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Clear search</TooltipContent>
<TooltipContent>{t("button.clear")}</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
@@ -679,7 +685,7 @@ export default function InputWithTags({
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Save search</TooltipContent>
<TooltipContent>{t("button.save")}</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
@@ -688,12 +694,14 @@ export default function InputWithTags({
<Tooltip>
<TooltipTrigger className="cursor-default">
<MdImageSearch
aria-label="Similarity search active"
aria-label={t("similaritySearch.active")}
className="size-4 text-selected"
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Similarity search active</TooltipContent>
<TooltipContent>
{t("similaritySearch.active")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
@@ -702,10 +710,10 @@ export default function InputWithTags({
<PopoverTrigger asChild>
<button
className="focus:outline-none"
aria-label="Filter information"
aria-label={t("button.filterInformation")}
>
<LuFilter
aria-label="Filters active"
aria-label={t("button.filterActive")}
className={cn(
"size-4",
Object.keys(filters).length > 0
@@ -717,43 +725,24 @@ export default function InputWithTags({
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-2">
<h3 className="font-medium">How to use text filters</h3>
<h3 className="font-medium">{t("filter.tips.title")}</h3>
<p className="text-sm text-muted-foreground">
Filters help you narrow down your search results. Here's how
to use them in the input field:
{t("filter.tips.desc")}
</p>
<ul className="list-disc pl-5 text-sm text-primary-variant">
<li>
Type a filter name followed by a colon (e.g., "cameras:").
</li>
<li>
Select a value from the suggestions or type your own.
</li>
<li>
Use multiple filters by adding them one after another with
a space in between.
</li>
<li>
Date filters (before: and after:) use{" "}
<em>{getIntlDateFormat()}</em> format.
</li>
<li>
Time range filter uses{" "}
<em>
{config?.ui.time_format == "24hour"
<Trans
ns="views/search"
values={{
DateFormat: getIntlDateFormat(),
exampleTime:
config?.ui.time_format == "24hour"
? "15:00-16:00"
: "3:00PM-4:00PM"}{" "}
</em>
format.
</li>
<li>Remove filters by clicking the 'x' next to them.</li>
</ul>
: "3:00PM-4:00PM",
}}
>
filter.tips.desc.step
</Trans>
<p className="text-sm text-muted-foreground">
Example:{" "}
<code className="text-primary">
cameras:front_door label:person before:01012024
time_range:3:00PM-4:00PM
</code>
<Trans ns="views/search">filter.tips.desc.example</Trans>
</p>
</div>
</PopoverContent>
@@ -780,27 +769,27 @@ export default function InputWithTags({
)}
>
{!currentFilterType && inputValue && (
<CommandGroup heading="Search">
<CommandGroup heading={t("search")}>
<CommandItem
className="cursor-pointer"
onSelect={() => handleSearch(inputValue)}
>
<LuSearch className="mr-2 h-4 w-4" />
Search for "{inputValue}"
{t("searchFor", { inputValue })}
</CommandItem>
</CommandGroup>
)}
{(Object.keys(filters).filter((key) => key !== "query").length > 0 ||
isSimilaritySearch) && (
<CommandGroup heading="Active Filters">
<CommandGroup heading={t("filter.header.activeFilters")}>
<div className="my-2 flex flex-wrap gap-2 px-2">
{isSimilaritySearch && (
<span className="inline-flex items-center whitespace-nowrap rounded-full bg-blue-100 px-2 py-0.5 text-sm text-blue-800">
Similarity Search
{t("similaritySearch.title")}
<button
onClick={handleClearSimilarity}
className="ml-1 focus:outline-none"
aria-label="Clear similarity search"
aria-label={t("similaritySearch.clear")}
>
<LuX className="h-3 w-3" />
</button>
@@ -816,8 +805,8 @@ export default function InputWithTags({
key={`${filterType}-${index}`}
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm capitalize text-green-800"
>
{filterType.replaceAll("_", " ")}:{" "}
{value.replaceAll("_", " ")}
{t("filter.label." + filterType)}:{" "}
{formatFilterValues(filterType, value)}
<button
onClick={() =>
removeFilter(filterType as FilterType, value)
@@ -835,10 +824,12 @@ export default function InputWithTags({
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm capitalize text-green-800"
>
{filterType === "event_id"
? "Tracked Object ID"
? t("trackedObjectId")
: filterType === "is_submitted"
? "Submitted to Frigate+"
: filterType.replaceAll("_", " ")}
? t("features.submittedToFrigatePlus.label", {
ns: "components/filter",
})
: t("filter.label." + filterType)}
: {formatFilterValues(filterType, filterValues)}
<button
onClick={() =>
@@ -863,7 +854,7 @@ export default function InputWithTags({
!inputValue &&
searchHistoryLoaded &&
(searchHistory?.length ?? 0) > 0 && (
<CommandGroup heading="Saved Searches">
<CommandGroup heading={t("savedSearches")}>
{searchHistory?.map((suggestion, index) => (
<CommandItem
key={index}
@@ -884,7 +875,7 @@ export default function InputWithTags({
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Delete saved search</TooltipContent>
<TooltipContent>{t("button.delete")}</TooltipContent>
</TooltipPortal>
</Tooltip>
</CommandItem>
@@ -892,7 +883,11 @@ export default function InputWithTags({
</CommandGroup>
)}
<CommandGroup
heading={currentFilterType ? "Filter Values" : "Filters"}
heading={
currentFilterType
? t("filter.header.currentFilterType")
: t("filter.header.noFilters")
}
>
{filterSuggestions(suggestions)
.filter(
@@ -905,7 +900,12 @@ export default function InputWithTags({
className="cursor-pointer"
onSelect={() => handleSuggestionClick(suggestion)}
>
{currentFilterType
? formatFilterValues(currentFilterType, suggestion)
: t("filter.label." + suggestion)}
{" ("}
{suggestion}
{")"}
</CommandItem>
))}
</CommandGroup>
+22 -12
View File
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { useMemo, useState } from "react";
import { isMobile } from "react-device-detect";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
type SaveSearchDialogProps = {
existingNames: string[];
@@ -26,15 +27,22 @@ export function SaveSearchDialog({
onClose,
onSave,
}: SaveSearchDialogProps) {
const { t } = useTranslation(["components/dialog"]);
const [searchName, setSearchName] = useState("");
const handleSave = () => {
if (searchName.trim()) {
onSave(searchName.trim());
setSearchName("");
toast.success(`Search (${searchName.trim()}) has been saved.`, {
position: "top-center",
});
toast.success(
t("search.saveSearch.success", {
searchName: searchName.trim(),
}),
{
position: "top-center",
},
);
onClose();
}
};
@@ -54,34 +62,36 @@ export function SaveSearchDialog({
}}
>
<DialogHeader>
<DialogTitle>Save Search</DialogTitle>
<DialogTitle>{t("search.saveSearch.label")}</DialogTitle>
<DialogDescription className="sr-only">
Provide a name for this saved search.
{t("search.saveSearch.desc")}
</DialogDescription>
</DialogHeader>
<Input
value={searchName}
className="text-md"
onChange={(e) => setSearchName(e.target.value)}
placeholder="Enter a name for your search"
placeholder={t("search.saveSearch.placeholder")}
/>
{overwrite && (
<div className="ml-1 text-sm text-danger">
{searchName} already exists. Saving will overwrite the existing
value.
{t("search.saveSearch.overwrite", { searchName })}
</div>
)}
<DialogFooter>
<Button aria-label="Cancel" onClick={onClose}>
Cancel
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onClose}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
onClick={handleSave}
variant="select"
className="mb-2 md:mb-0"
aria-label="Save this search"
aria-label={t("search.saveSearch.button.save.label")}
>
Save
{t("button.save", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>
+24 -11
View File
@@ -20,16 +20,19 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { DialogClose } from "../ui/dialog";
import { LuLogOut, LuSquarePen } from "react-icons/lu";
import useSWR from "swr";
import { useState } from "react";
import axios from "axios";
import { toast } from "sonner";
import SetPasswordDialog from "../overlay/SetPasswordDialog";
import { useTranslation } from "react-i18next";
type AccountSettingsProps = {
className?: string;
};
export default function AccountSettings({ className }: AccountSettingsProps) {
const { t } = useTranslation(["views/settings"]);
const { data: profile } = useSWR("profile");
const { data: config } = useSWR("config");
const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`;
@@ -48,7 +51,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
.then((response) => {
if (response.status === 200) {
setPasswordDialogOpen(false);
toast.success("Password updated successfully.", {
toast.success(t("users.toast.success.updatePassword"), {
position: "top-center",
});
}
@@ -58,9 +61,14 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Error setting password: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("users.toast.error.setPasswordFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
});
};
@@ -83,7 +91,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right">
<p>Account</p>
<p>{t("menu.user.account", { ns: "common" })}</p>
</TooltipContent>
</TooltipPortal>
</Tooltip>
@@ -95,8 +103,13 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
>
<div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden">
<DropdownMenuLabel>
Current User: {profile?.username || "anonymous"}{" "}
{profile?.role && `(${profile.role})`}
{t("menu.user.current", {
ns: "common",
user:
profile?.username || t("menu.user.anonymous", { ns: "common" }),
})}{" "}
{t("role." + profile?.role) &&
`(${t("role." + profile?.role, { ns: "common" })})`}
</DropdownMenuLabel>
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} />
{profile?.username && profile.username !== "anonymous" && (
@@ -104,22 +117,22 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
aria-label="Set Password"
aria-label={t("menu.user.setPassword", { ns: "common" })}
onClick={() => setPasswordDialogOpen(true)}
>
<LuSquarePen className="mr-2 size-4" />
<span>Set Password</span>
<span>{t("menu.user.setPassword", { ns: "common" })}</span>
</MenuItem>
)}
<MenuItem
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
aria-label="Log out"
aria-label={t("menu.user.logout", { ns: "common" })}
>
<a className="flex" href={logoutUrl}>
<LuLogOut className="mr-2 size-4" />
<span>Logout</span>
<span>{t("menu.user.logout", { ns: "common" })}</span>
</a>
</MenuItem>
</div>
+152 -52
View File
@@ -1,6 +1,7 @@
import {
LuActivity,
LuGithub,
LuLanguages,
LuLifeBuoy,
LuList,
LuLogOut,
@@ -10,6 +11,7 @@ import {
LuSettings,
LuSun,
LuSunMoon,
LuEarth,
} from "react-icons/lu";
import {
DropdownMenu,
@@ -52,21 +54,28 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
import useSWR from "swr";
import RestartDialog from "../overlay/dialog/RestartDialog";
import { useLanguage } from "@/context/language-provider";
import { useIsAdmin } from "@/hooks/use-is-admin";
import SetPasswordDialog from "../overlay/SetPasswordDialog";
import { toast } from "sonner";
import axios from "axios";
import { FrigateConfig } from "@/types/frigateConfig";
import { useTranslation } from "react-i18next";
type GeneralSettingsProps = {
className?: string;
};
export default function GeneralSettings({ className }: GeneralSettingsProps) {
const { t } = useTranslation(["common"]);
const { data: profile } = useSWR("profile");
const { data: config } = useSWR<FrigateConfig>("config");
const logoutUrl = config?.proxy?.logout_url || "/api/logout";
// settings
const { language, setLanguage, systemLanguage } = useLanguage();
const { theme, colorScheme, setTheme, setColorScheme } = useTheme();
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
@@ -90,9 +99,12 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
.then((response) => {
if (response.status === 200) {
setPasswordDialogOpen(false);
toast.success("Password updated successfully.", {
position: "top-center",
});
toast.success(
t("users.toast.success.updatePassword", { ns: "views/settings" }),
{
position: "top-center",
},
);
}
})
.catch((error) => {
@@ -100,9 +112,15 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Error setting password: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("users.toast.error.setPasswordFailed", {
ns: "views/settings",
errorMessage,
}),
{
position: "top-center",
},
);
});
};
@@ -126,7 +144,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right">
<p>Settings</p>
<p>{t("menu.settings")}</p>
</TooltipContent>
</TooltipPortal>
</Tooltip>
@@ -150,8 +168,11 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
{isMobile && (
<div className="mb-2">
<DropdownMenuLabel>
Current User: {profile?.username || "anonymous"}{" "}
{profile?.role && `(${profile.role})`}
{t("menu.user.current", {
user: profile?.username || t("menu.user.anonymous"),
})}{" "}
{t("role." + profile?.role) &&
`(${t("role." + profile?.role)})`}
</DropdownMenuLabel>
<DropdownMenuSeparator
className={isDesktop ? "mt-3" : "mt-1"}
@@ -163,11 +184,11 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label="Set Password"
aria-label={t("menu.user.setPassword")}
onClick={() => setPasswordDialogOpen(true)}
>
<LuSquarePen className="mr-2 size-4" />
<span>Set Password</span>
<span>{t("menu.user.setPassword")}</span>
</MenuItem>
)}
<MenuItem
@@ -176,18 +197,18 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label="Log out"
aria-label={t("menu.user.logout", { ns: "common" })}
>
<a className="flex" href={logoutUrl}>
<LuLogOut className="mr-2 size-4" />
<span>Logout</span>
<span>{t("menu.user.logout", { ns: "common" })}</span>
</a>
</MenuItem>
</div>
)}
{isAdmin && (
<>
<DropdownMenuLabel>System</DropdownMenuLabel>
<DropdownMenuLabel>{t("menu.system")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
<Link to="/system#general">
@@ -197,10 +218,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
aria-label="System metrics"
aria-label={t("menu.systemMetrics")}
>
<LuActivity className="mr-2 size-4" />
<span>System metrics</span>
<span>{t("menu.systemMetrics")}</span>
</MenuItem>
</Link>
<Link to="/logs">
@@ -210,10 +231,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
aria-label="System logs"
aria-label={t("menu.systemLogs")}
>
<LuList className="mr-2 size-4" />
<span>System logs</span>
<span>{t("menu.systemLogs")}</span>
</MenuItem>
</Link>
</DropdownMenuGroup>
@@ -222,7 +243,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<DropdownMenuLabel
className={isDesktop && isAdmin ? "mt-3" : "mt-1"}
>
Configuration
{t("menu.configuration")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
@@ -233,10 +254,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
aria-label="Settings"
aria-label={t("menu.settings")}
>
<LuSettings className="mr-2 size-4" />
<span>Settings</span>
<span>{t("menu.settings")}</span>
</MenuItem>
</Link>
{isAdmin && (
@@ -248,10 +269,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
aria-label="Configuration editor"
aria-label={t("menu.configurationEditor")}
>
<LuSquarePen className="mr-2 size-4" />
<span>Configuration editor</span>
<span>{t("menu.configurationEditor")}</span>
</MenuItem>
</Link>
</>
@@ -271,7 +292,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
)}
</DropdownMenuGroup>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Appearance
{t("menu.appearance")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<SubItem>
@@ -280,8 +301,8 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
>
<LuSunMoon className="mr-2 size-4" />
<span>Dark Mode</span>
<LuLanguages className="mr-2 size-4" />
<span>{t("menu.languages")}</span>
</SubItemTrigger>
<Portal>
<SubItemContent
@@ -296,16 +317,16 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label="Light mode"
onClick={() => setTheme("light")}
aria-label={t("menu.language.en")}
onClick={() => setLanguage("en")}
>
{theme === "light" ? (
{language.trim() === "en" ? (
<>
<LuSun className="mr-2 size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
Light
<LuLanguages className="mr-2 size-4" />
{t("menu.language.en")}
</>
) : (
<span className="ml-6 mr-2">Light</span>
<span className="ml-6 mr-2">{t("menu.language.en")}</span>
)}
</MenuItem>
<MenuItem
@@ -314,16 +335,18 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label="Dark mode"
onClick={() => setTheme("dark")}
aria-label={t("menu.language.zhCN")}
onClick={() => setLanguage("zh-CN")}
>
{theme === "dark" ? (
{language === "zh-CN" ? (
<>
<LuMoon className="mr-2 size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
Dark
<LuLanguages className="mr-2 size-4" />
{t("menu.language.zhCN")}
</>
) : (
<span className="ml-6 mr-2">Dark</span>
<span className="ml-6 mr-2">
{t("menu.language.zhCN")}
</span>
)}
</MenuItem>
<MenuItem
@@ -332,16 +355,16 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label="Use the system settings for light or dark mode"
onClick={() => setTheme("system")}
aria-label={t("menu.language.withSystem.label")}
onClick={() => setLanguage(systemLanguage)}
>
{theme === "system" ? (
{language === systemLanguage ? (
<>
<CgDarkMode className="mr-2 size-4 scale-100 transition-all" />
System
<LuEarth className="mr-2 size-4 scale-100 transition-all" />
{t("menu.withSystem")}
</>
) : (
<span className="ml-6 mr-2">System</span>
<span className="ml-6 mr-2">{t("menu.withSystem")}</span>
)}
</MenuItem>
</SubItemContent>
@@ -354,7 +377,84 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
}
>
<LuSunMoon className="mr-2 size-4" />
<span>Theme</span>
<span>{t("menu.darkMode.label")}</span>
</SubItemTrigger>
<Portal>
<SubItemContent
className={
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
}
>
<span tabIndex={0} className="sr-only" />
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label={t("menu.darkMode.light")}
onClick={() => setTheme("light")}
>
{theme === "light" ? (
<>
<LuSun className="mr-2 size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
{t("menu.darkMode.light")}
</>
) : (
<span className="ml-6 mr-2">
{t("menu.darkMode.light")}
</span>
)}
</MenuItem>
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label={t("menu.darkMode.dark")}
onClick={() => setTheme("dark")}
>
{theme === "dark" ? (
<>
<LuMoon className="mr-2 size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
{t("menu.darkMode.dark")}
</>
) : (
<span className="ml-6 mr-2">
{t("menu.darkMode.dark")}
</span>
)}
</MenuItem>
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label={t("menu.darkMode.withSystem.label")}
onClick={() => setTheme("system")}
>
{theme === "system" ? (
<>
<CgDarkMode className="mr-2 size-4 scale-100 transition-all" />
{t("menu.withSystem")}
</>
) : (
<span className="ml-6 mr-2">{t("menu.withSystem")}</span>
)}
</MenuItem>
</SubItemContent>
</Portal>
</SubItem>
<SubItem>
<SubItemTrigger
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
>
<LuSunMoon className="mr-2 size-4" />
<span>{t("menu.theme.label")}</span>
</SubItemTrigger>
<Portal>
<SubItemContent
@@ -377,11 +477,11 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
{scheme === colorScheme ? (
<>
<IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" />
{friendlyColorSchemeName(scheme)}
{t(friendlyColorSchemeName(scheme))}
</>
) : (
<span className="ml-6 mr-2">
{friendlyColorSchemeName(scheme)}
{t(friendlyColorSchemeName(scheme))}
</span>
)}
</MenuItem>
@@ -390,7 +490,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
</Portal>
</SubItem>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Help
{t("menu.help")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<a href="https://docs.frigate.video" target="_blank">
@@ -398,10 +498,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
aria-label="Frigate documentation"
aria-label={t("menu.documentation.label")}
>
<LuLifeBuoy className="mr-2 size-4" />
<span>Documentation</span>
<span>{t("menu.documentation")}</span>
</MenuItem>
</a>
<a
@@ -429,11 +529,11 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label="Restart Frigate"
aria-label={t("menu.restart")}
onClick={() => setRestartDialogOpen(true)}
>
<LuRotateCw className="mr-2 size-4" />
<span>Restart Frigate</span>
<span>{t("menu.restart")}</span>
</MenuItem>
</>
)}
+47 -28
View File
@@ -44,6 +44,7 @@ import {
useNotifications,
useNotificationSuspend,
} from "@/api/ws";
import { useTranslation } from "react-i18next";
type LiveContextMenuProps = {
className?: string;
@@ -85,6 +86,7 @@ export default function LiveContextMenu({
config,
children,
}: LiveContextMenuProps) {
const { t } = useTranslation("views/live");
const [showSettings, setShowSettings] = useState(false);
// camera enabled
@@ -209,7 +211,7 @@ export default function LiveContextMenu({
// notifications
const notificationsEnabledInConfig =
config?.cameras[camera].notifications.enabled_in_config;
config?.cameras[camera]?.notifications?.enabled_in_config;
const { payload: notificationState, send: sendNotification } =
useNotifications(camera);
@@ -234,14 +236,19 @@ export default function LiveContextMenu({
};
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(Number.parseInt(timestamp), {
const time = formatUnixTimestampToDateTime(Number.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 (
@@ -256,7 +263,7 @@ export default function LiveContextMenu({
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<div className="flex flex-row items-center gap-1">
<IoIosWarning className="mr-1 size-4 text-danger" />
<p className="mr-2 text-xs">Low-bandwidth mode</p>
<p className="mr-2 text-xs">{t("lowBandwidthMode")}</p>
</div>
)}
</div>
@@ -265,7 +272,7 @@ export default function LiveContextMenu({
<ContextMenuSeparator className="mb-1" />
<div className="p-2 text-sm">
<div className="flex w-full flex-col gap-1">
<p>Audio</p>
<p>{t("audio")}</p>
<div className="flex flex-row items-center gap-1">
<VolumeIcon
className="size-5"
@@ -292,7 +299,7 @@ export default function LiveContextMenu({
onClick={() => sendEnabled(isEnabled ? "OFF" : "ON")}
>
<div className="text-primary">
{isEnabled ? "Disable" : "Enable"} Camera
{isEnabled ? t("camera.disable") : t("camera.enable")}
</div>
</div>
</ContextMenuItem>
@@ -302,7 +309,7 @@ export default function LiveContextMenu({
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? muteAll : undefined}
>
<div className="text-primary">Mute All Cameras</div>
<div className="text-primary">{t("muteCameras.enable")}</div>
</div>
</ContextMenuItem>
<ContextMenuItem disabled={!isEnabled}>
@@ -310,7 +317,7 @@ export default function LiveContextMenu({
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? unmuteAll : undefined}
>
<div className="text-primary">Unmute All Cameras</div>
<div className="text-primary">{t("muteCameras.disable")}</div>
</div>
</ContextMenuItem>
<ContextMenuSeparator />
@@ -320,7 +327,9 @@ export default function LiveContextMenu({
onClick={isEnabled ? toggleStats : undefined}
>
<div className="text-primary">
{statsState ? "Hide" : "Show"} Stream Stats
{statsState
? t("streamStats.disable")
: t("streamStats.enable")}
</div>
</div>
</ContextMenuItem>
@@ -333,7 +342,9 @@ export default function LiveContextMenu({
: undefined
}
>
<div className="text-primary">Debug View</div>
<div className="text-primary">
{t("streaming.debugView", { ns: "components/dialog" })}
</div>
</div>
</ContextMenuItem>
{cameraGroup && cameraGroup !== "default" && (
@@ -344,7 +355,7 @@ export default function LiveContextMenu({
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? () => setShowSettings(true) : undefined}
>
<div className="text-primary">Streaming Settings</div>
<div className="text-primary">{t("streamingSettings")}</div>
</div>
</ContextMenuItem>
</>
@@ -357,7 +368,9 @@ export default function LiveContextMenu({
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? resetPreferredLiveMode : undefined}
>
<div className="text-primary">Reset</div>
<div className="text-primary">
{t("button.reset", { ns: "common" })}
</div>
</div>
</ContextMenuItem>
</>
@@ -368,7 +381,7 @@ export default function LiveContextMenu({
<ContextMenuSub>
<ContextMenuSubTrigger disabled={!isEnabled}>
<div className="flex items-center gap-2">
<span>Notifications</span>
<span>{t("notifications")}</span>
</div>
</ContextMenuSubTrigger>
<ContextMenuSubContent>
@@ -379,25 +392,29 @@ export default function LiveContextMenu({
{isSuspended ? (
<>
<IoIosNotificationsOff className="size-5 text-muted-foreground" />
<span>Suspended</span>
<span>
{t("button.suspended", { ns: "common" })}
</span>
</>
) : (
<>
<IoIosNotifications className="size-5 text-muted-foreground" />
<span>Enabled</span>
<span>
{t("button.enabled", { ns: "common" })}
</span>
</>
)}
</>
) : (
<>
<IoIosNotificationsOff className="size-5 text-danger" />
<span>Disabled</span>
<span>{t("button.disabled", { ns: "common" })}</span>
</>
)}
</div>
{isSuspended && (
<span className="text-xs text-primary-variant">
Until {formatSuspendedUntil(notificationSuspendUntil)}
{formatSuspendedUntil(notificationSuspendUntil)}
</span>
)}
</div>
@@ -418,9 +435,11 @@ export default function LiveContextMenu({
>
<div className="flex w-full flex-col gap-2">
{notificationState === "ON" ? (
<span>Unsuspend</span>
<span>
{t("button.unsuspended", { ns: "common" })}
</span>
) : (
<span>Enable</span>
<span>{t("button.enable", { ns: "common" })}</span>
)}
</div>
</ContextMenuItem>
@@ -431,7 +450,7 @@ export default function LiveContextMenu({
<ContextMenuSeparator />
<div className="px-2 py-1.5">
<p className="mb-2 text-sm font-medium text-muted-foreground">
Suspend for:
{t("suspend.forTime")}
</p>
<div className="space-y-1">
<ContextMenuItem
@@ -440,7 +459,7 @@ export default function LiveContextMenu({
isEnabled ? () => handleSuspend("5") : undefined
}
>
5 minutes
{t("time.5minutes", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
@@ -450,7 +469,7 @@ export default function LiveContextMenu({
: undefined
}
>
10 minutes
{t("time.10minutes", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
@@ -460,7 +479,7 @@ export default function LiveContextMenu({
: undefined
}
>
30 minutes
{t("time.30minutes", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
@@ -470,7 +489,7 @@ export default function LiveContextMenu({
: undefined
}
>
1 hour
{t("time.1hour", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
@@ -480,7 +499,7 @@ export default function LiveContextMenu({
: undefined
}
>
12 hours
{t("time.12hours", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
@@ -490,7 +509,7 @@ export default function LiveContextMenu({
: undefined
}
>
24 hours
{t("time.24hours", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
@@ -500,7 +519,7 @@ export default function LiveContextMenu({
: undefined
}
>
Until restart
{t("time.untilRestart", { ns: "common" })}
</ContextMenuItem>
</div>
</div>
+38 -28
View File
@@ -40,6 +40,8 @@ import {
} from "@/components/ui/tooltip";
import useSWR from "swr";
import { Trans, useTranslation } from "react-i18next";
type SearchResultActionsProps = {
searchResult: SearchResult;
findSimilar: () => void;
@@ -59,6 +61,8 @@ export default function SearchResultActions({
isContextMenu = false,
children,
}: SearchResultActionsProps) {
const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -68,7 +72,7 @@ export default function SearchResultActions({
.delete(`events/${searchResult.id}`)
.then((resp) => {
if (resp.status == 200) {
toast.success("Tracked object deleted successfully.", {
toast.success(t("searchResult.deleteTrackedObject.toast.success"), {
position: "top-center",
});
refreshResults();
@@ -79,9 +83,12 @@ export default function SearchResultActions({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to delete tracked object: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("searchResult.deleteTrackedObject.toast.error", { errorMessage }),
{
position: "top-center",
},
);
});
};
@@ -90,45 +97,45 @@ export default function SearchResultActions({
const menuItems = (
<>
{searchResult.has_clip && (
<MenuItem aria-label="Download video">
<MenuItem aria-label={t("itemMenu.downloadVideo.aria")}>
<a
className="flex items-center"
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
download={`${searchResult.camera}_${searchResult.label}.mp4`}
>
<LuDownload className="mr-2 size-4" />
<span>Download video</span>
<span>{t("itemMenu.downloadVideo")}</span>
</a>
</MenuItem>
)}
{searchResult.has_snapshot && (
<MenuItem aria-label="Download snapshot">
<MenuItem aria-label={t("itemMenu.downloadSnapshot.aria")}>
<a
className="flex items-center"
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
download={`${searchResult.camera}_${searchResult.label}.jpg`}
>
<LuCamera className="mr-2 size-4" />
<span>Download snapshot</span>
<span>{t("itemMenu.downloadSnapshot.label")}</span>
</a>
</MenuItem>
)}
{searchResult.data.type == "object" && (
<MenuItem
aria-label="Show the object lifecycle"
aria-label={t("itemMenu.viewObjectLifecycle.aria")}
onClick={showObjectLifecycle}
>
<FaArrowsRotate className="mr-2 size-4" />
<span>View object lifecycle</span>
<span>{t("itemMenu.viewObjectLifecycle.label")}</span>
</MenuItem>
)}
{config?.semantic_search?.enabled && isContextMenu && (
<MenuItem
aria-label="Find similar tracked objects"
aria-label={t("itemMenu.findSimilar.aria")}
onClick={findSimilar}
>
<MdImageSearch className="mr-2 size-4" />
<span>Find similar</span>
<span>{t("itemMenu.findSimilar.label")}</span>
</MenuItem>
)}
{isMobileOnly &&
@@ -137,17 +144,20 @@ export default function SearchResultActions({
searchResult.end_time &&
searchResult.data.type == "object" &&
!searchResult.plus_id && (
<MenuItem aria-label="Submit to Frigate Plus" onClick={showSnapshot}>
<MenuItem
aria-label={t("itemMenu.submitToPlus.aria")}
onClick={showSnapshot}
>
<FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" />
<span>Submit to Frigate+</span>
<span>{t("itemMenu.submitToPlus")}</span>
</MenuItem>
)}
<MenuItem
aria-label="Delete this tracked object"
aria-label={t("itemMenu.deleteTrackedObject.label")}
onClick={() => setDeleteDialogOpen(true)}
>
<LuTrash2 className="mr-2 size-4" />
<span>Delete</span>
<span>{t("button.delete", { ns: "common" })}</span>
</MenuItem>
</>
);
@@ -160,24 +170,20 @@ export default function SearchResultActions({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
<AlertDialogTitle>{t("dialog.confirmDelete")}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Deleting this tracked object removes the snapshot, any saved
embeddings, and any associated object lifecycle entries. Recorded
footage of this tracked object in History view will <em>NOT</em> be
deleted.
<br />
<br />
Are you sure you want to proceed?
<Trans ns="views/explore">dialog.confirmDelete.desc</Trans>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={handleDelete}
>
Delete
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -198,7 +204,9 @@ export default function SearchResultActions({
onClick={findSimilar}
/>
</TooltipTrigger>
<TooltipContent>Find similar</TooltipContent>
<TooltipContent>
{t("itemMenu.findSimilar.label")}
</TooltipContent>
</Tooltip>
)}
@@ -215,7 +223,9 @@ export default function SearchResultActions({
onClick={showSnapshot}
/>
</TooltipTrigger>
<TooltipContent>Submit to Frigate+</TooltipContent>
<TooltipContent>
{t("itemMenu.submitToPlus.label")}
</TooltipContent>
</Tooltip>
)}
+3 -1
View File
@@ -5,6 +5,7 @@ import { IoMdArrowRoundBack } from "react-icons/io";
import { cn } from "@/lib/utils";
import { isPWA } from "@/utils/isPWA";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
const MobilePageContext = createContext<{
open: boolean;
@@ -138,6 +139,7 @@ export function MobilePageHeader({
onClose,
...props
}: MobilePageHeaderProps) {
const { t } = useTranslation(["common"]);
const context = useContext(MobilePageContext);
if (!context)
throw new Error("MobilePageHeader must be used within MobilePage");
@@ -160,7 +162,7 @@ export function MobilePageHeader({
>
<Button
className="absolute left-0 rounded-lg"
aria-label="Go back"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={handleClose}
>
+3 -1
View File
@@ -9,6 +9,7 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
import { NavData } from "@/types/navigation";
import { IconType } from "react-icons";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
const variants = {
primary: {
@@ -34,6 +35,7 @@ export default function NavItem({
Icon,
onClick,
}: NavItemProps) {
const { t } = useTranslation(["common"]);
if (item.enabled == false) {
return;
}
@@ -60,7 +62,7 @@ export default function NavItem({
<TooltipTrigger>{content}</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right">
<p>{item.title}</p>
<p>{t(item.title)}</p>
</TooltipContent>
</TooltipPortal>
</Tooltip>
+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>
+4 -2
View File
@@ -18,6 +18,7 @@ import { useOverlayState } from "@/hooks/use-overlay-state";
import { usePersistence } from "@/hooks/use-persistence";
import { cn } from "@/lib/utils";
import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record";
import { useTranslation } from "react-i18next";
// Android native hls does not seek correctly
const USE_NATIVE_HLS = !isAndroid;
@@ -63,6 +64,7 @@ export default function HlsVideoPlayer({
toggleFullscreen,
onError,
}: HlsVideoPlayerProps) {
const { t } = useTranslation("components/player");
const { data: config } = useSWR<FrigateConfig>("config");
// playback
@@ -236,11 +238,11 @@ export default function HlsVideoPlayer({
const resp = await onUploadFrame(videoRef.current.currentTime);
if (resp && resp.status == 200) {
toast.success("Successfully submitted frame to Frigate+", {
toast.success(t("toast.success.submittedFrigatePlus"), {
position: "top-center",
});
} else {
toast.success("Failed to submit frame to Frigate+", {
toast.success(t("toast.error.submitFrigatePlusFailed"), {
position: "top-center",
});
}
+14 -6
View File
@@ -23,6 +23,7 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
import { baseUrl } from "@/api/baseUrl";
import { PlayerStats } from "./PlayerStats";
import { LuVideoOff } from "react-icons/lu";
import { Trans, useTranslation } from "react-i18next";
type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void;
@@ -71,6 +72,8 @@ export default function LivePlayer({
onError,
onResetLiveMode,
}: LivePlayerProps) {
const { t } = useTranslation(["components/player"]);
const internalContainerRef = useRef<HTMLDivElement | null>(null);
// stats
@@ -272,7 +275,7 @@ export default function LivePlayer({
} else {
player = (
<div className="w-5xl text-center text-sm">
iOS 17.1 or greater is required for this live stream type.
{t("livePlayerRequiredIOSVersion")}
</div>
);
}
@@ -400,12 +403,17 @@ export default function LivePlayer({
{offline && !showStillWithoutActivity && cameraEnabled && (
<div className="absolute inset-0 left-1/2 top-1/2 flex h-96 w-96 -translate-x-1/2 -translate-y-1/2">
<div className="flex flex-col items-center justify-center rounded-lg bg-background/50 p-5">
<p className="my-5 text-lg">Stream offline</p>
<p className="my-5 text-lg">{t("streamOffline.title")}</p>
<TbExclamationCircle className="mb-3 size-10" />
<p className="max-w-96 text-center">
No frames have been received on the{" "}
{capitalizeFirstLetter(cameraConfig.name)} <code>detect</code>{" "}
stream, check error logs
<Trans
values={{
cameraName: capitalizeFirstLetter(cameraConfig.name),
ns: "components/player",
}}
>
streamOffline.desc
</Trans>
</p>
</div>
</div>
@@ -416,7 +424,7 @@ export default function LivePlayer({
<div className="flex h-32 flex-col items-center justify-center rounded-lg p-4 md:h-48 md:w-48">
<LuVideoOff className="mb-2 size-8 md:size-10" />
<p className="max-w-32 text-center text-sm md:max-w-40 md:text-base">
Camera is disabled
{t("cameraDisabled")}
</p>
</div>
</div>
+24 -14
View File
@@ -1,5 +1,6 @@
import { cn } from "@/lib/utils";
import { PlayerStatsType } from "@/types/live";
import { useTranslation } from "react-i18next";
type PlayerStatsProps = {
stats: PlayerStatsType;
@@ -7,45 +8,46 @@ type PlayerStatsProps = {
};
export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
const { t } = useTranslation(["components/player"]);
const fullStatsContent = (
<>
<p>
<span className="text-white/70">Stream Type:</span>{" "}
<span className="text-white/70">{t("stats.streamType")}</span>{" "}
<span className="text-white">{stats.streamType}</span>
</p>
<p>
<span className="text-white/70">Bandwidth:</span>{" "}
<span className="text-white/70">{t("stats.bandwidth")}</span>{" "}
<span className="text-white">{stats.bandwidth.toFixed(2)} kbps</span>
</p>
{stats.latency != undefined && (
<p>
<span className="text-white/70">Latency:</span>{" "}
<span className="text-white/70">{t("stats.latency")}</span>{" "}
<span
className={`text-white ${stats.latency > 2 ? "text-danger" : ""}`}
>
{stats.latency.toFixed(2)} seconds
{t("stats.latency.value", { secounds: stats.latency.toFixed(2) })}
</span>
</p>
)}
<p>
<span className="text-white/70">Total Frames:</span>{" "}
<span className="text-white/70">{t("stats.totalFrames")}</span>{" "}
<span className="text-white">{stats.totalFrames}</span>
</p>
{stats.droppedFrames != undefined && (
<p>
<span className="text-white/70">Dropped Frames:</span>{" "}
<span className="text-white/70">{t("stats.droppedFrames")}</span>{" "}
<span className="text-white">{stats.droppedFrames}</span>
</p>
)}
{stats.decodedFrames != undefined && (
<p>
<span className="text-white/70">Decoded Frames:</span>{" "}
<span className="text-white/70">{t("stats.decodedFrames")}</span>{" "}
<span className="text-white">{stats.decodedFrames}</span>
</p>
)}
{stats.droppedFrameRate != undefined && (
<p>
<span className="text-white/70">Dropped Frame Rate:</span>{" "}
<span className="text-white/70">{t("stats.droppedFrameRate")}</span>{" "}
<span className="text-white">
{stats.droppedFrameRate.toFixed(2)}%
</span>
@@ -57,27 +59,35 @@ export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
const minimalStatsContent = (
<div className="flex flex-row items-center justify-center gap-4">
<div className="flex flex-col items-center justify-start gap-1">
<span className="text-white/70">Type</span>
<span className="text-white/70">{t("stats.streamType.short")}</span>
<span className="text-white">{stats.streamType}</span>
</div>
<div className="flex flex-col items-center gap-1">
<span className="text-white/70">Bandwidth</span>{" "}
<span className="text-white/70">{t("stats.bandwidth.short")}</span>{" "}
<span className="text-white">{stats.bandwidth.toFixed(2)} kbps</span>
</div>
{stats.latency != undefined && (
<div className="hidden flex-col items-center gap-1 md:flex">
<span className="text-white/70">Latency</span>
<span className="text-white/70">{t("stats.latency.short")}</span>
<span
className={`text-white ${stats.latency >= 2 ? "text-danger" : ""}`}
>
{stats.latency.toFixed(2)} sec
{t("stats.latency.short.value", {
secounds: stats.latency.toFixed(2),
})}
</span>
</div>
)}
{stats.droppedFrames != undefined && (
<div className="flex flex-col items-center justify-end gap-1">
<span className="text-white/70">Dropped</span>
<span className="text-white">{stats.droppedFrames} frames</span>
<span className="text-white/70">
{t("stats.droppedFrames.short")}
</span>
<span className="text-white">
{t("stats.droppedFrames.short.value", {
droppedFrames: stats.droppedFrames,
})}
</span>
</div>
)}
</div>
+8 -3
View File
@@ -20,6 +20,7 @@ import {
getPreviewForTimeRange,
usePreviewForTimeRange,
} from "@/hooks/use-camera-previews";
import { useTranslation } from "react-i18next";
type PreviewPlayerProps = {
className?: string;
@@ -42,6 +43,7 @@ export default function PreviewPlayer({
onControllerReady,
onClick,
}: PreviewPlayerProps) {
const { t } = useTranslation(["components/player"]);
const [currentHourFrame, setCurrentHourFrame] = useState<string>();
const currentPreview = usePreviewForTimeRange(
cameraPreviews,
@@ -88,7 +90,7 @@ export default function PreviewPlayer({
className,
)}
>
No Preview Found
{t("noPreviewFound")}
</div>
);
}
@@ -133,6 +135,7 @@ function PreviewVideoPlayer({
onClick,
setCurrentHourFrame,
}: PreviewVideoPlayerProps) {
const { t } = useTranslation(["components/player"]);
const { data: config } = useSWR<FrigateConfig>("config");
// controlling playback
@@ -324,7 +327,7 @@ function PreviewVideoPlayer({
</video>
{cameraPreviews && !currentPreview && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background_alt text-primary dark:bg-black md:rounded-2xl">
No Preview Found for {camera.replaceAll("_", " ")}
{t("noPreviewFoundFor", { camera: camera.replaceAll("_", " ") })}
</div>
)}
{firstLoad && <Skeleton className="absolute aspect-video size-full" />}
@@ -444,6 +447,8 @@ function PreviewFramesPlayer({
onControllerReady,
onClick,
}: PreviewFramesPlayerProps) {
const { t } = useTranslation(["components/player"]);
// frames data
const { data: previewFrames } = useSWR<string[]>(
@@ -544,7 +549,7 @@ function PreviewFramesPlayer({
/>
{previewFrames?.length === 0 && (
<div className="-y-translate-1/2 align-center absolute inset-x-0 top-1/2 rounded-lg bg-background_alt text-center text-primary dark:bg-black md:rounded-2xl">
No Preview Found for {camera.replaceAll("_", " ")}
{t("noPreviewFoundFor", { cameraName: camera.replaceAll("_", " ") })}
</div>
)}
{firstLoad && <Skeleton className="absolute aspect-video size-full" />}
@@ -21,6 +21,7 @@ import { cn } from "@/lib/utils";
import { InProgressPreview, VideoPreview } from "../preview/ScrubbablePreview";
import { Preview } from "@/types/preview";
import { baseUrl } from "@/api/baseUrl";
import { useTranslation } from "react-i18next";
type PreviewPlayerProps = {
review: ReviewSegment;
@@ -41,6 +42,7 @@ export default function PreviewThumbnailPlayer({
onClick,
onTimeUpdate,
}: PreviewPlayerProps) {
const { t } = useTranslation(["components/player"]);
const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
@@ -167,7 +169,9 @@ export default function PreviewThumbnailPlayer({
const formattedDate = useFormattedTimestamp(
review.start_time,
config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p",
config?.ui.time_format == "24hour"
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
: t("time.formattedTimestampExcludeSeconds", { ns: "common" }),
config?.ui?.timezone,
);
+8 -3
View File
@@ -33,6 +33,7 @@ import {
} from "../ui/alert-dialog";
import { cn } from "@/lib/utils";
import { FaCompress, FaExpand } from "react-icons/fa";
import { useTranslation } from "react-i18next";
type VideoControls = {
volume?: boolean;
@@ -309,6 +310,8 @@ function FrigatePlusUploadButton({
onUploadFrame,
containerRef,
}: FrigatePlusUploadButtonProps) {
const { t } = useTranslation(["components/player"]);
const [videoImg, setVideoImg] = useState<string>();
return (
@@ -346,14 +349,16 @@ function FrigatePlusUploadButton({
className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl"
>
<AlertDialogHeader>
<AlertDialogTitle>Submit this frame to Frigate+?</AlertDialogTitle>
<AlertDialogTitle>{t("submitFrigatePlus.title")}</AlertDialogTitle>
</AlertDialogHeader>
<img className="aspect-video w-full object-contain" src={videoImg} />
<AlertDialogFooter>
<AlertDialogAction className="bg-selected" onClick={onUploadFrame}>
Submit
{t("submitFrigatePlus.submit")}
</AlertDialogAction>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -12,6 +12,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import { VideoResolutionType } from "@/types/live";
import axios from "axios";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
/**
* Dynamically switches between video playback and scrubbing preview player.
@@ -50,6 +51,7 @@ export default function DynamicVideoPlayer({
toggleFullscreen,
containerRef,
}: DynamicVideoPlayerProps) {
const { t } = useTranslation(["components/player"]);
const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
@@ -247,7 +249,7 @@ export default function DynamicVideoPlayer({
)}
{!isScrubbing && !isLoading && noRecording && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
No recordings found for this time
{t("noRecordingsFoundForThisTime")}
</div>
)}
</>
@@ -31,6 +31,7 @@ import useSWR from "swr";
import { LuCheck, LuExternalLink, LuInfo, LuX } from "react-icons/lu";
import { Link } from "react-router-dom";
import { LiveStreamMetadata } from "@/types/live";
import { Trans, useTranslation } from "react-i18next";
type CameraStreamingDialogProps = {
camera: string;
@@ -49,6 +50,7 @@ export function CameraStreamingDialog({
setIsDialogOpen,
onSave,
}: CameraStreamingDialogProps) {
const { t } = useTranslation(["components/camera"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [isLoading, setIsLoading] = useState(false);
@@ -167,30 +169,36 @@ export function CameraStreamingDialog({
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="mb-4">
<DialogTitle className="capitalize">
{camera.replaceAll("_", " ")} Streaming Settings
{t("group.camera.setting.title", {
cameraName: camera.replaceAll("_", " "),
})}
</DialogTitle>
<DialogDescription>
Change the live streaming options for this camera group's dashboard.{" "}
<em>These settings are device/browser-specific.</em>
<Trans ns="components/camera">group.camera.setting.desc</Trans>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col space-y-8">
{!isRestreamed && (
<div className="flex flex-col gap-2">
<Label>Stream</Label>
<Label></Label>
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<LuX className="size-4 text-danger" />
<div>Restreaming is not enabled for this camera.</div>
<div>
{t("streaming.restreaming.disabled", {
ns: "components/dialog",
})}
</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Set up go2rtc for additional live view options and audio for
this camera.
{t("streaming.restreaming.desc", { ns: "components/dialog" })}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
@@ -198,7 +206,9 @@ export function CameraStreamingDialog({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t("streaming.restreaming.readTheDocumentation", {
ns: "components/dialog",
})}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -231,22 +241,23 @@ export function CameraStreamingDialog({
{supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>Audio is available for this stream</div>
<div>{t("group.camera.setting.audioIsAvailable")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>Audio is unavailable for this stream</div>
<div>{t("group.camera.setting.audioIsUnavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Audio must be output from your camera and configured
in go2rtc for this stream.
{t("group.camera.setting.audio.desc")}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
@@ -254,7 +265,7 @@ export function CameraStreamingDialog({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t("group.camera.setting.audio.desc.document")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -268,7 +279,7 @@ export function CameraStreamingDialog({
)}
<div className="flex flex-col items-start gap-2">
<Label htmlFor="streaming-method" className="text-right">
Streaming Method
{t("group.camera.setting.streamMethod.label")}
</Label>
<Select
value={streamType}
@@ -278,38 +289,44 @@ export function CameraStreamingDialog({
<SelectValue placeholder="Choose a streaming option" />
</SelectTrigger>
<SelectContent>
<SelectItem value="no-streaming">No Streaming</SelectItem>
<SelectItem value="no-streaming">
{t("group.camera.setting.streamMethod.method.noStreaming")}
</SelectItem>
<SelectItem value="smart">
Smart Streaming (recommended)
{t("group.camera.setting.streamMethod.method.smartStreaming")}
</SelectItem>
<SelectItem value="continuous">
{t(
"group.camera.setting.streamMethod.method.continuousStreaming",
)}
</SelectItem>
<SelectItem value="continuous">Continuous Streaming</SelectItem>
</SelectContent>
</Select>
{streamType === "no-streaming" && (
<p className="text-sm text-muted-foreground">
Camera images will only update once per minute and no live
streaming will occur.
{t("group.camera.setting.streamMethod.method.noStreaming.desc")}
</p>
)}
{streamType === "smart" && (
<p className="text-sm text-muted-foreground">
Smart streaming will update your camera image once per minute when
no detectable activity is occurring to conserve bandwidth and
resources. When activity is detected, the image seamlessly
switches to a live stream.
{t(
"group.camera.setting.streamMethod.method.smartStreaming.desc",
)}
</p>
)}
{streamType === "continuous" && (
<>
<p className="text-sm text-muted-foreground">
Camera image will always be a live stream when visible on the
dashboard, even if no activity is being detected.
{t(
"group.camera.setting.streamMethod.method.continuousStreaming.desc",
)}
</p>
<div className="flex items-center gap-2">
<IoIosWarning className="mr-2 size-5 text-danger" />
<div className="max-w-[85%] text-sm">
Continuous streaming may cause high bandwidth usage and
performance issues. Use with caution.
{t(
"group.camera.setting.streamMethod.method.continuousStreaming.desc.warning",
)}
</div>
</div>
</>
@@ -327,14 +344,12 @@ export function CameraStreamingDialog({
htmlFor="compatibility"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Compatibility mode
{t("group.camera.setting.compatibilityMode")}
</Label>
</div>
<div className="flex flex-col gap-2 leading-none">
<p className="text-sm text-muted-foreground">
Enable this option only if your camera's live stream is displaying
color artifacts and has a diagonal line on the right side of the
image.
{t("group.camera.setting.compatibilityMode.desc")}
</p>
</div>
</div>
@@ -343,14 +358,14 @@ export function CameraStreamingDialog({
<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={handleCancel}
>
Cancel
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label="Save"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading}
className="flex flex-1"
onClick={handleSave}
@@ -358,10 +373,10 @@ export function CameraStreamingDialog({
{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>
@@ -22,6 +22,7 @@ import { Toaster } from "../ui/sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { Trans, useTranslation } from "react-i18next";
type MotionMaskEditPaneProps = {
polygons?: Polygon[];
@@ -50,6 +51,7 @@ export default function MotionMaskEditPane({
snapPoints,
setSnapPoints,
}: MotionMaskEditPaneProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
@@ -105,7 +107,7 @@ export default function MotionMaskEditPane({
polygon: z.object({ name: z.string(), isFinished: z.boolean() }),
})
.refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.",
message: t("masksAndZones.polygonDrawing.error.mustBeFinished"),
path: ["polygon.isFinished"],
});
@@ -163,16 +165,26 @@ export default function MotionMaskEditPane({
.then((res) => {
if (res.status === 200) {
toast.success(
`${polygon.name || "Motion Mask"} has been saved. Restart Frigate to apply changes.`,
polygon.name
? t("masksAndZones.motionMasks.toast.success", {
polygonName: polygon.name,
})
: t("masksAndZones.motionMasks.toast.success.noName"),
{
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) => {
@@ -180,7 +192,7 @@ export default function MotionMaskEditPane({
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",
});
})
@@ -194,6 +206,7 @@ export default function MotionMaskEditPane({
scaledHeight,
setIsLoading,
cameraConfig,
t,
]);
function onSubmit(values: z.infer<typeof formSchema>) {
@@ -209,8 +222,8 @@ export default function MotionMaskEditPane({
}
useEffect(() => {
document.title = "Edit Motion Mask - Frigate";
}, []);
document.title = t("masksAndZones.motionMasks.documentTitle");
}, [t]);
if (!polygon) {
return;
@@ -220,14 +233,13 @@ export default function MotionMaskEditPane({
<>
<Toaster position="top-center" closeButton={true} />
<Heading as="h3" className="my-2">
{polygon.name.length ? "Edit" : "New"} Motion Mask
{polygon.name.length
? t("masksAndZones.motionMasks.edit")
: t("masksAndZones.motionMasks.add")}
</Heading>
<div className="my-3 space-y-3 text-sm text-muted-foreground">
<p>
Motion masks are used to prevent unwanted types of motion from
triggering detection (example: tree branches, camera timestamps).
Motion masks should be used <em>very sparingly</em>, over-masking will
make it more difficult for objects to be tracked.
<Trans ns="views/settings">masksAndZones.motionMasks.context</Trans>
</p>
<div className="flex items-center text-primary">
@@ -237,7 +249,7 @@ export default function MotionMaskEditPane({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t("masksAndZones.motionMasks.context.documentation")}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -246,11 +258,9 @@ export default function MotionMaskEditPane({
{polygons && activePolygonIndex !== undefined && (
<div className="my-2 flex w-full flex-row justify-between text-sm">
<div className="my-1 inline-flex">
{polygons[activePolygonIndex].points.length}{" "}
{polygons[activePolygonIndex].points.length > 1 ||
polygons[activePolygonIndex].points.length == 0
? "points"
: "point"}
{t("masksAndZones.motionMasks.point", {
count: polygons[activePolygonIndex].points.length,
})}
{polygons[activePolygonIndex].isFinished && (
<FaCheckCircle className="ml-2 size-5" />
)}
@@ -265,7 +275,7 @@ export default function MotionMaskEditPane({
</div>
)}
<div className="mb-3 text-sm text-muted-foreground">
Click to draw a polygon on the image.
{t("masksAndZones.motionMasks.clickDrawPolygon")}
</div>
<Separator className="my-3 bg-secondary" />
@@ -273,19 +283,19 @@ export default function MotionMaskEditPane({
{polygonArea && polygonArea >= 0.35 && (
<>
<div className="mb-3 text-sm text-danger">
The motion mask is covering {Math.round(polygonArea * 100)}% of the
camera frame. Large motion masks are not recommended.
{t("masksAndZones.motionMasks.polygonAreaTooLarge", {
polygonArea: Math.round(polygonArea * 100),
})}
</div>
<div className="mb-3 text-sm text-primary">
Motion masks do not prevent objects from being detected. You should
use a required zone instead.
{t("masksAndZones.motionMasks.polygonAreaTooLarge.tips")}
<Link
to="https://github.com/blakeblackshear/frigate/discussions/13040"
target="_blank"
rel="noopener noreferrer"
className="my-3 block"
>
Read the documentation{" "}
{t("masksAndZones.motionMasks.polygonAreaTooLarge.documentation")}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -319,14 +329,14 @@ export default function MotionMaskEditPane({
<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}
>
Cancel
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label="Save"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading}
className="flex flex-1"
type="submit"
@@ -334,10 +344,10 @@ export default function MotionMaskEditPane({
{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>
@@ -38,6 +38,7 @@ import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { getAttributeLabels } from "@/utils/iconUtil";
import { useTranslation } from "react-i18next";
type ObjectMaskEditPaneProps = {
polygons?: Polygon[];
@@ -66,6 +67,7 @@ export default function ObjectMaskEditPane({
snapPoints,
setSnapPoints,
}: ObjectMaskEditPaneProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
@@ -107,7 +109,7 @@ export default function ObjectMaskEditPane({
polygon: z.object({ isFinished: z.boolean(), name: z.string() }),
})
.refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.",
message: t("masksAndZones.polygonDrawing.error.mustBeFinished"),
path: ["polygon.isFinished"],
});
@@ -195,16 +197,26 @@ export default function ObjectMaskEditPane({
.then((res) => {
if (res.status === 200) {
toast.success(
`${polygon.name || "Object Mask"} has been saved. Restart Frigate to apply changes.`,
polygon.name
? t("masksAndZones.objectMasks.toast.success", {
polygonName: polygon.name,
})
: t("masksAndZones.objectMasks.toast.success.noName"),
{
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) => {
@@ -212,9 +224,15 @@ export default function ObjectMaskEditPane({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("toast.save.error", {
errorMessage,
ns: "common",
}),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);
@@ -227,6 +245,7 @@ export default function ObjectMaskEditPane({
scaledHeight,
setIsLoading,
cameraConfig,
t,
],
);
@@ -243,8 +262,8 @@ export default function ObjectMaskEditPane({
}
useEffect(() => {
document.title = "Edit Object Mask - Frigate";
}, []);
document.title = t("masksAndZones.objectMasks.documentTitle");
}, [t]);
if (!polygon) {
return;
@@ -254,23 +273,20 @@ export default function ObjectMaskEditPane({
<>
<Toaster position="top-center" closeButton={true} />
<Heading as="h3" className="my-2">
{polygon.name.length ? "Edit" : "New"} Object Mask
{polygon.name.length
? t("masksAndZones.objectMasks.edit")
: t("masksAndZones.objectMasks.add")}
</Heading>
<div className="my-2 text-sm text-muted-foreground">
<p>
Object filter masks are used to filter out false positives for a given
object type based on location.
</p>
<p>{t("masksAndZones.objectMasks.context")}</p>
</div>
<Separator className="my-3 bg-secondary" />
{polygons && activePolygonIndex !== undefined && (
<div className="my-2 flex w-full flex-row justify-between text-sm">
<div className="my-1 inline-flex">
{polygons[activePolygonIndex].points.length}{" "}
{polygons[activePolygonIndex].points.length > 1 ||
polygons[activePolygonIndex].points.length == 0
? "points"
: "point"}
{t("masksAndZones.objectMasks.point", {
count: polygons[activePolygonIndex].points.length,
})}
{polygons[activePolygonIndex].isFinished && (
<FaCheckCircle className="ml-2 size-5" />
)}
@@ -285,7 +301,7 @@ export default function ObjectMaskEditPane({
</div>
)}
<div className="mb-3 text-sm text-muted-foreground">
Click to draw a polygon on the image.
{t("masksAndZones.objectMasks.clickDrawPolygon")}
</div>
<Separator className="my-3 bg-secondary" />
@@ -310,7 +326,9 @@ export default function ObjectMaskEditPane({
name="objects"
render={({ field }) => (
<FormItem>
<FormLabel>Objects</FormLabel>
<FormLabel>
{t("masksAndZones.objectMasks.objects")}
</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
@@ -326,7 +344,7 @@ export default function ObjectMaskEditPane({
</SelectContent>
</Select>
<FormDescription>
The object type that that applies to this object mask.
{t("masksAndZones.objectMasks.objects.desc")}
</FormDescription>
<FormMessage />
</FormItem>
@@ -346,25 +364,25 @@ export default function ObjectMaskEditPane({
<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}
>
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>
@@ -380,6 +398,7 @@ type ZoneObjectSelectorProps = {
};
export function ZoneObjectSelector({ camera }: ZoneObjectSelectorProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config");
const attributeLabels = useMemo(() => {
@@ -423,11 +442,13 @@ export function ZoneObjectSelector({ camera }: ZoneObjectSelectorProps) {
return (
<>
<SelectGroup>
<SelectItem value="all_labels">All object types</SelectItem>
<SelectItem value="all_labels">
{t("masksAndZones.objectMasks.objects.allObjectTypes")}
</SelectItem>
<SelectSeparator className="bg-secondary" />
{allLabels.map((item) => (
<SelectItem key={item} value={item}>
{item.replaceAll("_", " ").charAt(0).toUpperCase() + item.slice(1)}
{t(item, { ns: "objects" })}
</SelectItem>
))}
</SelectGroup>
@@ -4,6 +4,7 @@ import { MdOutlineRestartAlt, MdUndo } from "react-icons/md";
import { Button } from "../ui/button";
import { TbPolygon, TbPolygonOff } from "react-icons/tb";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type PolygonEditControlsProps = {
polygons: Polygon[];
@@ -20,6 +21,7 @@ export default function PolygonEditControls({
snapPoints,
setSnapPoints,
}: PolygonEditControlsProps) {
const { t } = useTranslation(["views/settings"]);
const undo = () => {
if (activePolygonIndex === undefined || !polygons) {
return;
@@ -80,35 +82,37 @@ export default function PolygonEditControls({
<Button
variant="default"
className="size-6 rounded-md p-1"
aria-label="Remove last point"
aria-label={t("masksAndZones.form.polygonDrawing.removeLastPoint")}
disabled={!polygons[activePolygonIndex].points.length}
onClick={undo}
>
<MdUndo className="text-secondary-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>Remove last point</TooltipContent>
<TooltipContent>
{t("masksAndZones.form.polygonDrawing.removeLastPoint")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
className="size-6 rounded-md p-1"
aria-label="Clear all points"
aria-label={t("masksAndZones.form.polygonDrawing.reset.label")}
disabled={!polygons[activePolygonIndex].points.length}
onClick={reset}
>
<MdOutlineRestartAlt className="text-secondary-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>Reset</TooltipContent>
<TooltipContent>{t("button.reset", { ns: "common" })}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={snapPoints ? "select" : "default"}
className={cn("size-6 rounded-md p-1")}
aria-label="Snap points"
aria-label={t("masksAndZones.form.polygonDrawing.snapPoints.true")}
onClick={() => setSnapPoints((prev) => !prev)}
>
{snapPoints ? (
@@ -119,7 +123,9 @@ export default function PolygonEditControls({
</Button>
</TooltipTrigger>
<TooltipContent>
{snapPoints ? "Don't snap points" : "Snap points"}
{snapPoints
? t("masksAndZones.form.polygonDrawing.snapPoints.false")
: t("masksAndZones.form.polygonDrawing.snapPoints.true")}
</TooltipContent>
</Tooltip>
</div>
+52 -22
View File
@@ -36,6 +36,7 @@ import { reviewQueries } from "@/utils/zoneEdutUtil";
import IconWrapper from "../ui/icon-wrapper";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { buttonVariants } from "../ui/button";
import { Trans, useTranslation } from "react-i18next";
type PolygonItemProps = {
polygon: Polygon;
@@ -56,6 +57,7 @@ export default function PolygonItem({
setEditPane,
handleCopyCoordinates,
}: PolygonItemProps) {
const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -175,14 +177,25 @@ export default function PolygonItem({
.put(`config/set?${url}`, { requires_restart: 0 })
.then((res) => {
if (res.status === 200) {
toast.success(`${polygon?.name} has been deleted.`, {
position: "top-center",
});
toast.success(
t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.name,
}),
{
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) => {
@@ -190,7 +203,7 @@ export default function PolygonItem({
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",
});
})
@@ -198,7 +211,7 @@ export default function PolygonItem({
setIsLoading(false);
});
},
[updateConfig, cameraConfig],
[updateConfig, cameraConfig, t],
);
const handleDelete = () => {
@@ -253,19 +266,30 @@ export default function PolygonItem({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
<AlertDialogTitle>
{t("masksAndZones.form.polygonDrawing.delete.title")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete the{" "}
{polygon.type.replace("_", " ")} <em>{polygon.name}</em>?
<Trans
ns="views/settings"
values={{
type: polygon.type.replace("_", " "),
name: polygon.name,
}}
>
masksAndZones.form.polygonDrawing.delete.desc
</Trans>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={handleDelete}
>
Delete
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -279,26 +303,26 @@ export default function PolygonItem({
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
aria-label="Edit"
aria-label={t("button.edit", { ns: "common" })}
onClick={() => {
setActivePolygonIndex(index);
setEditPane(polygon.type);
}}
>
Edit
{t("button.edit", { ns: "common" })}
</DropdownMenuItem>
<DropdownMenuItem
aria-label="Copy"
aria-label={t("button.copy", { ns: "common" })}
onClick={() => handleCopyCoordinates(index)}
>
Copy
{t("button.copy", { ns: "common" })}
</DropdownMenuItem>
<DropdownMenuItem
aria-label="Delete"
aria-label={t("button.delete", { ns: "common" })}
disabled={isLoading}
onClick={() => setDeleteDialogOpen(true)}
>
Delete
{t("button.delete", { ns: "common" })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -317,7 +341,9 @@ export default function PolygonItem({
}}
/>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
<TooltipContent>
{t("button.edit", { ns: "common" })}
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -330,7 +356,9 @@ export default function PolygonItem({
onClick={() => handleCopyCoordinates(index)}
/>
</TooltipTrigger>
<TooltipContent>Copy coordinates</TooltipContent>
<TooltipContent>
{t("button.copyCoordinates", { ns: "common" })}
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -344,7 +372,9 @@ export default function PolygonItem({
onClick={() => !isLoading && setDeleteDialogOpen(true)}
/>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
<TooltipContent>
{t("button.delete", { ns: "common" })}
</TooltipContent>
</Tooltip>
</div>
)}
+24 -17
View File
@@ -17,8 +17,9 @@ import FilterSwitch from "../filter/FilterSwitch";
import { SearchFilter, SearchSource } from "@/types/search";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { useTranslation } from "react-i18next";
type SearchSettingsProps = {
type ExploreSettingsProps = {
className?: string;
columns: number;
defaultView: string;
@@ -27,7 +28,7 @@ type SearchSettingsProps = {
setDefaultView: (view: string) => void;
onUpdateFilter: (filter: SearchFilter) => void;
};
export default function SearchSettings({
export default function ExploreSettings({
className,
columns,
setColumns,
@@ -35,7 +36,8 @@ export default function SearchSettings({
filter,
setDefaultView,
onUpdateFilter,
}: SearchSettingsProps) {
}: ExploreSettingsProps) {
const { t } = useTranslation(["components/filter"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [open, setOpen] = useState(false);
@@ -46,21 +48,20 @@ export default function SearchSettings({
const trigger = (
<Button
className="flex items-center gap-2"
aria-label="Explore Settings"
aria-label={t("explore.settings.title")}
size="sm"
>
<FaCog className="text-secondary-foreground" />
Settings
{t("explore.settings.title")}
</Button>
);
const content = (
<div className={cn(className, "my-3 space-y-5 py-3 md:mt-0 md:py-0")}>
<div className="space-y-4">
<div className="space-y-0.5">
<div className="text-md">Default View</div>
<div className="text-md">{t("explore.settings.defaultView")}</div>
<div className="space-y-1 text-xs text-muted-foreground">
When no filters are selected, display a summary of the most recent
tracked objects per label, or display an unfiltered grid.
{t("explore.settings.defaultView.desc")}
</div>
</div>
<Select
@@ -68,7 +69,9 @@ export default function SearchSettings({
onValueChange={(value) => setDefaultView(value)}
>
<SelectTrigger className="w-full">
{defaultView == "summary" ? "Summary" : "Unfiltered Grid"}
{defaultView == "summary"
? t("explore.settings.defaultView.summary")
: t("explore.settings.defaultView.unfilteredGrid")}
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -78,7 +81,9 @@ export default function SearchSettings({
className="cursor-pointer"
value={value}
>
{value == "summary" ? "Summary" : "Unfiltered Grid"}
{value == "summary"
? t("explore.settings.defaultView.summary")
: t("explore.settings.defaultView.unfilteredGrid")}
</SelectItem>
))}
</SelectGroup>
@@ -90,9 +95,9 @@ export default function SearchSettings({
<DropdownMenuSeparator />
<div className="flex w-full flex-col space-y-4">
<div className="space-y-0.5">
<div className="text-md">Grid Columns</div>
<div className="text-md">{t("explore.settings.gridColumns")}</div>
<div className="space-y-1 text-xs text-muted-foreground">
Select the number of columns in the grid view.
{t("explore.settings.gridColumns.desc")}
</div>
</div>
<div className="flex items-center space-x-4">
@@ -148,20 +153,22 @@ export function SearchTypeContent({
searchSources,
setSearchSources,
}: SearchTypeContentProps) {
const { t } = useTranslation(["components/filter"]);
return (
<>
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<div className="space-y-0.5">
<div className="text-md">Search Source</div>
<div className="text-md">
{t("explore.settings.searchSource.label")}
</div>
<div className="space-y-1 text-xs text-muted-foreground">
Choose whether to search the thumbnails or descriptions of your
tracked objects.
{t("explore.settings.searchSource.desc")}
</div>
</div>
<div className="mt-2.5 flex flex-col gap-2.5">
<FilterSwitch
label="Thumbnail Image"
label={t("explore.settings.searchSource.options.thumbnailImage")}
isChecked={searchSources?.includes("thumbnail") ?? false}
onCheckedChange={(isChecked) => {
const updatedSources = searchSources ? [...searchSources] : [];
@@ -179,7 +186,7 @@ export function SearchTypeContent({
}}
/>
<FilterSwitch
label="Description"
label={t("explore.settings.searchSource.options.description")}
isChecked={searchSources?.includes("description") ?? false}
onCheckedChange={(isChecked) => {
const updatedSources = searchSources ? [...searchSources] : [];
+98 -66
View File
@@ -29,6 +29,7 @@ import { toast } from "sonner";
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
import ActivityIndicator from "../indicators/activity-indicator";
import { getAttributeLabels } from "@/utils/iconUtil";
import { Trans, useTranslation } from "react-i18next";
type ZoneEditPaneProps = {
polygons?: Polygon[];
@@ -59,6 +60,7 @@ export default function ZoneEditPane({
snapPoints,
setSnapPoints,
}: ZoneEditPaneProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
@@ -102,7 +104,9 @@ export default function ZoneEditPane({
name: z
.string()
.min(2, {
message: "Zone name must be at least 2 characters.",
message: t(
"masksAndZones.form.zoneName.error.mustBeAtLeastTwoCharacters",
),
})
.transform((val: string) => val.trim().replace(/\s+/g, "_"))
.refine(
@@ -110,7 +114,9 @@ export default function ZoneEditPane({
return !cameras.map((cam) => cam.name).includes(value);
},
{
message: "Zone name must not be the name of a camera.",
message: t(
"masksAndZones.form.zoneName.error.mustNotBeSameWithCamera",
),
},
)
.refine(
@@ -123,7 +129,7 @@ export default function ZoneEditPane({
return !otherPolygonNames.includes(value);
},
{
message: "Zone name already exists on this camera.",
message: t("masksAndZones.form.zoneName.error.alreadyExists"),
},
)
.refine(
@@ -131,27 +137,31 @@ export default function ZoneEditPane({
return !value.includes(".");
},
{
message: "Zone name must not contain a period.",
message: t(
"masksAndZones.form.zoneName.error.mustNotContainPeriod",
),
},
)
.refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
message: "Zone name has an illegal character.",
message: t("masksAndZones.form.zoneName.error.hasIllegalCharacter"),
}),
inertia: z.coerce
.number()
.min(1, {
message: "Inertia must be above 0.",
message: t("masksAndZones.form.inertia.error.mustBeAboveZero"),
})
.or(z.literal("")),
loitering_time: z.coerce
.number()
.min(0, {
message: "Loitering time must be greater than or equal to 0.",
message: t(
"masksAndZones.form.loiteringTime.error.mustBeGreaterOrEqualZero",
),
})
.optional()
.or(z.literal("")),
isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.",
message: t("masksAndZones.polygonDrawing.error.mustBeFinished"),
}),
objects: z.array(z.string()).optional(),
review_alerts: z.boolean().default(false).optional(),
@@ -160,28 +170,28 @@ export default function ZoneEditPane({
lineA: z.coerce
.number()
.min(0.1, {
message: "Distance must be greater than or equal to 0.1",
message: t("masksAndZones.form.distance.error"),
})
.optional()
.or(z.literal("")),
lineB: z.coerce
.number()
.min(0.1, {
message: "Distance must be greater than or equal to 0.1",
message: t("masksAndZones.form.distance.error"),
})
.optional()
.or(z.literal("")),
lineC: z.coerce
.number()
.min(0.1, {
message: "Distance must be greater than or equal to 0.1",
message: t("masksAndZones.form.distance.error"),
})
.optional()
.or(z.literal("")),
lineD: z.coerce
.number()
.min(0.1, {
message: "Distance must be greater than or equal to 0.1",
message: t("masksAndZones.form.distance.error"),
})
.optional()
.or(z.literal("")),
@@ -201,7 +211,7 @@ export default function ZoneEditPane({
return true;
},
{
message: "All distance fields must be filled to use speed estimation.",
message: t("masksAndZones.form.distance.error.mustBeFilled"),
path: ["speedEstimation"],
},
)
@@ -215,8 +225,9 @@ export default function ZoneEditPane({
);
},
{
message:
"Zones with loitering times greater than 0 should not be used with speed estimation.",
message: t(
"masksAndZones.zones.speedThreshold.toast.error.loiteringTimeError",
),
path: ["loitering_time"],
},
);
@@ -255,11 +266,11 @@ export default function ZoneEditPane({
polygon.points.length !== 4
) {
toast.error(
"Speed estimation has been disabled for this zone. Zones with speed estimation must have exactly 4 points.",
t("masksAndZones.zones.speedThreshold.toast.error.pointLengthError"),
);
form.setValue("speedEstimation", false);
}
}, [polygon, form]);
}, [polygon, form, t]);
const saveToConfig = useCallback(
async (
@@ -319,7 +330,7 @@ export default function ZoneEditPane({
// Wait for the config to be updated
mutatedConfig = await updateConfig();
} catch (error) {
toast.error(`Failed to save config changes.`, {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
return;
@@ -401,16 +412,24 @@ export default function ZoneEditPane({
.then((res) => {
if (res.status === 200) {
toast.success(
`Zone (${zoneName}) has been saved. Restart Frigate to apply changes.`,
t("masksAndZones.zones.toast.success", {
zoneName,
}),
{
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) => {
@@ -418,9 +437,15 @@ export default function ZoneEditPane({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("toast.save.error", {
errorMessage,
ns: "common",
}),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);
@@ -434,6 +459,7 @@ export default function ZoneEditPane({
scaledHeight,
setIsLoading,
cameraConfig,
t,
],
);
@@ -454,8 +480,8 @@ export default function ZoneEditPane({
}
useEffect(() => {
document.title = "Edit Zone - Frigate";
}, []);
document.title = t("masksAndZones.zones.documentTitle");
}, [t]);
if (!polygon) {
return;
@@ -465,23 +491,21 @@ export default function ZoneEditPane({
<>
<Toaster position="top-center" closeButton={true} />
<Heading as="h3" className="my-2">
{polygon.name.length ? "Edit" : "New"} Zone
{polygon.name.length
? t("masksAndZones.zones.edit")
: t("masksAndZones.zones.add")}
</Heading>
<div className="my-2 text-sm text-muted-foreground">
<p>
Zones allow you to define a specific area of the frame so you can
determine whether or not an object is within a particular area.
</p>
<p>{t("masksAndZones.zones.desc")}</p>
</div>
<Separator className="my-3 bg-secondary" />
{polygons && activePolygonIndex !== undefined && (
<div className="my-2 flex w-full flex-row justify-between text-sm">
<div className="my-1 inline-flex">
{polygons[activePolygonIndex].points.length}{" "}
{polygons[activePolygonIndex].points.length > 1 ||
polygons[activePolygonIndex].points.length == 0
? "points"
: "point"}
{t("masksAndZones.zones.point", {
count: polygons[activePolygonIndex].points.length,
})}
{polygons[activePolygonIndex].isFinished && (
<FaCheckCircle className="ml-2 size-5" />
)}
@@ -496,7 +520,7 @@ export default function ZoneEditPane({
</div>
)}
<div className="mb-3 text-sm text-muted-foreground">
Click to draw a polygon on the image.
{t("masksAndZones.zones.clickDrawPolygon")}
</div>
<Separator className="my-3 bg-secondary" />
@@ -508,17 +532,16 @@ export default function ZoneEditPane({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>{t("masksAndZones.zones.name")}</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]"
placeholder="Enter a name..."
placeholder={t("masksAndZones.zones.name.inputPlaceHolder")}
{...field}
/>
</FormControl>
<FormDescription>
Name must be at least 2 characters and must not be the name of
a camera or another zone.
{t("masksAndZones.zones.name.tips")}
</FormDescription>
<FormMessage />
</FormItem>
@@ -530,7 +553,7 @@ export default function ZoneEditPane({
name="inertia"
render={({ field }) => (
<FormItem>
<FormLabel>Inertia</FormLabel>
<FormLabel>{t("masksAndZones.zones.inertia")}</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]"
@@ -539,8 +562,9 @@ export default function ZoneEditPane({
/>
</FormControl>
<FormDescription>
Specifies how many frames that an object must be in a zone
before they are considered in the zone. <em>Default: 3</em>
<Trans ns="views/settings">
masksAndZones.zones.inertia.desc
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
@@ -552,7 +576,7 @@ export default function ZoneEditPane({
name="loitering_time"
render={({ field }) => (
<FormItem>
<FormLabel>Loitering Time</FormLabel>
<FormLabel>{t("masksAndZones.zones.loiteringTime")}</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]"
@@ -561,8 +585,9 @@ export default function ZoneEditPane({
/>
</FormControl>
<FormDescription>
Sets a minimum amount of time in seconds that the object must
be in the zone for it to activate. <em>Default: 0</em>
<Trans ns="views/settings">
masksAndZones.zones.loiteringTime.desc
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
@@ -570,9 +595,9 @@ export default function ZoneEditPane({
/>
<Separator className="my-2 flex bg-secondary" />
<FormItem>
<FormLabel>Objects</FormLabel>
<FormLabel>{t("masksAndZones.zones.objects")}</FormLabel>
<FormDescription>
List of objects that apply to this zone.
{t("masksAndZones.zones.objects.desc")}
</FormDescription>
<ZoneObjectSelector
camera={polygon.camera}
@@ -606,7 +631,7 @@ export default function ZoneEditPane({
className="cursor-pointer text-primary"
htmlFor="allLabels"
>
Speed Estimation
{t("masksAndZones.zones.speedEstimation")}
</FormLabel>
<Switch
checked={field.value}
@@ -618,7 +643,9 @@ export default function ZoneEditPane({
polygons[activePolygonIndex].points.length !== 4
) {
toast.error(
"Zones with speed estimation must have exactly 4 points.",
t(
"masksAndZones.zones.speedEstimation.pointLengthError",
),
);
return;
}
@@ -627,7 +654,9 @@ export default function ZoneEditPane({
if (checked && loiteringTime && loiteringTime > 0) {
toast.error(
"Zones with loitering times greater than 0 should not be used with speed estimation.",
t(
"masksAndZones.zones.speedEstimation.loiteringTimeError",
),
);
}
field.onChange(checked);
@@ -637,8 +666,7 @@ export default function ZoneEditPane({
</FormControl>
</div>
<FormDescription>
Enable speed estimation for objects in this zone. The zone
must have exactly 4 points.
{t("masksAndZones.zones.speedEstimation.desc")}
</FormDescription>
<FormMessage />
</FormItem>
@@ -750,8 +778,12 @@ export default function ZoneEditPane({
render={({ field }) => (
<FormItem>
<FormLabel>
Speed Threshold (
{config?.ui.unit_system == "imperial" ? "mph" : "kph"})
{t("masksAndZones.zones.speedThreshold", {
unit:
config?.ui.unit_system == "imperial"
? t("unit.speed.mph")
: t("unit.speed.kph"),
})}
</FormLabel>
<FormControl>
<Input
@@ -760,8 +792,7 @@ export default function ZoneEditPane({
/>
</FormControl>
<FormDescription>
Specifies a minimum speed for objects to be considered
in this zone.
{t("masksAndZones.zones.speedThreshold.desc")}
</FormDescription>
<FormMessage />
</FormItem>
@@ -782,25 +813,25 @@ export default function ZoneEditPane({
<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}
>
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>
@@ -823,6 +854,7 @@ export function ZoneObjectSelector({
selectedLabels,
updateLabelFilter,
}: ZoneObjectSelectorProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config");
const attributeLabels = useMemo(() => {
@@ -880,7 +912,7 @@ export function ZoneObjectSelector({
<div className="scrollbar-container h-auto overflow-y-auto overflow-x-hidden">
<div className="my-2.5 flex items-center justify-between">
<Label className="cursor-pointer text-primary" htmlFor="allLabels">
All Objects
{t("masksAndZones.zones.allObjects")}
</Label>
<Switch
className="ml-1"
@@ -901,7 +933,7 @@ export function ZoneObjectSelector({
className="w-full cursor-pointer capitalize text-primary"
htmlFor={item}
>
{item.replaceAll("_", " ")}
{t(item, { ns: "objects" })}
</Label>
<Switch
key={item}
+17 -13
View File
@@ -12,6 +12,10 @@ import {
import { Switch } from "./switch";
import { cn } from "@/lib/utils";
import { LuCheck } from "react-icons/lu";
import { useTranslation } from "react-i18next";
const { t } = useTranslation(["common"]);
export interface DateRangePickerProps {
/** Click handler for applying the updates from DateRangePicker. */
@@ -59,15 +63,15 @@ interface Preset {
// Define presets
const PRESETS: Preset[] = [
{ name: "today", label: "Today" },
{ name: "yesterday", label: "Yesterday" },
{ name: "last7", label: "Last 7 days" },
{ name: "last14", label: "Last 14 days" },
{ name: "last30", label: "Last 30 days" },
{ name: "thisWeek", label: "This Week" },
{ name: "lastWeek", label: "Last Week" },
{ name: "thisMonth", label: "This Month" },
{ name: "lastMonth", label: "Last Month" },
{ name: "today", label: t("time.today") },
{ name: "yesterday", label: t("time.yesterday") },
{ name: "last7", label: t("time.last7") },
{ name: "last14", label: t("time.last14") },
{ name: "last30", label: t("time.last30") },
{ name: "thisWeek", label: t("time.thisWeek") },
{ name: "lastWeek", label: t("time.lastWeek") },
{ name: "thisMonth", label: t("time.thisMonth") },
{ name: "lastMonth", label: t("time.lastMonth") },
];
/** The DateRangePicker component allows a user to select a range of dates */
@@ -418,7 +422,7 @@ export function DateRangePicker({
<div className="mx-auto flex w-64 items-center justify-evenly gap-2 py-2">
<Button
variant="select"
aria-label="Apply"
aria-label={t("button.apply", { ns: "common" })}
onClick={() => {
setIsOpen(false);
if (
@@ -429,7 +433,7 @@ export function DateRangePicker({
}
}}
>
Apply
{t("button.apply", { ns: "common"})}
</Button>
<Button
onClick={() => {
@@ -438,9 +442,9 @@ export function DateRangePicker({
onReset?.();
}}
variant="ghost"
aria-label="Reset"
aria-label={t("button.reset", { ns: "common" })}
>
Reset
{t("button.reset", { ns: "common"})}
</Button>
</div>
</div>
+14 -1
View File
@@ -1,12 +1,24 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { enUS, Locale, zhCN } from "date-fns/locale";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import i18n from "@/utils/i18n";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
let locale: Locale;
switch(i18n.language) {
case "zh-CN":
locale = zhCN;
break;
default:
locale = enUS;
break;
}
function Calendar({
className,
classNames,
@@ -15,6 +27,7 @@ function Calendar({
}: CalendarProps) {
return (
<DayPicker
locale={locale}
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
+7 -4
View File
@@ -6,6 +6,7 @@ import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
@@ -196,6 +197,7 @@ const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { t } = useTranslation(["views/explore"]);
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
@@ -210,13 +212,13 @@ const CarouselPrevious = React.forwardRef<
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
aria-label="Previous slide"
aria-label={t("objectLifecycle.carousel.previous")}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
<span className="sr-only">{t("objectLifecycle.carousel.previous")}</span>
</Button>
);
});
@@ -226,6 +228,7 @@ const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { t } = useTranslation(["views/explore"]);
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
@@ -240,13 +243,13 @@ const CarouselNext = React.forwardRef<
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
aria-label="Next slide"
aria-label={t("objectLifecycle.carousel.next")}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
<span className="sr-only">{t("objectLifecycle.carousel.next")}</span>
</Button>
);
});
+9 -6
View File
@@ -3,11 +3,14 @@ import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
import { useTranslation } from "react-i18next"
const { t } = useTranslation(["common"])
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
aria-label={t("pagination.label")}
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
@@ -64,13 +67,13 @@ const PaginationPrevious = ({
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
aria-label={t("pagination.previous.label")}
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
<span>{t("pagination.previous")}</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
@@ -80,12 +83,12 @@ const PaginationNext = ({
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
aria-label={t("pagination.next.label")}
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<span>{t("pagination.next")}</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
@@ -101,7 +104,7 @@ const PaginationEllipsis = ({
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
<span className="sr-only">{t("pagination.more")}</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
+77
View File
@@ -0,0 +1,77 @@
import { createContext, useContext, useState, useEffect, useMemo } from "react";
import i18next from "i18next";
type LanguageProviderState = {
language: string;
systemLanguage: string;
setLanguage: (language: string) => void;
};
const initialState: LanguageProviderState = {
language: i18next.language || "en",
systemLanguage: "en",
setLanguage: () => null,
};
const LanguageProviderContext =
createContext<LanguageProviderState>(initialState);
export function LanguageProvider({
children,
defaultLanguage = "en",
storageKey = "frigate-ui-language",
...props
}: {
children: React.ReactNode;
defaultLanguage?: string;
storageKey?: string;
}) {
const [language, setLanguage] = useState<string>(() => {
try {
const storedData = localStorage.getItem(storageKey);
const newLanguage = storedData || defaultLanguage;
i18next.changeLanguage(newLanguage);
return newLanguage;
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error retrieving language data from storage:", error);
return defaultLanguage;
}
});
const systemLanguage = useMemo<string>(() => {
if (typeof window === "undefined") return "en";
return window.navigator.language;
}, []);
useEffect(() => {
if (language === systemLanguage) return;
i18next.changeLanguage(language);
}, [language, systemLanguage]);
const value = {
language,
systemLanguage,
setLanguage: (language: string) => {
localStorage.setItem(storageKey, language);
setLanguage(language);
window.location.reload();
},
};
return (
<LanguageProviderContext.Provider {...props} value={value}>
{children}
</LanguageProviderContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export const useLanguage = () => {
const context = useContext(LanguageProviderContext);
if (context === undefined)
throw new Error("useLanguage must be used within a LanguageProvider");
return context;
};
+12 -9
View File
@@ -5,6 +5,7 @@ import { ApiProvider } from "@/api";
import { IconContext } from "react-icons";
import { TooltipProvider } from "@/components/ui/tooltip";
import { StatusBarMessagesProvider } from "@/context/statusbar-provider";
import { LanguageProvider } from "./language-provider";
import { StreamingSettingsProvider } from "./streaming-settings-provider";
import { AuthProvider } from "./auth-context";
@@ -18,15 +19,17 @@ function providers({ children }: TProvidersProps) {
<AuthProvider>
<ApiProvider>
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
<TooltipProvider>
<IconContext.Provider value={{ size: "20" }}>
<StatusBarMessagesProvider>
<StreamingSettingsProvider>
{children}
</StreamingSettingsProvider>
</StatusBarMessagesProvider>
</IconContext.Provider>
</TooltipProvider>
<LanguageProvider>
<TooltipProvider>
<IconContext.Provider value={{ size: "20" }}>
<StatusBarMessagesProvider>
<StreamingSettingsProvider>
{children}
</StreamingSettingsProvider>
</StatusBarMessagesProvider>
</IconContext.Provider>
</TooltipProvider>
</LanguageProvider>
</ThemeProvider>
</ApiProvider>
</AuthProvider>
+1 -3
View File
@@ -23,9 +23,7 @@ export const colorSchemes: ColorScheme[] = [
// eslint-disable-next-line react-refresh/only-export-components
export const friendlyColorSchemeName = (className: string): string => {
const words = className.split("-").slice(1); // Exclude the first word (e.g., 'theme')
return words
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
return "menu.theme." + words.join(".");
};
type ThemeProviderProps = {
+1 -1
View File
@@ -54,7 +54,7 @@ export function useCameraActivity(
// handle camera activity
const hasActiveObjects = useMemo(
() => objects.filter((obj) => !obj.stationary).length > 0,
() => objects?.filter((obj) => !obj?.stationary)?.length > 0,
[objects],
);
+7 -7
View File
@@ -31,35 +31,35 @@ export default function useNavigation(
id: ID_LIVE,
variant,
icon: FaVideo,
title: "Live",
title: "menu.live",
url: "/",
},
{
id: ID_REVIEW,
variant,
icon: MdVideoLibrary,
title: "Review",
title: "menu.review",
url: "/review",
},
{
id: ID_EXPLORE,
variant,
icon: IoSearch,
title: "Explore",
title: "menu.explore",
url: "/explore",
},
{
id: ID_EXPORT,
variant,
icon: FaCompactDisc,
title: "Export",
title: "menu.export",
url: "/export",
},
{
id: ID_PLAYGROUND,
variant,
icon: LuConstruction,
title: "UI Playground",
title: "menu.uiPlayground",
url: "/playground",
enabled: ENV !== "production",
},
@@ -67,11 +67,11 @@ export default function useNavigation(
id: ID_FACE_LIBRARY,
variant,
icon: TbFaceId,
title: "Face Library",
title: "menu.faceLibrary",
url: "/faces",
enabled: isDesktop && config?.face_recognition.enabled,
},
] as NavData[],
[config?.face_recognition.enabled, variant],
[config?.face_recognition?.enabled, variant],
);
}
+12 -3
View File
@@ -11,7 +11,10 @@ import useDeepMemo from "./use-deep-memo";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { useFrigateStats } from "@/api/ws";
import { useTranslation } from "react-i18next";
export default function useStats(stats: FrigateStats | undefined) {
const { t } = useTranslation(["views/system"]);
const { data: config } = useSWR<FrigateConfig>("config");
const memoizedStats = useDeepMemo(stats);
@@ -72,7 +75,10 @@ export default function useStats(stats: FrigateStats | undefined) {
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
problems.push({
text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high FFMPEG CPU usage (${ffmpegAvg}%)`,
text: t("stats.ffmpegHighCpuUsage", {
camera: capitalizeFirstLetter(name.replaceAll("_", " ")),
ffmpegAvg,
}),
color: "text-danger",
relevantLink: "/system#cameras",
});
@@ -80,7 +86,10 @@ export default function useStats(stats: FrigateStats | undefined) {
if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) {
problems.push({
text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high detect CPU usage (${detectAvg}%)`,
text: t("stats.detectHighCpuUsage", {
camera: capitalizeFirstLetter(name.replaceAll("_", " ")),
detectAvg,
}),
color: "text-danger",
relevantLink: "/system#cameras",
});
@@ -88,7 +97,7 @@ export default function useStats(stats: FrigateStats | undefined) {
});
return problems;
}, [config, memoizedStats]);
}, [config, memoizedStats, t]);
return { potentialProblems };
}
+2
View File
@@ -2,6 +2,8 @@ import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import "@/utils/i18n";
import "react-i18next";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
+6 -6
View File
@@ -1,21 +1,21 @@
import Heading from "@/components/ui/heading";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { FaExclamationTriangle } from "react-icons/fa";
export default function AccessDenied() {
const { t } = useTranslation(["common"]);
useEffect(() => {
document.title = "Access Denied - Frigate";
}, []);
document.title = t("accessDenied.documentTitle");
}, [t]);
return (
<div className="flex min-h-screen flex-col items-center justify-center text-center">
<FaExclamationTriangle className="mb-4 size-8" />
<Heading as="h2" className="mb-2">
Access Denied
{t("accessDenied.title")}
</Heading>
<p className="text-primary-variant">
You don't have permission to view this page.
</p>
<p className="text-primary-variant">{t("accessDenied.desc")}</p>
</div>
);
}
+17 -13
View File
@@ -14,6 +14,7 @@ import { toast } from "sonner";
import { LuCopy, LuSave } from "react-icons/lu";
import { MdOutlineRestartAlt } from "react-icons/md";
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import { useTranslation } from "react-i18next";
import { useRestart } from "@/api/ws";
type SaveOptions = "saveonly" | "restart";
@@ -24,11 +25,12 @@ type ApiErrorResponse = {
};
function ConfigEditor() {
const { t } = useTranslation(["views/configEditor"]);
const apiHost = useApiHost();
useEffect(() => {
document.title = "Config Editor - Frigate";
}, []);
document.title = t("documentTitle");
}, [t]);
const { data: config } = useSWR<string>("config/raw");
@@ -64,7 +66,7 @@ function ConfigEditor() {
toast.success(response.data.message, { position: "top-center" });
}
} catch (error) {
toast.error("Error saving config", { position: "top-center" });
toast.error(t("toast.error.savingError"), { position: "top-center" });
const axiosError = error as AxiosError<ApiErrorResponse>;
const errorMessage =
@@ -76,7 +78,7 @@ function ConfigEditor() {
throw new Error(errorMessage);
}
},
[editorRef],
[editorRef, t],
);
const handleCopyConfig = useCallback(async () => {
@@ -85,8 +87,10 @@ function ConfigEditor() {
}
copy(editorRef.current.getValue());
toast.success("Config copied to clipboard.", { position: "top-center" });
}, [editorRef]);
toast.success(t("toast.success.copyToClipboard"), {
position: "top-center",
});
}, [editorRef, t]);
const handleSaveAndRestart = useCallback(async () => {
try {
@@ -208,38 +212,38 @@ function ConfigEditor() {
<div className="relative h-full overflow-hidden">
<div className="mr-1 flex items-center justify-between">
<Heading as="h2" className="mb-0 ml-1 md:ml-0">
Config Editor
{t("configEditor")}
</Heading>
<div className="flex flex-row gap-1">
<Button
size="sm"
className="flex items-center gap-2"
aria-label="Copy config"
aria-label={t("copyConfig")}
onClick={() => handleCopyConfig()}
>
<LuCopy className="text-secondary-foreground" />
<span className="hidden md:block">Copy Config</span>
<span className="hidden md:block">{t("copyConfig")}</span>
</Button>
<Button
size="sm"
className="flex items-center gap-2"
aria-label="Save and restart"
aria-label={t("saveAndRestart")}
onClick={handleSaveAndRestart}
>
<div className="relative size-5">
<LuSave className="absolute left-0 top-0 size-3 text-secondary-foreground" />
<MdOutlineRestartAlt className="absolute size-4 translate-x-1 translate-y-1/2 text-secondary-foreground" />
</div>
<span className="hidden md:block">Save & Restart</span>
<span className="hidden md:block">{t("saveAndRestart")}</span>
</Button>
<Button
size="sm"
className="flex items-center gap-2"
aria-label="Save only without restarting"
aria-label={t("saveOnly")}
onClick={() => onHandleSaveConfig("saveonly")}
>
<LuSave className="text-secondary-foreground" />
<span className="hidden md:block">Save Only</span>
<span className="hidden md:block">{t("saveOnly")}</span>
</Button>
</div>
</div>
+6 -3
View File
@@ -23,9 +23,12 @@ import EventView from "@/views/events/EventView";
import { RecordingView } from "@/views/recording/RecordingView";
import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
export default function Events() {
const { t } = useTranslation(["views/events"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
@@ -77,11 +80,11 @@ export default function Events() {
useEffect(() => {
if (recording) {
document.title = "Recordings - Frigate";
document.title = t("recordings.documentTitle");
} else {
document.title = `Review - Frigate`;
document.title = t("documentTitle");
}
}, [recording, severity]);
}, [recording, severity, t]);
// review filter
+38 -21
View File
@@ -15,6 +15,7 @@ import { formatSecondsToDuration } from "@/utils/dateUtil";
import SearchView from "@/views/search/SearchView";
import { useCallback, useEffect, useMemo, useState } from "react";
import { isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { TbExclamationCircle } from "react-icons/tb";
import { Link } from "react-router-dom";
@@ -27,6 +28,8 @@ const API_LIMIT = 25;
export default function Explore() {
// search field handler
const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
@@ -201,7 +204,9 @@ export default function Explore() {
revalidateAll: false,
onError: (error) => {
toast.error(
`Error fetching tracked objects: ${error.response.data.message}`,
t("fetchingTrackedObjectsFailed", {
errorMessage: error.response.data.message,
}),
{
position: "top-center",
},
@@ -357,13 +362,12 @@ export default function Explore() {
<div className="flex max-w-96 flex-col items-center justify-center space-y-3 rounded-lg bg-background/50 p-5">
<div className="my-5 flex flex-col items-center gap-2 text-xl">
<TbExclamationCircle className="mb-3 size-10" />
<div>Explore is Unavailable</div>
<div>{t("exploreIsUnavailable.title")}</div>
</div>
{embeddingsReindexing && allModelsLoaded && (
<>
<div className="text-center text-primary-variant">
Explore can be used after tracked object embeddings have
finished reindexing.
{t("exploreIsUnavailable.embeddingsReindexing.context")}
</div>
<div className="pt-5 text-center">
<AnimatedCircularProgressBar
@@ -379,29 +383,35 @@ export default function Explore() {
<div className="mb-3 flex flex-col items-center justify-center gap-1">
<div className="text-primary-variant">
{reindexState.time_remaining === -1
? "Starting up..."
: "Estimated time remaining:"}
? t(
"exploreIsUnavailable.embeddingsReindexing.startingUp",
)
: t(
"exploreIsUnavailable.embeddingsReindexing.estimatedTime",
)}
</div>
{reindexState.time_remaining >= 0 &&
(formatSecondsToDuration(reindexState.time_remaining) ||
"Finishing shortly")}
t(
"exploreIsUnavailable.embeddingsReindexing.finishingShortly",
))}
</div>
)}
<div className="flex flex-row items-center justify-center gap-3">
<span className="text-primary-variant">
Thumbnails embedded:
t("exploreIsUnavailable.embeddingsReindexing.step.thumbnailsEmbedded")
</span>
{reindexState.thumbnails}
</div>
<div className="flex flex-row items-center justify-center gap-3">
<span className="text-primary-variant">
Descriptions embedded:
t("exploreIsUnavailable.embeddingsReindexing.step.descriptionsEmbedded")
</span>
{reindexState.descriptions}
</div>
<div className="flex flex-row items-center justify-center gap-3">
<span className="text-primary-variant">
Tracked objects processed:
t("exploreIsUnavailable.embeddingsReindexing.step.trackedObjectsProcessed")
</span>
{reindexState.processed_objects} /{" "}
{reindexState.total_objects}
@@ -412,26 +422,32 @@ export default function Explore() {
{!allModelsLoaded && (
<>
<div className="text-center text-primary-variant">
Frigate is downloading the necessary embeddings models to
support the Semantic Search feature. This may take several
minutes depending on the speed of your network connection.
{t("exploreIsUnavailable.downloadingModels.context")}
</div>
<div className="flex w-96 flex-col gap-2 py-5">
<div className="flex flex-row items-center justify-center gap-2">
{renderModelStateIcon(visionModelState)}
Vision model
{t(
"exploreIsUnavailable.downloadingModels.setup.visionModel",
)}
</div>
<div className="flex flex-row items-center justify-center gap-2">
{renderModelStateIcon(visionFeatureExtractorState)}
Vision model feature extractor
{t(
"exploreIsUnavailable.downloadingModels.setup.visionModelFeatureExtractor",
)}
</div>
<div className="flex flex-row items-center justify-center gap-2">
{renderModelStateIcon(textModelState)}
Text model
{t(
"exploreIsUnavailable.downloadingModels.setup.textModel",
)}
</div>
<div className="flex flex-row items-center justify-center gap-2">
{renderModelStateIcon(textTokenizerState)}
Text tokenizer
{t(
"exploreIsUnavailable.downloadingModels.setup.textTokenizer",
)}
</div>
</div>
{(textModelState === "error" ||
@@ -439,12 +455,11 @@ export default function Explore() {
visionModelState === "error" ||
visionFeatureExtractorState === "error") && (
<div className="my-3 max-w-96 text-center text-danger">
An error has occurred. Check Frigate logs.
{t("exploreIsUnavailable.downloadingModels.error")}
</div>
)}
<div className="text-center text-primary-variant">
You may want to reindex the embeddings of your tracked objects
once the models are downloaded.
{t("exploreIsUnavailable.downloadingModels.tips.context")}
</div>
<div className="flex items-center text-primary-variant">
<Link
@@ -453,7 +468,9 @@ export default function Explore() {
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t(
"exploreIsUnavailable.downloadingModels.tips.documentation",
)}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
+16 -10
View File
@@ -17,18 +17,22 @@ import { useSearchEffect } from "@/hooks/use-overlay-state";
import { cn } from "@/lib/utils";
import { DeleteClipType, Export } from "@/types/export";
import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { LuFolderX } from "react-icons/lu";
import { toast } from "sonner";
import useSWR from "swr";
function Exports() {
const { t } = useTranslation(["views/exports"]);
const { data: exports, mutate } = useSWR<Export[]>("exports");
useEffect(() => {
document.title = "Export - Frigate";
}, []);
document.title = t("documentTitle");
}, [t]);
// Search
@@ -97,12 +101,12 @@ function Exports() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to rename export: ${errorMessage}`, {
toast.error(t("toast.error.renameExportFailed", { errorMessage }), {
position: "top-center",
});
});
},
[mutate],
[mutate, t],
);
return (
@@ -115,20 +119,22 @@ function Exports() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Export</AlertDialogTitle>
<AlertDialogTitle>{t("deleteExport")}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {deleteClip?.exportName}?
{t("deleteExport.desc", { exportName: deleteClip?.exportName })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<Button
className="text-white"
aria-label="Delete Export"
variant="destructive"
onClick={() => onHandleDelete()}
>
Delete
{t("button.delete", { ns: "common" })}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
@@ -176,7 +182,7 @@ function Exports() {
<div className="flex w-full items-center justify-center p-2">
<Input
className="text-md w-full bg-muted md:w-1/3"
placeholder="Search"
placeholder={t("search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@@ -204,7 +210,7 @@ function Exports() {
) : (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuFolderX className="size-16" />
No exports found
{t("noExports")}
</div>
)}
</div>
+39 -34
View File
@@ -25,18 +25,21 @@ import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuImagePlus, LuRefreshCw, LuScanFace, LuTrash2 } from "react-icons/lu";
import { toast } from "sonner";
import useSWR from "swr";
export default function FaceLibrary() {
const { t } = useTranslation(["views/faceLibrary"]);
const { data: config } = useSWR<FrigateConfig>("config");
// title
useEffect(() => {
document.title = "Face Library - Frigate";
}, []);
document.title = t("documentTitle");
}, [t]);
const [page, setPage] = useState<string>();
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
@@ -94,7 +97,7 @@ export default function FaceLibrary() {
if (resp.status == 200) {
setUpload(false);
refreshFaces();
toast.success("Successfully uploaded image.", {
toast.success(t("toast.success.uploadedImage"), {
position: "top-center",
});
}
@@ -104,12 +107,12 @@ export default function FaceLibrary() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to upload image: ${errorMessage}`, {
toast.error(t("toast.error.uploadingImageFailed", { errorMessage }), {
position: "top-center",
});
});
},
[pageToggle, refreshFaces],
[pageToggle, refreshFaces, t],
);
const onAddName = useCallback(
@@ -124,7 +127,7 @@ export default function FaceLibrary() {
if (resp.status == 200) {
setAddFace(false);
refreshFaces();
toast.success("Successfully add face library.", {
toast.success(t("toast.success.addFaceLibrary"), {
position: "top-center",
});
}
@@ -134,12 +137,12 @@ export default function FaceLibrary() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to set face name: ${errorMessage}`, {
toast.error(t("toast.error.addFaceLibraryFailed", { errorMessage }), {
position: "top-center",
});
});
},
[refreshFaces],
[refreshFaces, t],
);
// face multiselect
@@ -176,7 +179,7 @@ export default function FaceLibrary() {
setSelectedFaces([]);
if (resp.status == 200) {
toast.success(`Successfully deleted face.`, {
toast.success(t("toast.success.deletedFace"), {
position: "top-center",
});
refreshFaces();
@@ -187,11 +190,11 @@ export default function FaceLibrary() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to delete: ${errorMessage}`, {
toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), {
position: "top-center",
});
});
}, [selectedFaces, refreshFaces]);
}, [selectedFaces, refreshFaces, t]);
// keyboard
@@ -219,15 +222,15 @@ export default function FaceLibrary() {
<UploadImageDialog
open={upload}
title="Upload Face Image"
description={`Upload an image to scan for faces and include for ${pageToggle}`}
title={t("uploadFaceImage.title")}
description={t("uploadFaceImage.desc", { pageToggle })}
setOpen={setUpload}
onSave={onUploadImage}
/>
<TextEntryDialog
title="Create Face Library"
description="Create a new face library"
title={t("createFaceLibrary.title")}
description={t("createFaceLibrary.desc")}
open={addFace}
setOpen={setAddFace}
onSave={onAddName}
@@ -253,9 +256,9 @@ export default function FaceLibrary() {
value="train"
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == "train" ? "" : "*:text-muted-foreground"}`}
data-nav-item="train"
aria-label="Select train"
aria-label={t("train.aria")}
>
<div>Train</div>
<div>{t("train.title")}</div>
</ToggleGroupItem>
<div>|</div>
</>
@@ -267,7 +270,7 @@ export default function FaceLibrary() {
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
aria-label={`Select ${item}`}
aria-label={t("selectItem", { item })}
>
<div className="capitalize">
{item} ({faceData[item].length})
@@ -282,19 +285,19 @@ export default function FaceLibrary() {
<div className="flex items-center justify-center gap-2">
<Button className="flex gap-2" onClick={() => onDelete()}>
<LuTrash2 className="size-7 rounded-md p-1 text-secondary-foreground" />
Delete Face Attempts
{t("button.deleteFaceAttempts")}
</Button>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<Button className="flex gap-2" onClick={() => setAddFace(true)}>
<LuScanFace className="size-7 rounded-md p-1 text-secondary-foreground" />
Add Face
{t("button.addFace")}
</Button>
{pageToggle != "train" && (
<Button className="flex gap-2" onClick={() => setUpload(true)}>
<LuImagePlus className="size-7 rounded-md p-1 text-secondary-foreground" />
Upload Image
{t("button.uploadImage")}
</Button>
)}
</div>
@@ -370,6 +373,7 @@ function FaceAttempt({
onClick,
onRefresh,
}: FaceAttemptProps) {
const { t } = useTranslation(["views/faceLibrary"]);
const data = useMemo(() => {
const parts = image.split("-");
@@ -386,7 +390,7 @@ function FaceAttempt({
.post(`/faces/train/${trainName}/classify`, { training_file: image })
.then((resp) => {
if (resp.status == 200) {
toast.success(`Successfully trained face.`, {
toast.success(t("toast.success.trainedFace"), {
position: "top-center",
});
onRefresh();
@@ -397,12 +401,12 @@ function FaceAttempt({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to train: ${errorMessage}`, {
toast.error(t("toast.error.trainFailed", { errorMessage }), {
position: "top-center",
});
});
},
[image, onRefresh],
[image, onRefresh, t],
);
const onReprocess = useCallback(() => {
@@ -410,7 +414,7 @@ function FaceAttempt({
.post(`/faces/reprocess`, { training_file: image })
.then((resp) => {
if (resp.status == 200) {
toast.success(`Successfully updated face score.`, {
toast.success(t("toast.success.updatedFaceScore"), {
position: "top-center",
});
onRefresh();
@@ -421,11 +425,11 @@ function FaceAttempt({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to update face score: ${errorMessage}`, {
toast.error(t("toast.error.updateFaceScoreFailed", { errorMessage }), {
position: "top-center",
});
});
}, [image, onRefresh]);
}, [image, onRefresh, t]);
return (
<div
@@ -463,7 +467,7 @@ function FaceAttempt({
</TooltipTrigger>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Train Face as:</DropdownMenuLabel>
<DropdownMenuLabel>{t("trainFaceAs")}</DropdownMenuLabel>
{faceNames.map((faceName) => (
<DropdownMenuItem
key={faceName}
@@ -475,7 +479,7 @@ function FaceAttempt({
))}
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>Train Face as Person</TooltipContent>
<TooltipContent>{t("trainFaceAsPerson")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
@@ -484,7 +488,7 @@ function FaceAttempt({
onClick={() => onReprocess()}
/>
</TooltipTrigger>
<TooltipContent>Reprocess Face</TooltipContent>
<TooltipContent>{t("button.reprocessFace")}</TooltipContent>
</Tooltip>
</div>
</div>
@@ -519,12 +523,13 @@ type FaceImageProps = {
onRefresh: () => void;
};
function FaceImage({ name, image, onRefresh }: FaceImageProps) {
const { t } = useTranslation(["views/faceLibrary"]);
const onDelete = useCallback(() => {
axios
.post(`/faces/${name}/delete`, { ids: [image] })
.then((resp) => {
if (resp.status == 200) {
toast.success(`Successfully deleted face.`, {
toast.success(t("toast.success.deletedFace"), {
position: "top-center",
});
onRefresh();
@@ -535,11 +540,11 @@ function FaceImage({ name, image, onRefresh }: FaceImageProps) {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to delete: ${errorMessage}`, {
toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), {
position: "top-center",
});
});
}, [name, image, onRefresh]);
}, [name, image, onRefresh, t]);
return (
<div className="relative flex flex-col rounded-lg">
@@ -559,7 +564,7 @@ function FaceImage({ name, image, onRefresh }: FaceImageProps) {
onClick={onDelete}
/>
</TooltipTrigger>
<TooltipContent>Delete Face Attempt</TooltipContent>
<TooltipContent>{t("button.deleteFaceAttempts")}</TooltipContent>
</Tooltip>
</div>
</div>
+11 -4
View File
@@ -9,10 +9,13 @@ import { FrigateConfig } from "@/types/frigateConfig";
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
import LiveCameraView from "@/views/live/LiveCameraView";
import LiveDashboardView from "@/views/live/LiveDashboardView";
import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useRef } from "react";
import useSWR from "swr";
function Live() {
const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR<FrigateConfig>("config");
// selection
@@ -64,13 +67,17 @@ function Live() {
.split("_")
.filter((text) => text)
.map((text) => text[0].toUpperCase() + text.substring(1));
document.title = `${capitalized.join(" ")} - Live - Frigate`;
document.title = t("documentTitle.withCamera", {
camera: capitalized.join(" "),
});
} else if (cameraGroup && cameraGroup != "default") {
document.title = `${cameraGroup[0].toUpperCase()}${cameraGroup.substring(1)} - Live - Frigate`;
document.title = t("documentTitle.withCamera", {
camera: `${cameraGroup[0].toUpperCase()}${cameraGroup.substring(1)}`,
});
} else {
document.title = "Live - Frigate";
document.title = t("documentTitle", { ns: "views/live" });
}
}, [cameraGroup, selectedCameraName]);
}, [cameraGroup, selectedCameraName, t]);
// settings
+33 -24
View File
@@ -34,8 +34,10 @@ import { debounce } from "lodash";
import { isIOS, isMobile } from "react-device-detect";
import { isPWA } from "@/utils/isPWA";
import { isInIframe } from "@/utils/isIFrame";
import { useTranslation } from "react-i18next";
function Logs() {
const { t } = useTranslation(["views/system"]);
const [logService, setLogService] = useState<LogType>("frigate");
const tabsRef = useRef<HTMLDivElement | null>(null);
const lazyLogWrapperRef = useRef<HTMLDivElement>(null);
@@ -47,8 +49,8 @@ function Logs() {
const lastFetchedIndexRef = useRef(-1);
useEffect(() => {
document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`;
}, [logService]);
document.title = t("documentTitle.logs." + logService);
}, [logService, t]);
useEffect(() => {
if (tabsRef.current) {
@@ -104,13 +106,16 @@ function Logs() {
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred";
toast.error(`Error fetching logs: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("logs.toast.error.fetchingLogsFailed", { errorMessage }),
{
position: "top-center",
},
);
}
return [];
},
[logService, filterLines],
[logService, filterLines, t],
);
const fetchInitialLogs = useCallback(async () => {
@@ -132,13 +137,13 @@ function Logs() {
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred";
toast.error(`Error fetching logs: ${errorMessage}`, {
toast.error(t("logs.toast.error.fetchingLogsFailed", { errorMessage }), {
position: "top-center",
});
} finally {
setIsLoading(false);
}
}, [logService, filterLines, filterSeverity]);
}, [logService, filterLines, filterSeverity, t]);
const abortControllerRef = useRef<AbortController | null>(null);
@@ -203,10 +208,12 @@ function Logs() {
error instanceof Error
? error.message
: "An unknown error occurred";
toast.error(`Error while streaming logs: ${errorMessage}`);
toast.error(
t("logs.toast.error.whileStreamingLogs", { errorMessage }),
);
}
});
}, [logService, filterSeverity]);
}, [logService, filterSeverity, t]);
useEffect(() => {
setIsLoading(true);
@@ -285,13 +292,13 @@ function Logs() {
fetchInitialLogs()
.then(() => {
copy(logs.join("\n"));
toast.success("Copied logs to clipboard");
toast.success(t("logs.copy.success"));
})
.catch(() => {
toast.error("Could not copy logs to clipboard");
toast.error(t("logs.copy.error"));
});
}
}, [logs, fetchInitialLogs]);
}, [logs, fetchInitialLogs, t]);
const handleDownloadLogs = useCallback(() => {
axios
@@ -496,23 +503,25 @@ function Logs() {
<div className="flex items-center gap-2">
<Button
className="flex items-center justify-between gap-2"
aria-label="Copy logs to clipboard"
aria-label={t("logs.copy.label")}
size="sm"
onClick={handleCopyLogs}
>
<FaCopy className="text-secondary-foreground" />
<div className="hidden text-primary md:block">
Copy to Clipboard
{t("logs.copy.label")}
</div>
</Button>
<Button
className="flex items-center justify-between gap-2"
aria-label="Download logs"
aria-label={t("logs.download.label")}
size="sm"
onClick={handleDownloadLogs}
>
<FaDownload className="text-secondary-foreground" />
<div className="hidden text-primary md:block">Download</div>
<div className="hidden text-primary md:block">
{t("button.download", { ns: "common" })}
</div>
</Button>
<LogSettingsButton
selectedLabels={filterSeverity}
@@ -527,8 +536,10 @@ function Logs() {
<div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
<div className="col-span-3 lg:col-span-2">
<div className="flex w-full flex-row items-center">
<div className="ml-1 min-w-16 capitalize lg:min-w-20">Type</div>
<div className="mr-3">Timestamp</div>
<div className="ml-1 min-w-16 capitalize lg:min-w-20">
{t("logs.type.label")}
</div>
<div className="mr-3">{t("logs.type.timestamp")}</div>
</div>
</div>
<div
@@ -537,7 +548,7 @@ function Logs() {
logService == "frigate" ? "col-span-2" : "col-span-1",
)}
>
Tag
{t("logs.type.tag")}
</div>
<div
className={cn(
@@ -547,7 +558,7 @@ function Logs() {
: "md:col-span-8 lg:col-span-9",
)}
>
<div className="flex flex-1">Message</div>
<div className="flex flex-1">{t("logs.type.message")}</div>
</div>
</div>
@@ -566,9 +577,7 @@ function Logs() {
<TooltipTrigger>
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
</TooltipTrigger>
<TooltipContent>
Logs are streaming from the server
</TooltipContent>
<TooltipContent>{t("logs.tips")}</TooltipContent>
</Tooltip>
</div>
)}
+6 -4
View File
@@ -1,15 +1,17 @@
import Heading from "@/components/ui/heading";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
function NoMatch() {
const { t } = useTranslation(["common"]);
useEffect(() => {
document.title = "Not Found - Frigate";
}, []);
document.title = t("notFound.documentTitle");
}, [t]);
return (
<>
<Heading as="h2">404</Heading>
<p>Page not found</p>
<Heading as="h2">{t("notFound.title")}</Heading>
<p>{t("notFound.desc")}</p>
</>
);
}
+42 -33
View File
@@ -43,13 +43,14 @@ import { useInitialCameraState } from "@/api/ws";
import { isInIframe } from "@/utils/isIFrame";
import { isPWA } from "@/utils/isPWA";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { useTranslation } from "react-i18next";
const allSettingsViews = [
"UI settings",
"classification settings",
"camera settings",
"masks / zones",
"motion tuner",
"uiSettings",
"classificationSettings",
"cameraSettings",
"masksAndZones",
"motionTuner",
"debug",
"users",
"notifications",
@@ -57,7 +58,8 @@ const allSettingsViews = [
type SettingsType = (typeof allSettingsViews)[number];
export default function Settings() {
const [page, setPage] = useState<SettingsType>("UI settings");
const { t } = useTranslation(["views/settings"]);
const [page, setPage] = useState<SettingsType>("uiSettings");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
const tabsRef = useRef<HTMLDivElement | null>(null);
@@ -69,7 +71,7 @@ export default function Settings() {
const isAdmin = useIsAdmin();
const allowedViewsForViewer: SettingsType[] = ["UI settings", "debug"];
const allowedViewsForViewer: SettingsType[] = ["uiSettings", "debug"];
const visibleSettingsViews = !isAdmin
? allowedViewsForViewer
: allSettingsViews;
@@ -133,7 +135,7 @@ export default function Settings() {
setSelectedCamera(firstEnabledCamera.name);
} else if (
!cameraEnabledStates[selectedCamera] &&
page !== "camera settings"
page !== "cameraSettings"
) {
// Switch to first enabled camera if current one is disabled, unless on "camera settings" page
const firstEnabledCamera =
@@ -163,8 +165,8 @@ export default function Settings() {
useSearchEffect("page", (page: string) => {
if (allSettingsViews.includes(page as SettingsType)) {
// Restrict viewer to UI settings
if (!isAdmin && !["UI settings", "debug"].includes(page)) {
setPage("UI settings");
if (!isAdmin && !["uiSettings", "debug"].includes(page)) {
setPage("uiSettings");
} else {
setPage(page as SettingsType);
}
@@ -183,8 +185,8 @@ export default function Settings() {
});
useEffect(() => {
document.title = "Settings - Frigate";
}, []);
document.title = t("documentTitle.default");
}, [t]);
return (
<div className="flex size-full flex-col p-2">
@@ -199,8 +201,8 @@ export default function Settings() {
onValueChange={(value: SettingsType) => {
if (value) {
// Restrict viewer navigation
if (!isAdmin && !["UI settings", "debug"].includes(value)) {
setPageToggle("UI settings");
if (!isAdmin && !["uiSettings", "debug"].includes(value)) {
setPageToggle("uiSettings");
} else {
setPageToggle(value);
}
@@ -210,12 +212,15 @@ export default function Settings() {
{visibleSettingsViews.map((item) => (
<ToggleGroupItem
key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "UI settings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "uiSettings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
aria-label={`Select ${item}`}
aria-label={t("selectItem", {
item: t("menu." + item),
ns: "common",
})}
>
<div className="capitalize">{item}</div>
<div className="capitalize">{t("menu." + item)}</div>
</ToggleGroupItem>
))}
</ToggleGroup>
@@ -223,11 +228,11 @@ export default function Settings() {
</div>
</ScrollArea>
{(page == "debug" ||
page == "camera settings" ||
page == "masks / zones" ||
page == "motion tuner") && (
page == "cameraSettings" ||
page == "masksAndZones" ||
page == "motionTuner") && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
{page == "masks / zones" && (
{page == "masksAndZones" && (
<ZoneMaskFilterButton
selectedZoneMask={filterZoneMask}
updateZoneMaskFilter={setFilterZoneMask}
@@ -244,27 +249,27 @@ export default function Settings() {
)}
</div>
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
{page == "UI settings" && <UiSettingsView />}
{page == "classification settings" && (
{page == "uiSettings" && <UiSettingsView />}
{page == "classificationSettings" && (
<ClassificationSettingsView setUnsavedChanges={setUnsavedChanges} />
)}
{page == "debug" && (
<ObjectSettingsView selectedCamera={selectedCamera} />
)}
{page == "camera settings" && (
{page == "cameraSettings" && (
<CameraSettingsView
selectedCamera={selectedCamera}
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page == "masks / zones" && (
{page == "masksAndZones" && (
<MasksAndZonesView
selectedCamera={selectedCamera}
selectedZoneMask={filterZoneMask}
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page == "motion tuner" && (
{page == "motionTuner" && (
<MotionTunerView
selectedCamera={selectedCamera}
setUnsavedChanges={setUnsavedChanges}
@@ -282,17 +287,19 @@ export default function Settings() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>You have unsaved changes.</AlertDialogTitle>
<AlertDialogTitle>
{t("dialog.unsavedChanges.title")}
</AlertDialogTitle>
<AlertDialogDescription>
Do you want to save your changes before continuing?
{t("dialog.unsavedChanges.desc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => handleDialog(false)}>
Cancel
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDialog(true)}>
Save
{t("button.save", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -317,6 +324,8 @@ function CameraSelectButton({
cameraEnabledStates,
currentPage,
}: CameraSelectButtonProps) {
const { t } = useTranslation(["views/settings"]);
const [open, setOpen] = useState(false);
if (!allCameras.length) {
@@ -332,7 +341,7 @@ function CameraSelectButton({
<FaVideo className="text-background dark:text-primary" />
<div className="hidden text-background dark:text-primary md:block">
{selectedCamera == undefined
? "No Camera"
? t("cameraSetting.noCamera")
: selectedCamera.replaceAll("_", " ")}
</div>
</Button>
@@ -342,7 +351,7 @@ function CameraSelectButton({
{isMobile && (
<>
<DropdownMenuLabel className="flex justify-center">
Camera
{t("cameraSetting.camera")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
@@ -351,7 +360,7 @@ function CameraSelectButton({
<div className="flex flex-col gap-2.5">
{allCameras.map((item) => {
const isEnabled = cameraEnabledStates[item.name];
const isCameraSettingsPage = currentPage === "camera settings";
const isCameraSettingsPage = currentPage === "cameraSettings";
return (
<FilterSwitch
key={item.name}
+10 -6
View File
@@ -12,15 +12,16 @@ import Logo from "@/components/Logo";
import useOptimisticState from "@/hooks/use-optimistic-state";
import CameraMetrics from "@/views/system/CameraMetrics";
import { useHashState } from "@/hooks/use-overlay-state";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { Toaster } from "@/components/ui/sonner";
import { FrigateConfig } from "@/types/frigateConfig";
import FeatureMetrics from "@/views/system/FeatureMetrics";
import { useTranslation } from "react-i18next";
const allMetrics = ["general", "features", "storage", "cameras"] as const;
type SystemMetric = (typeof allMetrics)[number];
function System() {
const { t } = useTranslation(["views/system"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
@@ -52,9 +53,9 @@ function System() {
useEffect(() => {
if (pageToggle) {
document.title = `${capitalizeFirstLetter(pageToggle)} Stats - Frigate`;
document.title = t("documentTitle." + pageToggle);
}
}, [pageToggle]);
}, [pageToggle, t]);
// stats collection
@@ -91,7 +92,9 @@ function System() {
{item == "features" && <LuSearchCode className="size-4" />}
{item == "storage" && <LuHardDrive className="size-4" />}
{item == "cameras" && <FaVideo className="size-4" />}
{isDesktop && <div className="capitalize">{item}</div>}
{isDesktop && (
<div className="capitalize">{t(item + ".title")}</div>
)}
</ToggleGroupItem>
))}
</ToggleGroup>
@@ -99,13 +102,14 @@ function System() {
<div className="flex h-full items-center">
{lastUpdated && (
<div className="h-full content-center text-sm text-muted-foreground">
Last refreshed: <TimeAgo time={lastUpdated * 1000} dense />
{t("lastRefreshed")}
<TimeAgo time={lastUpdated * 1000} dense />
</div>
)}
</div>
</div>
<div className="mt-2 flex items-end gap-2">
<div className="h-full content-center font-medium">System</div>
<div className="h-full content-center font-medium">{t("title")}</div>
{statsSnapshot && (
<div className="h-full content-center text-sm text-muted-foreground">
{statsSnapshot.service.version}
+2 -1
View File
@@ -1,4 +1,5 @@
import copy from "copy-to-clipboard";
import { t } from "i18next";
import { toast } from "sonner";
export function shareOrCopy(url: string, title?: string) {
@@ -9,7 +10,7 @@ export function shareOrCopy(url: string, title?: string) {
});
} else {
copy(url);
toast.success("Copied URL to clipboard.", {
toast.success(t("toast.copyUrlToClipboard"), {
position: "top-center",
});
}
+93
View File
@@ -0,0 +1,93 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import HttpBackend from "i18next-http-backend";
i18n
.use(initReactI18next)
.use(HttpBackend)
.init({
fallbackLng: "en", // use en if detected lng is not available
backend: {
loadPath: "/locales/{{lng}}/{{ns}}.json",
},
ns: [
"common",
"objects",
"audio",
"components/camera",
"components/dialog",
"components/filter",
"components/icons",
"components/player",
"views/events",
"views/explore",
"views/live",
"views/settings",
"views/system",
"views/exports",
"views/explore",
],
defaultNS: "common",
react: {
transSupportBasicHtmlNodes: true,
transKeepBasicHtmlNodesFor: [
"br",
"strong",
"i",
"em",
"li",
"p",
"code",
"span",
"p",
"ul",
"li",
"ol",
],
},
interpolation: {
escapeValue: false, // react already safes from xss
},
keySeparator: ".",
parseMissingKeyHandler: (key: string) => {
const parts = key.split(".");
// Handle special cases for objects and audio
if (parts[0] === "object" || parts[0] === "audio") {
return (
parts[1]
?.split("_")
.map(
(word) =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
)
.join(" ") || key
);
}
// For nested keys, try to make them more readable
if (parts.length > 1) {
const lastPart = parts[parts.length - 1];
return lastPart
.split("_")
.map(
(word) =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
)
.join(" ");
}
// For single keys, just capitalize and format
return key
.split("_")
.map(
(word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
)
.join(" ");
},
});
export default i18n;
+44 -16
View File
@@ -1,8 +1,10 @@
import { ObjectLifecycleSequence } from "@/types/timeline";
import { t } from "i18next";
export function getLifecycleItemDescription(
lifecycleItem: ObjectLifecycleSequence,
) {
// can't use useTranslation here
const label = (
(Array.isArray(lifecycleItem.data.sub_label)
? lifecycleItem.data.sub_label[0]
@@ -11,37 +13,63 @@ export function getLifecycleItemDescription(
switch (lifecycleItem.class_type) {
case "visible":
return `${label} detected`;
return t("objectLifecycle.lifecycleItemDesc.visible", {
label,
ns: "views/explore",
});
case "entered_zone":
return `${label} entered ${lifecycleItem.data.zones
.join(" and ")
.replaceAll("_", " ")}`;
return t("objectLifecycle.lifecycleItemDesc.entered_zone", {
label,
ns: "views/explore",
zones: lifecycleItem.data.zones.join(" and ").replaceAll("_", " "),
});
case "active":
return `${label} became active`;
return t("objectLifecycle.lifecycleItemDesc.active", {
label,
ns: "views/explore",
});
case "stationary":
return `${label} became stationary`;
return t("objectLifecycle.lifecycleItemDesc.stationary", {
label,
ns: "views/explore",
});
case "attribute": {
let title = "";
if (
lifecycleItem.data.attribute == "face" ||
lifecycleItem.data.attribute == "license_plate"
) {
title = `${lifecycleItem.data.attribute.replaceAll(
"_",
" ",
)} detected for ${label}`;
title = t(
"objectLifecycle.lifecycleItemDesc.attribute.faceOrLicense_plate",
{
label,
attribute: lifecycleItem.data.attribute.replaceAll("_", " "),
ns: "views/explore",
},
);
} else {
title = `${
lifecycleItem.data.label
} recognized as ${lifecycleItem.data.attribute.replaceAll("_", " ")}`;
title = t("objectLifecycle.lifecycleItemDesc.attribute.other", {
label: lifecycleItem.data.label,
attribute: lifecycleItem.data.attribute.replaceAll("_", " "),
ns: "views/explore",
});
}
return title;
}
case "gone":
return `${label} left`;
return t("objectLifecycle.lifecycleItemDesc.gone", {
label,
ns: "views/explore",
});
case "heard":
return `${label} heard`;
return t("objectLifecycle.lifecycleItemDesc.heard", {
label,
ns: "views/explore",
});
case "external":
return `${label} detected`;
return t("objectLifecycle.lifecycleItemDesc.external", {
label,
ns: "views/explore",
});
}
}
+30 -16
View File
@@ -54,7 +54,9 @@ import { FilterList, LAST_24_HOURS_KEY } from "@/types/filter";
import { GiSoundWaves } from "react-icons/gi";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import ReviewDetailDialog from "@/components/overlay/detail/ReviewDetailDialog";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
import { useTranslation } from "react-i18next";
type EventViewProps = {
reviewItems?: SegmentedReviewData;
@@ -94,6 +96,7 @@ export default function EventView({
pullLatestData,
updateFilter,
}: EventViewProps) {
const { t } = useTranslation(["views/events"]);
const { data: config } = useSWR<FrigateConfig>("config");
const contentRef = useRef<HTMLDivElement | null>(null);
@@ -198,8 +201,10 @@ export default function EventView({
.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",
},
);
}
})
@@ -208,12 +213,18 @@ export default function EventView({
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",
message: errorMessage,
}),
{
position: "top-center",
},
);
});
},
[reviewItems],
[reviewItems, t],
);
const [motionOnly, setMotionOnly] = useState(false);
@@ -266,7 +277,7 @@ export default function EventView({
<ToggleGroupItem
className={cn(severityToggle != "alert" && "text-muted-foreground")}
value="alert"
aria-label="Select alerts"
aria-label={t("alerts")}
>
{isMobileOnly ? (
<div
@@ -285,7 +296,7 @@ export default function EventView({
<>
<MdCircle className="size-2 text-severity_alert md:mr-[10px]" />
<div className="hidden md:flex md:flex-row md:items-center">
Alerts
{t("alerts")}
{reviewCounts.alert > -1 ? (
`${reviewCounts.alert}`
) : (
@@ -300,7 +311,7 @@ export default function EventView({
severityToggle != "detection" && "text-muted-foreground",
)}
value="detection"
aria-label="Select detections"
aria-label={t("detections")}
>
{isMobileOnly ? (
<div
@@ -321,7 +332,7 @@ export default function EventView({
<>
<MdCircle className="size-2 text-severity_detection md:mr-[10px]" />
<div className="hidden md:flex md:flex-row md:items-center">
Detections
{t("detections")}
{reviewCounts.detection > -1 ? (
`${reviewCounts.detection}`
) : (
@@ -337,14 +348,14 @@ export default function EventView({
severityToggle != "significant_motion" && "text-muted-foreground",
)}
value="significant_motion"
aria-label="Select motion"
aria-label={t("motion.label")}
>
{isMobileOnly ? (
<GiSoundWaves className="size-6 rotate-90 text-severity_significant_motion" />
) : (
<>
<MdCircle className="size-2 text-severity_significant_motion md:mr-[10px]" />
<div className="hidden md:block">Motion</div>
<div className="hidden md:block">{t("motion.label")}</div>
</>
)}
</ToggleGroupItem>
@@ -461,6 +472,8 @@ function DetectionReview({
setSelectedReviews,
pullLatestData,
}: DetectionReviewProps) {
const { t } = useTranslation(["views/events"]);
const reviewTimelineRef = useRef<HTMLDivElement>(null);
// detail
@@ -712,7 +725,7 @@ function DetectionReview({
{!loading && currentItems?.length === 0 && (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuFolderCheck className="size-16" />
There are no {severity.replace(/_/g, " ")}s to review
{t("empty." + severity.replace(/_/g, " "))}
</div>
)}
@@ -779,14 +792,14 @@ function DetectionReview({
<div className="col-span-full flex items-center justify-center">
<Button
className="text-white"
aria-label="Mark these items as reviewed"
aria-label={t("markTheseItemsAsReviewed")}
variant="select"
onClick={() => {
setSelectedReviews([]);
markAllItemsAsReviewed(currentItems ?? []);
}}
>
Mark these items as reviewed
{t("markTheseItemsAsReviewed")}
</Button>
</div>
)}
@@ -862,6 +875,7 @@ function MotionReview({
motionOnly = false,
onOpenRecording,
}: MotionReviewProps) {
const { t } = useTranslation(["views/events"]);
const segmentDuration = 30;
const { data: config } = useSWR<FrigateConfig>("config");
@@ -1051,7 +1065,7 @@ function MotionReview({
return (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuFolderX className="size-16" />
No motion data found
{t("empty.motion")}
</div>
);
}
+10 -9
View File
@@ -21,6 +21,7 @@ import TimeAgo from "@/components/dynamic/TimeAgo";
import SearchResultActions from "@/components/menu/SearchResultActions";
import { SearchTab } from "@/components/overlay/detail/SearchDetailDialog";
import { FrigateConfig } from "@/types/frigateConfig";
import { useTranslation } from "react-i18next";
type ExploreViewProps = {
searchDetail: SearchResult | undefined;
@@ -35,11 +36,12 @@ export default function ExploreView({
setSimilaritySearch,
onSelectSearch,
}: ExploreViewProps) {
const { t } = useTranslation(["views/explore"]);
// title
useEffect(() => {
document.title = "Explore - Frigate";
}, []);
document.title = t("documentTitle");
}, [t]);
// data
@@ -137,6 +139,7 @@ function ThumbnailRow({
setSimilaritySearch,
onSelectSearch,
}: ThumbnailRowType) {
const { t } = useTranslation(["views/explore"]);
const navigate = useNavigate();
const handleSearch = (label: string) => {
@@ -149,15 +152,13 @@ function ThumbnailRow({
return (
<div className="rounded-lg bg-background_alt p-2 md:px-4">
<div className="flex flex-row items-center text-lg capitalize">
{objectType.replaceAll("_", " ")}
{t(objectType, { ns: "objects" })}
{searchResults && (
<span className="ml-3 text-sm text-secondary-foreground">
(
{
{t("trackedObjectsCount", {
// @ts-expect-error we know this is correct
searchResults[0].event_count
}{" "}
tracked objects){" "}
count: searchResults[0].event_count,
})}
</span>
)}
{isValidating && <ActivityIndicator className="ml-2 size-4" />}
@@ -225,7 +226,7 @@ function ExploreThumbnailImage({
};
const handleShowObjectLifecycle = () => {
onSelectSearch(event, false, "object lifecycle");
onSelectSearch(event, false, "object_lifecycle");
};
const handleShowSnapshot = () => {
+11 -3
View File
@@ -50,6 +50,7 @@ import { Toaster } from "@/components/ui/sonner";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import LiveContextMenu from "@/components/menu/LiveContextMenu";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { useTranslation } from "react-i18next";
type DraggableGridLayoutProps = {
cameras: CameraConfig[];
@@ -79,6 +80,7 @@ export default function DraggableGridLayout({
fullscreen,
toggleFullscreen,
}: DraggableGridLayoutProps) {
const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR<FrigateConfig>("config");
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
@@ -658,7 +660,9 @@ export default function DraggableGridLayout({
</div>
</TooltipTrigger>
<TooltipContent>
{isEditMode ? "Exit Editing" : "Edit Layout"}
{isEditMode
? t("editLayout.exitEdit")
: t("editLayout.label")}
</TooltipContent>
</Tooltip>
{!isEditMode && (
@@ -676,7 +680,9 @@ export default function DraggableGridLayout({
</div>
</TooltipTrigger>
<TooltipContent>
{isEditMode ? "Exit Editing" : "Edit Camera Group"}
{isEditMode
? t("editLayout.exitEdit")
: t("editLayout.group.label")}
</TooltipContent>
</Tooltip>
)}
@@ -694,7 +700,9 @@ export default function DraggableGridLayout({
</div>
</TooltipTrigger>
<TooltipContent>
{fullscreen ? "Exit Fullscreen" : "Fullscreen"}
{fullscreen
? t("button.exitFullscreen", { ns: "common" })
: t("button.fullscreen", { ns: "common" })}
</TooltipContent>
</Tooltip>
</>
+18 -4
View File
@@ -14,6 +14,7 @@ import {
isSafari,
useMobileOrientation,
} from "react-device-detect";
import { useTranslation } from "react-i18next";
import { FaCompress, FaExpand } from "react-icons/fa";
import { IoMdArrowBack } from "react-icons/io";
import { LuPictureInPicture } from "react-icons/lu";
@@ -32,6 +33,7 @@ export default function LiveBirdseyeView({
fullscreen,
toggleFullscreen,
}: LiveBirdseyeViewProps) {
const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR<FrigateConfig>("config");
const navigate = useNavigate();
const { isPortrait } = useMobileOrientation();
@@ -144,12 +146,16 @@ export default function LiveBirdseyeView({
{!fullscreen ? (
<Button
className={`flex items-center gap-2 rounded-lg ${isMobile ? "ml-2" : "ml-0"}`}
aria-label="Go Back"
aria-label={t("label.back", { ns: "common" })}
size={isMobile ? "icon" : "sm"}
onClick={() => navigate(-1)}
>
<IoMdArrowBack className="size-5" />
{isDesktop && <div className="text-primary">Back</div>}
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
) : (
<div />
@@ -164,7 +170,11 @@ export default function LiveBirdseyeView({
variant={fullscreen ? "overlay" : "primary"}
Icon={fullscreen ? FaCompress : FaExpand}
isActive={fullscreen}
title={fullscreen ? "Close" : "Fullscreen"}
title={
fullscreen
? t("button.close", { ns: "common" })
: t("button.fullscreen", { ns: "common" })
}
onClick={toggleFullscreen}
/>
)}
@@ -174,7 +184,11 @@ export default function LiveBirdseyeView({
variant={fullscreen ? "overlay" : "primary"}
Icon={LuPictureInPicture}
isActive={pip}
title={pip ? "Close" : "Picture in Picture"}
title={
pip
? t("button.close", { ns: "common" })
: t("button.pictureInPicture", { ns: "common" })
}
onClick={() => {
if (!pip) {
setPip(true);
+193 -117
View File
@@ -103,6 +103,7 @@ import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
import useSWR from "swr";
import { cn } from "@/lib/utils";
import { useSessionPersistence } from "@/hooks/use-session-persistence";
import {
Select,
SelectContent,
@@ -117,6 +118,7 @@ import axios from "axios";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { Trans, useTranslation } from "react-i18next";
type LiveCameraViewProps = {
config?: FrigateConfig;
@@ -132,6 +134,7 @@ export default function LiveCameraView({
fullscreen,
toggleFullscreen,
}: LiveCameraViewProps) {
const { t } = useTranslation(["views/live"]);
const navigate = useNavigate();
const { isPortrait } = useMobileOrientation();
const mainRef = useRef<HTMLDivElement | null>(null);
@@ -427,16 +430,20 @@ export default function LiveCameraView({
>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label="Go back"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => navigate(-1)}
>
<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>
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label="Show historical footage"
aria-label={t("history.label")}
size="sm"
onClick={() => {
navigate("review", {
@@ -452,7 +459,11 @@ export default function LiveCameraView({
}}
>
<LuHistory className="size-5 text-secondary-foreground" />
{isDesktop && <div className="text-primary">History</div>}
{isDesktop && (
<div className="text-primary">
{t("button.history", { ns: "common" })}
</div>
)}
</Button>
</div>
) : (
@@ -465,13 +476,15 @@ export default function LiveCameraView({
{fullscreen && (
<Button
className="bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-primary"
aria-label="Go back"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => navigate(-1)}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-secondary-foreground">Back</div>
<div className="text-secondary-foreground">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
)}
@@ -481,7 +494,11 @@ export default function LiveCameraView({
variant={fullscreen ? "overlay" : "primary"}
Icon={fullscreen ? FaCompress : FaExpand}
isActive={fullscreen}
title={fullscreen ? "Close" : "Fullscreen"}
title={
fullscreen
? t("button.close", { ns: "common" })
: t("button.fullscreen", { ns: "common" })
}
onClick={toggleFullscreen}
/>
)}
@@ -491,7 +508,11 @@ export default function LiveCameraView({
variant={fullscreen ? "overlay" : "primary"}
Icon={LuPictureInPicture}
isActive={pip}
title={pip ? "Close" : "Picture in Picture"}
title={
pip
? t("button.close", { ns: "common" })
: t("button.pictureInPicture", { ns: "common" })
}
onClick={() => {
if (!pip) {
setPip(true);
@@ -667,6 +688,7 @@ function PtzControlPanel({
clickOverlay: boolean;
setClickOverlay: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const { t } = useTranslation(["views/live"]);
const { data: ptz } = useSWR<CameraPtzInfo>(`${camera}/ptz/info`);
const { send: sendPtz } = usePtzCommand(camera);
@@ -752,7 +774,7 @@ function PtzControlPanel({
{ptz?.features?.includes("pt") && (
<>
<TooltipButton
label="Move camera left"
label={t("ptz.move.left.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("MOVE_LEFT");
@@ -767,7 +789,7 @@ function PtzControlPanel({
<FaAngleLeft />
</TooltipButton>
<TooltipButton
label="Move camera up"
label={t("ptz.move.up.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("MOVE_UP");
@@ -782,7 +804,7 @@ function PtzControlPanel({
<FaAngleUp />
</TooltipButton>
<TooltipButton
label="Move camera down"
label={t("ptz.move.down.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("MOVE_DOWN");
@@ -797,7 +819,7 @@ function PtzControlPanel({
<FaAngleDown />
</TooltipButton>
<TooltipButton
label="Move camera right"
label={t("ptz.move.right.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("MOVE_RIGHT");
@@ -816,7 +838,7 @@ function PtzControlPanel({
{ptz?.features?.includes("zoom") && (
<>
<TooltipButton
label="Zoom in"
label={t("ptz.zoom.in.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("ZOOM_IN");
@@ -831,7 +853,7 @@ function PtzControlPanel({
<MdZoomIn />
</TooltipButton>
<TooltipButton
label="Zoom out"
label={t("ptz.zoom.out.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("ZOOM_OUT");
@@ -854,14 +876,19 @@ function PtzControlPanel({
<TooltipTrigger asChild>
<Button
className={`${clickOverlay ? "text-selected" : "text-primary"}`}
aria-label="Click in the frame to center the camera"
aria-label={t("ptz.move.clickMove.label")}
onClick={() => setClickOverlay(!clickOverlay)}
>
<TbViewfinder />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{clickOverlay ? "Disable" : "Enable"} click to move</p>
<p>
{clickOverlay
? t("ptz.move.clickMove.disable")
: t("ptz.move.clickMove.enable")}{" "}
click to move
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -872,7 +899,7 @@ function PtzControlPanel({
<TooltipTrigger asChild>
<DropdownMenu modal={!isDesktop}>
<DropdownMenuTrigger asChild>
<Button aria-label="PTZ camera presets">
<Button aria-label={t("ptz.presets")}>
<BsThreeDotsVertical />
</Button>
</DropdownMenuTrigger>
@@ -894,7 +921,7 @@ function PtzControlPanel({
</DropdownMenu>
</TooltipTrigger>
<TooltipContent>
<p>PTZ camera presets</p>
<p>{t("ptz.presets")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -904,6 +931,7 @@ function PtzControlPanel({
}
function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
const { t } = useTranslation(["views/live"]);
const rankMap = { all: 0, motion: 1, active_objects: 2 };
const getValidMode = (retain?: { mode?: string }): keyof typeof rankMap => {
const mode = retain?.mode;
@@ -918,13 +946,25 @@ function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
? recordRetainMode
: alertsRetainMode;
const source = effectiveRetainMode === recordRetainMode ? "camera" : "alerts";
const source =
effectiveRetainMode === recordRetainMode
? t("camera", { ns: "views/events" })
: t("alerts", { ns: "views/events" });
return effectiveRetainMode !== "all" ? (
<div>
Your {source} recording retention configuration is set to{" "}
<code>mode: {effectiveRetainMode}</code>, so this on-demand recording will
only keep segments with {effectiveRetainMode.replaceAll("_", " ")}.
<Trans
ns="views/live"
values={{
source,
effectiveRetainMode,
effectiveRetainModeName: t(
"effectiveRetainMode.modes." + effectiveRetainMode,
),
}}
>
effectiveRetainMode.notAllTips
</Trans>
</div>
) : null;
}
@@ -967,6 +1007,8 @@ function FrigateCameraFeatures({
supports2WayTalk,
cameraEnabled,
}: FrigateCameraFeaturesProps) {
const { t } = useTranslation(["views/live"]);
const { payload: detectState, send: sendDetect } = useDetectState(
camera.name,
);
@@ -1010,14 +1052,9 @@ function FrigateCameraFeatures({
setIsRecording(true);
const toastId = toast.success(
<div className="flex flex-col space-y-3">
<div className="font-semibold">
Started manual on-demand recording.
</div>
<div className="font-semibold">{t("manualRecording.started")}</div>
{!camera.record.enabled || camera.record.alerts.retain.days == 0 ? (
<div>
Since recording is disabled or restricted in the config for this
camera, only a snapshot will be saved.
</div>
<div>{t("manualRecording.recordDisabledTips")}</div>
) : (
<OnDemandRetentionMessage camera={camera} />
)}
@@ -1030,11 +1067,11 @@ function FrigateCameraFeatures({
setActiveToastId(toastId);
}
} catch (error) {
toast.error("Failed to start manual on-demand recording.", {
toast.error(t("manualRecording.failedToStart"), {
position: "top-center",
});
}
}, [camera]);
}, [camera, t]);
const endEvent = useCallback(() => {
if (activeToastId) {
@@ -1047,16 +1084,16 @@ function FrigateCameraFeatures({
});
recordingEventIdRef.current = null;
setIsRecording(false);
toast.success("Ended manual on-demand recording.", {
toast.success(t("manualRecording.ended"), {
position: "top-center",
});
}
} catch (error) {
toast.error("Failed to end manual on-demand recording.", {
toast.error(t("manualRecording.failedToEnd"), {
position: "top-center",
});
}
}, [activeToastId]);
}, [activeToastId, t]);
const handleEventButtonClick = useCallback(() => {
if (isRecording) {
@@ -1092,7 +1129,9 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={enabledState == "ON" ? LuPower : LuPowerOff}
isActive={enabledState == "ON"}
title={`${enabledState == "ON" ? "Disable" : "Enable"} Camera`}
title={
enabledState == "ON" ? t("camera.disable") : t("camera.enable")
}
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
disabled={false}
/>
@@ -1101,7 +1140,9 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={detectState == "ON" ? MdPersonSearch : MdPersonOff}
isActive={detectState == "ON"}
title={`${detectState == "ON" ? "Disable" : "Enable"} Detect`}
title={
detectState == "ON" ? t("detect.disable") : t("detect.enable")
}
onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
@@ -1110,7 +1151,11 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={recordState == "ON" ? LuVideo : LuVideoOff}
isActive={recordState == "ON"}
title={`${recordState == "ON" ? "Disable" : "Enable"} Recording`}
title={
recordState == "ON"
? t("recording.disable")
: t("recording.enable")
}
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
@@ -1119,7 +1164,11 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={snapshotState == "ON" ? MdPhotoCamera : MdNoPhotography}
isActive={snapshotState == "ON"}
title={`${snapshotState == "ON" ? "Disable" : "Enable"} Snapshots`}
title={
snapshotState == "ON"
? t("snapshots.disable")
: t("snapshots.enable")
}
onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
@@ -1129,7 +1178,11 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={audioState == "ON" ? LuEar : LuEarOff}
isActive={audioState == "ON"}
title={`${audioState == "ON" ? "Disable" : "Enable"} Audio Detect`}
title={
audioState == "ON"
? t("audioDetect.disable")
: t("audioDetect.enable")
}
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
@@ -1142,7 +1195,11 @@ function FrigateCameraFeatures({
autotrackingState == "ON" ? TbViewfinder : TbViewfinderOff
}
isActive={autotrackingState == "ON"}
title={`${autotrackingState == "ON" ? "Disable" : "Enable"} Autotracking`}
title={
autotrackingState == "ON"
? t("autotracking.disable")
: t("autotracking.enable")
}
onClick={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
@@ -1159,7 +1216,7 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={isRecording ? TbRecordMail : TbRecordMailOff}
isActive={isRecording}
title={`${isRecording ? "Stop" : "Start"} on-demand recording`}
title={t("manualRecording." + (isRecording ? "stop" : "start"))}
onClick={handleEventButtonClick}
disabled={!cameraEnabled}
/>
@@ -1180,20 +1237,29 @@ function FrigateCameraFeatures({
<div className="flex flex-col gap-5 p-4">
{!isRestreamed && (
<div className="flex flex-col gap-2">
<Label>Stream</Label>
<Label>
{t("streaming.label", { ns: "components/dialog" })}
</Label>
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<LuX className="size-4 text-danger" />
<div>Restreaming is not enabled for this camera.</div>
<div>
{t("streaming.restreaming.NotEnabled", {
ns: "components/dialog",
})}
</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Set up go2rtc for additional live view options and audio
for this camera.
{t("streaming.restreaming.desc", {
ns: "components/dialog",
})}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
@@ -1201,7 +1267,9 @@ function FrigateCameraFeatures({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t("streaming.restreaming.readTheDocumentation", {
ns: "components/dialog",
})}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -1213,7 +1281,9 @@ function FrigateCameraFeatures({
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="flex flex-col gap-1">
<Label htmlFor="streaming-method">Stream</Label>
<Label htmlFor="streaming-method">
{t("stream.title")}
</Label>
<Select
value={streamName}
onValueChange={(value) => {
@@ -1248,22 +1318,23 @@ function FrigateCameraFeatures({
{supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>Audio is available for this stream</div>
<div>{t("stream.audio.available")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>Audio is unavailable for this stream</div>
<div>{t("stream.audio.unavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Audio must be output from your camera and
configured in go2rtc for this stream.
{t("stream.audio.tips")}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
@@ -1271,7 +1342,7 @@ function FrigateCameraFeatures({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t("stream.audio.tips.documentation")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -1288,26 +1359,23 @@ function FrigateCameraFeatures({
{supports2WayTalk ? (
<>
<LuCheck className="size-4 text-success" />
<div>
Two-way talk is available for this stream
</div>
<div>{t("stream.twoWayTalk.available")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>
Two-way talk is unavailable for this stream
</div>
<div>{t("stream.twoWayTalk.available")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Your device must suppport the feature and
WebRTC must be configured for two-way talk.
{t("stream.twoWayTalk.tips")}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live/#webrtc-extra-configuration"
@@ -1315,7 +1383,9 @@ function FrigateCameraFeatures({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t(
"stream.twoWayTalk.tips.documentation",
)}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -1332,20 +1402,19 @@ function FrigateCameraFeatures({
<IoIosWarning className="mr-1 size-8 text-danger" />
<p className="text-sm">
Live view is in low-bandwidth mode due to buffering
or stream errors.
{t("stream.lowBandwidth.tips")}
</p>
</div>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label="Reset the stream"
aria-label={t("stream.lowBandwidth.resetStream")}
variant="outline"
size="sm"
onClick={() => setLowBandwidth(false)}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
Reset stream
{t("stream.lowBandwidth.resetStream")}
</div>
</Button>
</div>
@@ -1359,7 +1428,7 @@ function FrigateCameraFeatures({
className="mx-0 cursor-pointer text-primary"
htmlFor="backgroundplay"
>
Play in background
{t("stream.playInBackground.label")}
</Label>
<Switch
className="ml-1"
@@ -1371,8 +1440,7 @@ function FrigateCameraFeatures({
/>
</div>
<p className="text-sm text-muted-foreground">
Enable this option to continue streaming when the player is
hidden.
{t("stream.playInBackground.tips")}
</p>
</div>
)}
@@ -1382,7 +1450,9 @@ function FrigateCameraFeatures({
className="mx-0 cursor-pointer text-primary"
htmlFor="showstats"
>
Show stream stats
{t("streaming.showStats.label", {
ns: "components/dialog",
})}
</Label>
<Switch
className="ml-1"
@@ -1392,13 +1462,12 @@ function FrigateCameraFeatures({
/>
</div>
<p className="text-sm text-muted-foreground">
Enable this option to show stream statistics as an overlay on
the camera feed.
{t("streaming.showStats.desc", { ns: "components/dialog" })}
</p>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-sm font-medium leading-none">
Debug View
{t("streaming.debugView", { ns: "components/dialog" })}
<LuExternalLink
onClick={() =>
navigate(`/settings?page=debug&camera=${camera.name}`)
@@ -1427,7 +1496,7 @@ function FrigateCameraFeatures({
variant="primary"
Icon={FaCog}
isActive={false}
title={`${camera} Settings`}
title={t("cameraSettings.title", { camera })}
/>
</DrawerTrigger>
<DrawerContent className="rounded-2xl px-2 py-4">
@@ -1435,14 +1504,14 @@ function FrigateCameraFeatures({
{isAdmin && (
<>
<FilterSwitch
label="Camera Enabled"
label={t("cameraSettings.cameraEnabled")}
isChecked={enabledState == "ON"}
onCheckedChange={() =>
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
}
/>
<FilterSwitch
label="Object Detection"
label={t("cameraSettings.objectDetection")}
isChecked={detectState == "ON"}
onCheckedChange={() =>
sendDetect(detectState == "ON" ? "OFF" : "ON")
@@ -1450,7 +1519,7 @@ function FrigateCameraFeatures({
/>
{recordingEnabled && (
<FilterSwitch
label="Recording"
label={t("cameraSettings.recording")}
isChecked={recordState == "ON"}
onCheckedChange={() =>
sendRecord(recordState == "ON" ? "OFF" : "ON")
@@ -1458,7 +1527,7 @@ function FrigateCameraFeatures({
/>
)}
<FilterSwitch
label="Snapshots"
label={t("cameraSettings.snapshots")}
isChecked={snapshotState == "ON"}
onCheckedChange={() =>
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
@@ -1466,7 +1535,7 @@ function FrigateCameraFeatures({
/>
{audioDetectEnabled && (
<FilterSwitch
label="Audio Detection"
label={t("cameraSettings.audioDetection")}
isChecked={audioState == "ON"}
onCheckedChange={() =>
sendAudio(audioState == "ON" ? "OFF" : "ON")
@@ -1475,7 +1544,7 @@ function FrigateCameraFeatures({
)}
{autotrackingEnabled && (
<FilterSwitch
label="Autotracking"
label={t("cameraSettings.autotracking")}
isChecked={autotrackingState == "ON"}
onCheckedChange={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
@@ -1489,20 +1558,27 @@ function FrigateCameraFeatures({
<div className="mt-3 flex flex-col gap-5">
{!isRestreamed && (
<div className="flex flex-col gap-2 p-2">
<Label>Stream</Label>
<Label>{t("streaming", { ns: "components/dialog" })}</Label>
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<LuX className="size-4 text-danger" />
<div>Restreaming is not enabled for this camera.</div>
<div>
{t("streaming.restreaming.disabled", {
ns: "components/dialog",
})}
</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Set up go2rtc for additional live view options and audio for
this camera.
{t("streaming.restreaming.desc", {
ns: "components/dialog",
})}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
@@ -1510,7 +1586,9 @@ function FrigateCameraFeatures({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t("streaming.restreaming.readTheDocumentation", {
ns: "components/dialog",
})}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -1521,7 +1599,7 @@ function FrigateCameraFeatures({
)}
{isRestreamed && Object.values(camera.live.streams).length > 0 && (
<div className="mt-1 p-2">
<div className="mb-1 text-sm">Stream</div>
<div className="mb-1 text-sm">{t("stream.title")}</div>
<Select
value={streamName}
onValueChange={(value) => {
@@ -1555,22 +1633,23 @@ function FrigateCameraFeatures({
{supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>Audio is available for this stream</div>
<div>{t("stream.audio.available")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>Audio is unavailable for this stream</div>
<div>{t("stream.audio.unavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-52 text-xs">
Audio must be output from your camera and configured
in go2rtc for this stream.
{t("stream.audio.tips")}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
@@ -1578,7 +1657,7 @@ function FrigateCameraFeatures({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t("stream.audio.tips.documentation")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -1595,22 +1674,23 @@ function FrigateCameraFeatures({
{supports2WayTalk ? (
<>
<LuCheck className="size-4 text-success" />
<div>Two-way talk is available for this stream</div>
<div>{t("stream.twoWayTalk.available")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>Two-way talk is unavailable for this stream</div>
<div>{t("stream.twoWayTalk.unavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-52 text-xs">
Your device must suppport the feature and WebRTC
must be configured for two-way talk.
{t("stream.twoWayTalk.tips")}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live/#webrtc-extra-configuration"
@@ -1618,7 +1698,7 @@ function FrigateCameraFeatures({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t("stream.twoWayTalk.tips.documentation")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -1633,10 +1713,7 @@ function FrigateCameraFeatures({
<div className="flex flex-row items-center gap-2">
<IoIosWarning className="mr-1 size-8 text-danger" />
<p className="text-sm">
Live view is in low-bandwidth mode due to buffering or
stream errors.
</p>
<p className="text-sm">{t("stream.lowBandwidth.tips")}</p>
</div>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
@@ -1646,7 +1723,9 @@ function FrigateCameraFeatures({
onClick={() => setLowBandwidth(false)}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">Reset stream</div>
<div className="text-primary-variant">
{t("stream.lowBandwidth.resetStream")}
</div>
</Button>
</div>
)}
@@ -1654,7 +1733,7 @@ function FrigateCameraFeatures({
)}
<div className="flex flex-col gap-1 px-2">
<div className="mb-1 text-sm font-medium leading-none">
On-Demand Recording
{t("manualRecording.title")}
</div>
<Button
onClick={handleEventButtonClick}
@@ -1663,46 +1742,43 @@ function FrigateCameraFeatures({
isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
)}
>
{isRecording ? "End" : "Start"} on-demand recording
{t("manualRecording." + isRecording ? "end" : "start")}
</Button>
<p className="text-sm text-muted-foreground">
Start a manual event based on this camera's recording retention
settings.
{t("manualRecording.tips")}
</p>
</div>
{isRestreamed && (
<>
<div className="flex flex-col gap-2">
<FilterSwitch
label="Play in Background"
label={t("manualRecording.playInBackground.label")}
isChecked={playInBackground}
onCheckedChange={(checked) => {
setPlayInBackground(checked);
}}
/>
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
Enable this option to continue streaming when the player is
hidden.
{t("manualRecording.playInBackground.desc")}
</p>
</div>
<div className="flex flex-col gap-2">
<FilterSwitch
label="Show Stats"
label={t("manualRecording.showStats.label")}
isChecked={showStats}
onCheckedChange={(checked) => {
setShowStats(checked);
}}
/>
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
Enable this option to show stream statistics as an overlay on
the camera feed.
{t("manualRecording.showStats.desc")}
</p>
</div>
</>
)}
<div className="mb-3 flex flex-col gap-1 px-2">
<div className="flex items-center justify-between text-sm font-medium leading-none">
Debug View
{t("manualRecording.debugView")}
<LuExternalLink
onClick={() =>
navigate(`/settings?page=debug&camera=${camera.name}`)
+9 -2
View File
@@ -43,6 +43,7 @@ import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import { useResizeObserver } from "@/hooks/resize-observer";
import LiveContextMenu from "@/components/menu/LiveContextMenu";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { useTranslation } from "react-i18next";
type LiveDashboardViewProps = {
cameras: CameraConfig[];
@@ -60,6 +61,8 @@ export default function LiveDashboardView({
fullscreen,
toggleFullscreen,
}: LiveDashboardViewProps) {
const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR<FrigateConfig>("config");
// layout
@@ -471,7 +474,9 @@ export default function LiveDashboardView({
}
const streamName =
currentGroupStreamingSettings?.[camera.name]?.streamName ||
Object.values(camera.live.streams)?.[0];
camera?.live?.streams
? Object?.values(camera?.live?.streams)?.[0]
: "";
const autoLive =
currentGroupStreamingSettings?.[camera.name]?.streamType !==
"no-streaming";
@@ -560,7 +565,9 @@ export default function LiveDashboardView({
</div>
</TooltipTrigger>
<TooltipContent>
{fullscreen ? "Exit Fullscreen" : "Fullscreen"}
{fullscreen
? t("button.exitFullscreen", { ns: "common" })
: t("button.fullscreen", { ns: "common" })}
</TooltipContent>
</Tooltip>
</div>
+19 -8
View File
@@ -49,6 +49,7 @@ import { cn } from "@/lib/utils";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { useTimezone } from "@/hooks/use-date-utils";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
import { useTranslation } from "react-i18next";
type RecordingViewProps = {
startCamera: string;
@@ -72,6 +73,7 @@ export function RecordingView({
filter,
updateFilter,
}: RecordingViewProps) {
const { t } = useTranslation(["views/events"]);
const { data: config } = useSWR<FrigateConfig>("config");
const navigate = useNavigate();
const contentRef = useRef<HTMLDivElement | null>(null);
@@ -393,12 +395,16 @@ export function RecordingView({
<div className={cn("flex items-center gap-2")}>
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label="Go back"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => navigate(-1)}
>
<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>
<Button
className="flex items-center gap-2.5 rounded-lg"
@@ -409,7 +415,11 @@ export function RecordingView({
}}
>
<FaVideo className="size-5 text-secondary-foreground" />
{isDesktop && <div className="text-primary">Live</div>}
{isDesktop && (
<div className="text-primary">
{t("menu.live", { ns: "common" })}
</div>
)}
</Button>
</div>
<div className="flex items-center justify-end gap-2">
@@ -482,16 +492,16 @@ export function RecordingView({
<ToggleGroupItem
className={`${timelineType == "timeline" ? "" : "text-muted-foreground"}`}
value="timeline"
aria-label="Select timeline"
aria-label={t("timeline.aria")}
>
<div className="">Timeline</div>
<div className="">{t("timeline")}</div>
</ToggleGroupItem>
<ToggleGroupItem
className={`${timelineType == "events" ? "" : "text-muted-foreground"}`}
value="events"
aria-label="Select events"
aria-label={t("events.aria")}
>
<div className="">Events</div>
<div className="">{t("events.label")}</div>
</ToggleGroupItem>
</ToggleGroup>
) : (
@@ -689,6 +699,7 @@ function Timeline({
setScrubbing,
setExportRange,
}: TimelineProps) {
const { t } = useTranslation(["views/events"]);
const internalTimelineRef = useRef<HTMLDivElement>(null);
const selectedTimelineRef = timelineRef || internalTimelineRef;
@@ -798,7 +809,7 @@ function Timeline({
>
{mainCameraReviewItems.length === 0 ? (
<div className="mt-5 text-center text-primary">
No events found for this time period.
{t("events.noFoundForTimePeriod")}
</div>
) : (
mainCameraReviewItems.map((review) => {
+6 -4
View File
@@ -22,7 +22,7 @@ import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { isEqual } from "lodash";
import { formatDateToLocaleString } from "@/utils/dateUtil";
import SearchThumbnailFooter from "@/components/card/SearchThumbnailFooter";
import SearchSettings from "@/components/settings/SearchSettings";
import ExploreSettings from "@/components/settings/SearchSettings";
import {
Tooltip,
TooltipContent,
@@ -31,6 +31,7 @@ import {
import Chip from "@/components/indicators/Chip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import SearchActionGroup from "@/components/filter/SearchActionGroup";
import { useTranslation } from "react-i18next";
type SearchViewProps = {
search: string;
@@ -70,6 +71,7 @@ export default function SearchView({
setColumns,
setDefaultView,
}: SearchViewProps) {
const { t } = useTranslation(["views/explore"]);
const contentRef = useRef<HTMLDivElement | null>(null);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
@@ -495,7 +497,7 @@ export default function SearchView({
filter={searchFilter}
onUpdateFilter={onUpdateFilter}
/>
<SearchSettings
<ExploreSettings
columns={columns}
setColumns={setColumns}
defaultView={defaultView}
@@ -531,7 +533,7 @@ export default function SearchView({
{uniqueResults?.length == 0 && !isLoading && (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuSearchX className="size-16" />
No Tracked Objects Found
{t("noTrackedObjects")}
</div>
)}
@@ -616,7 +618,7 @@ export default function SearchView({
}}
refreshResults={refresh}
showObjectLifecycle={() =>
onSelectSearch(value, false, "object lifecycle")
onSelectSearch(value, false, "object_lifecycle")
}
showSnapshot={() =>
onSelectSearch(value, false, "snapshot")
+81 -49
View File
@@ -13,6 +13,7 @@ import { toast } from "sonner";
import DeleteUserDialog from "@/components/overlay/DeleteUserDialog";
import { HiTrash } from "react-icons/hi";
import { FaUserEdit } from "react-icons/fa";
import { LuPlus, LuShield, LuUserCog } from "react-icons/lu";
import {
Table,
@@ -30,8 +31,10 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import RoleChangeDialog from "@/components/overlay/RoleChangeDialog";
import { useTranslation } from "react-i18next";
export default function AuthenticationView() {
const { t } = useTranslation("views/settings");
const { data: config } = useSWR<FrigateConfig>("config");
const { data: users, mutate: mutateUsers } = useSWR<User[]>("users");
@@ -46,30 +49,38 @@ export default function AuthenticationView() {
>();
useEffect(() => {
document.title = "Authentication Settings - Frigate";
}, []);
document.title = t("documentTitle.authentication");
}, [t]);
const onSavePassword = useCallback((user: string, password: string) => {
axios
.put(`users/${user}/password`, { password })
.then((response) => {
if (response.status === 200) {
setShowSetPassword(false);
toast.success("Password updated successfully", {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to save password: ${errorMessage}`, {
position: "top-center",
const onSavePassword = useCallback(
(user: string, password: string) => {
axios
.put(`users/${user}/password`, { password })
.then((response) => {
if (response.status === 200) {
setShowSetPassword(false);
toast.success(t("users.toast.success.updatePassword"), {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("users.toast.error.setPasswordFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
});
});
}, []);
},
[t],
);
const onCreate = (
user: string,
@@ -85,7 +96,7 @@ export default function AuthenticationView() {
users?.push({ username: user, role: role });
return users;
}, false);
toast.success(`User ${user} created successfully`, {
toast.success(t("users.toast.success.createUser", { user }), {
position: "top-center",
});
}
@@ -95,9 +106,14 @@ export default function AuthenticationView() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to create user: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("users.toast.error.createUserFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
});
};
@@ -111,7 +127,7 @@ export default function AuthenticationView() {
(users) => users?.filter((u) => u.username !== user),
false,
);
toast.success(`User ${user} deleted successfully`, {
toast.success(t("users.toast.success.deleteUser", { user }), {
position: "top-center",
});
}
@@ -121,9 +137,14 @@ export default function AuthenticationView() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to delete user: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("users.toast.error.deleteUserFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
});
};
@@ -142,7 +163,7 @@ export default function AuthenticationView() {
),
false,
);
toast.success(`Role updated for ${user}`, {
toast.success(t("users.toast.success.roleUpdated", { user }), {
position: "top-center",
});
}
@@ -152,9 +173,14 @@ export default function AuthenticationView() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to update role: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("users.toast.error.roleUpdateFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
});
};
@@ -173,20 +199,20 @@ export default function AuthenticationView() {
<div className="mb-5 flex flex-row items-center justify-between gap-2">
<div className="flex flex-col items-start">
<Heading as="h3" className="my-2">
User Management
{t("users.management")}
</Heading>
<p className="text-sm text-muted-foreground">
Manage this Frigate instance's user accounts.
{t("users.management.desc")}
</p>
</div>
<Button
className="flex items-center gap-2 self-start sm:self-auto"
aria-label="Add a new user"
aria-label={t("users.addUser")}
variant="default"
onClick={() => setShowCreate(true)}
>
<LuPlus className="size-4" />
Add User
{t("users.addUser")}
</Button>
</div>
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
@@ -195,16 +221,20 @@ export default function AuthenticationView() {
<Table>
<TableHeader className="sticky top-0 bg-muted/50">
<TableRow>
<TableHead className="w-[250px]">Username</TableHead>
<TableHead>Role</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead className="w-[250px]">
{t("users.table.username")}
</TableHead>
<TableHead>{t("users.table.role")}</TableHead>
<TableHead className="text-right">
{t("users.table.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center">
No users found.
{t("users.table.noUsers")}
</TableCell>
</TableRow>
) : (
@@ -231,7 +261,9 @@ export default function AuthenticationView() {
: ""
}
>
{user.role || "viewer"}
{t("role." + (user.role || "viewer"), {
ns: "common",
})}
</Badge>
</TableCell>
<TableCell className="text-right">
@@ -255,12 +287,12 @@ export default function AuthenticationView() {
>
<LuUserCog className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
Role
{t("role.title", { ns: "common" })}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Change user role</p>
<p>{t("users.table.changeRole")}</p>
</TooltipContent>
</Tooltip>
)}
@@ -278,12 +310,12 @@ export default function AuthenticationView() {
>
<FaUserEdit className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
Password
{t("users.table.password")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Update password</p>
<p>{t("users.updatePassword")}</p>
</TooltipContent>
</Tooltip>
@@ -301,12 +333,12 @@ export default function AuthenticationView() {
>
<HiTrash className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
Delete
{t("button.delete", { ns: "common" })}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete user</p>
<p>{t("users.table.deleteUser")}</p>
</TooltipContent>
</Tooltip>
)}
+148 -67
View File
@@ -27,6 +27,7 @@ import { LuExternalLink } from "react-icons/lu";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { MdCircle } from "react-icons/md";
import { cn } from "@/lib/utils";
import { Trans, useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useAlertsState, useDetectionsState, useEnabledState } from "@/api/ws";
@@ -45,6 +46,8 @@ export default function CameraSettingsView({
selectedCamera,
setUnsavedChanges,
}: CameraSettingsViewProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
@@ -76,18 +79,18 @@ export default function CameraSettingsView({
const alertsLabels = useMemo(() => {
return cameraConfig?.review.alerts.labels
? cameraConfig.review.alerts.labels
.map((label) => label.replaceAll("_", " "))
.map((label) => t(label, { ns: "objects" }))
.join(", ")
: "";
}, [cameraConfig]);
}, [cameraConfig, t]);
const detectionsLabels = useMemo(() => {
return cameraConfig?.review.detections.labels
? cameraConfig.review.detections.labels
.map((label) => label.replaceAll("_", " "))
.map((label) => t(label, { ns: "objects" }))
.join(", ")
: "";
}, [cameraConfig]);
}, [cameraConfig, t]);
// form
@@ -157,17 +160,20 @@ export default function CameraSettingsView({
})
.then((res) => {
if (res.status === 200) {
toast.success(
`Review classification configuration has been saved. Restart Frigate to apply changes.`,
toast.success(t("camera.reviewClassification.toast.success"), {
position: "top-center",
});
updateConfig();
} else {
toast.error(
t("toast.save.error", {
errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
}
})
.catch((error) => {
@@ -175,15 +181,21 @@ export default function CameraSettingsView({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("toast.save.error", {
errorMessage,
ns: "common",
}),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);
});
},
[updateConfig, setIsLoading, selectedCamera, cameraConfig],
[updateConfig, setIsLoading, selectedCamera, cameraConfig, t],
);
const onCancel = useCallback(() => {
@@ -252,13 +264,13 @@ export default function CameraSettingsView({
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
<Heading as="h3" className="my-2">
Camera Settings
<Trans ns="views/settings">camera.title</Trans>
</Heading>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Streams
<Trans ns="views/settings">camera.streams.title</Trans>
</Heading>
<div className="flex flex-row items-center">
@@ -271,19 +283,18 @@ export default function CameraSettingsView({
}}
/>
<div className="space-y-0.5">
<Label htmlFor="camera-enabled">Enable</Label>
<Label htmlFor="camera-enabled">
<Trans>button.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
Disabling a camera completely stops Frigate's processing of this
camera's streams. Detection, recording, and debugging will be
unavailable.
<br /> <em>Note: This does not disable go2rtc restreams.</em>
<Trans ns="views/settings">camera.streams.desc</Trans>
</div>
<Separator className="mb-2 mt-4 flex bg-secondary" />
<Heading as="h4" className="my-2">
Review
<Trans ns="views/settings">camera.review.title</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
@@ -297,7 +308,9 @@ export default function CameraSettingsView({
}}
/>
<div className="space-y-0.5">
<Label htmlFor="alerts-enabled">Alerts</Label>
<Label htmlFor="alerts-enabled">
<Trans ns="views/settings">camera.review.alerts</Trans>
</Label>
</div>
</div>
<div className="flex flex-col">
@@ -311,12 +324,13 @@ export default function CameraSettingsView({
}}
/>
<div className="space-y-0.5">
<Label htmlFor="detections-enabled">Detections</Label>
<Label htmlFor="detections-enabled">
<Trans ns="views/settings">camera.review.detections</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
Enable/disable alerts and detections for this camera. When
disabled, no new review items will be generated.
<Trans ns="views/settings">camera.review.desc</Trans>
</div>
</div>
</div>
@@ -324,16 +338,15 @@ export default function CameraSettingsView({
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Review Classification
<Trans ns="views/settings">camera.reviewClassification.title</Trans>
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
Frigate categorizes review items as Alerts and Detections. By
default, all <em>person</em> and <em>car</em> objects are
considered Alerts. You can refine categorization of your review
items by configuring required zones for them.
<Trans ns="views/settings">
camera.reviewClassification.desc
</Trans>
</p>
<div className="flex items-center text-primary">
<Link
@@ -342,7 +355,9 @@ export default function CameraSettingsView({
rel="noopener noreferrer"
className="inline"
>
Read the Documentation{" "}
<Trans ns="views/settings">
camera.reviewClassification.readTheDocumentation
</Trans>{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -371,11 +386,15 @@ export default function CameraSettingsView({
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
Alerts{" "}
<Trans ns="views/settings">
camera.review.alerts
</Trans>
<MdCircle className="ml-3 size-2 text-severity_alert" />
</FormLabel>
<FormDescription>
Select zones for Alerts
<Trans ns="views/settings">
camera.reviewClassification.selectAlertsZones
</Trans>
</FormDescription>
</div>
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
@@ -424,20 +443,37 @@ export default function CameraSettingsView({
</>
) : (
<div className="font-normal text-destructive">
No zones are defined for this camera.
<Trans ns="views/settings">
camera.reviewClassification.noDefinedZones
</Trans>
</div>
)}
<FormMessage />
<div className="text-sm">
All {alertsLabels} objects
{watchedAlertsZones && watchedAlertsZones.length > 0
? ` detected in ${watchedAlertsZones.map((zone) => capitalizeFirstLetter(zone).replaceAll("_", " ")).join(", ")}`
: ""}{" "}
on{" "}
{capitalizeFirstLetter(
cameraConfig?.name ?? "",
).replaceAll("_", " ")}{" "}
will be shown as Alerts.
? t(
"camera.reviewClassification.zoneObjectAlertsTips",
{
alertsLabels,
zone: watchedAlertsZones
.map((zone) =>
capitalizeFirstLetter(zone).replaceAll(
"_",
" ",
),
)
.join(", "),
cameraName: capitalizeFirstLetter(
cameraConfig?.name ?? "",
).replaceAll("_", " "),
},
)
: t("camera.reviewClassification.objectAlertsTips", {
alertsLabels,
cameraName: capitalizeFirstLetter(
cameraConfig?.name ?? "",
).replaceAll("_", " "),
})}
</div>
</FormItem>
)}
@@ -452,12 +488,16 @@ export default function CameraSettingsView({
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
Detections{" "}
<Trans ns="views/settings">
camera.review.detections
</Trans>
<MdCircle className="ml-3 size-2 text-severity_detection" />
</FormLabel>
{selectDetections && (
<FormDescription>
Select zones for Detections
<Trans ns="views/settings">
camera.reviewClassification.selectDetectionsZones
</Trans>
</FormDescription>
)}
</div>
@@ -520,7 +560,9 @@ export default function CameraSettingsView({
htmlFor="select-detections"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Limit detections to specific zones
<Trans ns="views/settings">
camera.reviewClassification.limitDetections
</Trans>
</label>
</div>
</div>
@@ -528,22 +570,59 @@ export default function CameraSettingsView({
)}
<div className="text-sm">
All {detectionsLabels} objects{" "}
<em>not classified as Alerts</em>{" "}
{watchedDetectionsZones &&
watchedDetectionsZones.length > 0
? ` that are detected in ${watchedDetectionsZones.map((zone) => capitalizeFirstLetter(zone).replaceAll("_", " ")).join(", ")}`
: ""}{" "}
on{" "}
{capitalizeFirstLetter(
cameraConfig?.name ?? "",
).replaceAll("_", " ")}{" "}
will be shown as Detections
{(!selectDetections ||
(watchedDetectionsZones &&
watchedDetectionsZones.length === 0)) &&
", regardless of zone"}
.
watchedDetectionsZones.length > 0 ? (
!selectDetections ? (
<Trans
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips"
values={{
detectionsLabels,
zone: watchedDetectionsZones
.map((zone) =>
capitalizeFirstLetter(zone).replaceAll(
"_",
" ",
),
)
.join(", "),
cameraName: capitalizeFirstLetter(
cameraConfig?.name ?? "",
).replaceAll("_", " "),
}}
ns="views/settings"
></Trans>
) : (
<Trans
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
values={{
detectionsLabels,
zone: watchedDetectionsZones
.map((zone) =>
capitalizeFirstLetter(zone).replaceAll(
"_",
" ",
),
)
.join(", "),
cameraName: capitalizeFirstLetter(
cameraConfig?.name ?? "",
).replaceAll("_", " "),
}}
ns="views/settings"
/>
)
) : (
<Trans
i18nKey="camera.reviewClassification.objectDetectionsTips"
values={{
detectionsLabels,
cameraName: capitalizeFirstLetter(
cameraConfig?.name ?? "",
).replaceAll("_", " "),
}}
ns="views/settings"
/>
)}
</div>
</FormItem>
)}
@@ -554,26 +633,28 @@ export default function CameraSettingsView({
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button
className="flex flex-1"
aria-label="Cancel"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
Cancel
<Trans>button.cancel</Trans>
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label="Save"
aria-label={t("button.save", { ns: "common" })}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
<span>
<Trans>button.saving</Trans>
</span>
</div>
) : (
"Save"
<Trans>button.save</Trans>
)}
</Button>
</div>
@@ -20,6 +20,7 @@ import {
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { Trans, useTranslation } from "react-i18next";
type ClassificationSettings = {
search: {
@@ -41,6 +42,7 @@ type ClassificationSettingsViewProps = {
export default function ClassificationSettingsView({
setUnsavedChanges,
}: ClassificationSettingsViewProps) {
const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [changedValue, setChangedValue] = useState(false);
@@ -141,15 +143,18 @@ export default function ClassificationSettingsView({
)
.then((res) => {
if (res.status === 200) {
toast.success("Classification settings have been saved.", {
toast.success(t("classification.toast.success"), {
position: "top-center",
});
setChangedValue(false);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
toast.error(
t("classification.toast.error", { errorMessage: res.statusText }),
{
position: "top-center",
},
);
}
})
.catch((error) => {
@@ -157,14 +162,14 @@ export default function ClassificationSettingsView({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center",
});
})
.finally(() => {
setIsLoading(false);
});
}, [updateConfig, classificationSettings.search]);
}, [updateConfig, classificationSettings.search, t]);
const onCancel = useCallback(() => {
setClassificationSettings(origSearchSettings);
@@ -188,8 +193,8 @@ export default function ClassificationSettingsView({
}, [changedValue]);
useEffect(() => {
document.title = "Classification Settings - Frigate";
}, []);
document.title = t("documentTitle.classification");
}, [t]);
if (!config) {
return <ActivityIndicator />;
@@ -200,19 +205,15 @@ export default function ClassificationSettingsView({
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
<Heading as="h3" className="my-2">
Classification Settings
{t("classification.title")}
</Heading>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Semantic Search
{t("classification.semanticSearch.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
Semantic Search in Frigate allows you to find tracked objects
within your review items using either the image itself, a
user-defined text description, or an automatically generated one.
</p>
<p>{t("classification.semanticSearch.desc")}</p>
<div className="flex items-center text-primary">
<Link
@@ -221,7 +222,7 @@ export default function ClassificationSettingsView({
rel="noopener noreferrer"
className="inline"
>
Read the Documentation
{t("classification.semanticSearch.readTheDocumentation")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -242,7 +243,9 @@ export default function ClassificationSettingsView({
}}
/>
<div className="space-y-0.5">
<Label htmlFor="enabled">Enabled</Label>
<Label htmlFor="enabled">
{t("button.enabled", { ns: "common" })}
</Label>
</div>
</div>
<div className="flex flex-col">
@@ -259,31 +262,38 @@ export default function ClassificationSettingsView({
}}
/>
<div className="space-y-0.5">
<Label htmlFor="reindex">Re-Index On Startup</Label>
<Label htmlFor="reindex">
{t("classification.semanticSearch.reindexOnStartup.label")}
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
Re-indexing will reprocess all thumbnails and descriptions (if
enabled) and apply the embeddings on each startup.{" "}
<em>Don't forget to disable the option after restarting!</em>
<Trans ns="views/settings">
classification.semanticSearch.reindexOnStartup.desc
</Trans>
</div>
</div>
<div className="mt-2 flex flex-col space-y-6">
<div className="space-y-0.5">
<div className="text-md">Model Size</div>
<div className="text-md">
{t("classification.semanticSearch.modelSize.label")}
</div>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
The size of the model used for Semantic Search embeddings.
<Trans ns="views/settings">
classification.semanticSearch.modelSize.desc
</Trans>
</p>
<ul className="list-disc pl-5 text-sm">
<li>
Using <em>small</em> employs a quantized version of the
model that uses less RAM and runs faster on CPU with a very
negligible difference in embedding quality.
<Trans ns="views/settings">
classification.semanticSearch.modelSize.small.desc
</Trans>
</li>
<li>
Using <em>large</em> employs the full Jina model and will
automatically run on the GPU if applicable.
<Trans ns="views/settings">
classification.semanticSearch.modelSize.large.desc
</Trans>
</li>
</ul>
</div>
@@ -309,7 +319,7 @@ export default function ClassificationSettingsView({
className="cursor-pointer"
value={size}
>
{size}
{t("classification.semanticSearch.modelSize." + size)}
</SelectItem>
))}
</SelectGroup>
@@ -322,16 +332,11 @@ export default function ClassificationSettingsView({
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Face Recognition
{t("classification.faceRecognition.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
Face recognition allows people to be assigned names and when
their face is recognized Frigate will assign the person's name
as a sub label. This information is included in the UI, filters,
as well as in notifications.
</p>
<p>{t("classification.faceRecognition.desc")}</p>
<div className="flex items-center text-primary">
<Link
@@ -340,7 +345,7 @@ export default function ClassificationSettingsView({
rel="noopener noreferrer"
className="inline"
>
Read the Documentation
{t("classification.faceRecognition.readTheDocumentation")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -361,7 +366,9 @@ export default function ClassificationSettingsView({
}}
/>
<div className="space-y-0.5">
<Label htmlFor="enabled">Enabled</Label>
<Label htmlFor="enabled">
{t("button.enabled", { ns: "common" })}
</Label>
</div>
</div>
</div>
@@ -369,18 +376,11 @@ export default function ClassificationSettingsView({
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
License Plate Recognition
{t("classification.licensePlateRecognition.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
Frigate can recognize license plates on vehicles and
automatically add the detected characters to the
recognized_license_plate field or a known name as a sub_label to
objects that are of type car. A common use case may be to read
the license plates of cars pulling into a driveway or cars
passing by on a street.
</p>
<p>{t("classification.licensePlateRecognition.desc")}</p>
<div className="flex items-center text-primary">
<Link
@@ -389,7 +389,9 @@ export default function ClassificationSettingsView({
rel="noopener noreferrer"
className="inline"
>
Read the Documentation
{t(
"classification.licensePlateRecognition.readTheDocumentation",
)}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -410,7 +412,9 @@ export default function ClassificationSettingsView({
}}
/>
<div className="space-y-0.5">
<Label htmlFor="enabled">Enabled</Label>
<Label htmlFor="enabled">
{t("button.enabled", { ns: "common" })}
</Label>
</div>
</div>
</div>
@@ -420,10 +424,10 @@ export default function ClassificationSettingsView({
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button
className="flex flex-1"
aria-label="Reset"
aria-label={t("button.reset", { ns: "common" })}
onClick={onCancel}
>
Reset
{t("button.reset", { ns: "common" })}
</Button>
<Button
variant="select"
@@ -435,10 +439,10 @@ export default function ClassificationSettingsView({
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
"Save"
t("button.save", { ns: "common" })
)}
</Button>
</div>
+40 -36
View File
@@ -37,7 +37,9 @@ import PolygonItem from "@/components/settings/PolygonItem";
import { Link } from "react-router-dom";
import { isDesktop } from "react-device-detect";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useTranslation } from "react-i18next";
type MasksAndZoneViewProps = {
selectedCamera: string;
@@ -50,6 +52,7 @@ export default function MasksAndZonesView({
selectedZoneMask,
setUnsavedChanges,
}: MasksAndZoneViewProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
@@ -182,8 +185,8 @@ export default function MasksAndZonesView({
setActivePolygonIndex(undefined);
setHoveredPolygonIndex(null);
setUnsavedChanges(false);
document.title = "Mask and Zone Editor - Frigate";
}, [allPolygons, setUnsavedChanges]);
document.title = t("documentTitle.masksAndZones");
}, [allPolygons, setUnsavedChanges, t]);
const handleSave = useCallback(() => {
setAllPolygons([...(editingPolygons ?? [])]);
@@ -218,12 +221,16 @@ export default function MasksAndZonesView({
.map((point) => `${point[0]},${point[1]}`)
.join(","),
);
toast.success(`Copied coordinates for ${poly.name} to clipboard.`);
toast.success(
t("masksAndZones.toast.success.copyCoordinates", {
polyName: poly.name,
}),
);
} else {
toast.error("Could not copy coordinates to clipboard.");
toast.error(t("masksAndZones.toast.error.copyCoordinatesFailed"));
}
},
[allPolygons, scaledHeight, scaledWidth],
[allPolygons, scaledHeight, scaledWidth, t],
);
useEffect(() => {
@@ -418,8 +425,8 @@ export default function MasksAndZonesView({
});
useEffect(() => {
document.title = "Mask and Zone Editor - Frigate";
}, []);
document.title = t("documentTitle.masksAndZones");
}, [t]);
if (!cameraConfig && !selectedCamera) {
return <ActivityIndicator />;
@@ -480,7 +487,7 @@ export default function MasksAndZonesView({
{editPane === undefined && (
<>
<Heading as="h3" className="my-2">
Masks / Zones
{t("menu.masksAndZones")}
</Heading>
<div className="flex w-full flex-col">
{(selectedZoneMask === undefined ||
@@ -489,15 +496,13 @@ export default function MasksAndZonesView({
<div className="my-3 flex flex-row items-center justify-between">
<HoverCard>
<HoverCardTrigger asChild>
<div className="text-md cursor-default">Zones</div>
<div className="text-md cursor-default">
{t("masksAndZones.zones.label")}
</div>
</HoverCardTrigger>
<HoverCardContent>
<div className="my-2 flex flex-col gap-2 text-sm text-primary-variant">
<p>
Zones allow you to define a specific area of the
frame so you can determine whether or not an
object is within a particular area.
</p>
<p>{t("masksAndZones.zones.desc")}</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/zones"
@@ -505,7 +510,7 @@ export default function MasksAndZonesView({
rel="noopener noreferrer"
className="inline"
>
Documentation{" "}
{t("masksAndZones.zones.desc.documentation")}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -517,7 +522,7 @@ export default function MasksAndZonesView({
<Button
variant="secondary"
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
aria-label="Add a new zone"
aria-label={t("masksAndZones.zones.add")}
onClick={() => {
setEditPane("zone");
handleNewPolygon("zone");
@@ -526,7 +531,9 @@ export default function MasksAndZonesView({
<LuPlus />
</Button>
</TooltipTrigger>
<TooltipContent>Add Zone</TooltipContent>
<TooltipContent>
{t("masksAndZones.zones.add")}
</TooltipContent>
</Tooltip>
</div>
{allPolygons
@@ -556,17 +563,12 @@ export default function MasksAndZonesView({
<HoverCard>
<HoverCardTrigger asChild>
<div className="text-md cursor-default">
Motion Masks
{t("masksAndZones.motionMasks.label")}
</div>
</HoverCardTrigger>
<HoverCardContent>
<div className="my-2 flex flex-col gap-2 text-sm text-primary-variant">
<p>
Motion masks are used to prevent unwanted types
of motion from triggering detection. Over
masking will make it more difficult for objects
to be tracked.
</p>
<p>{t("masksAndZones.motionMasks.desc")}</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/masks#motion-masks"
@@ -574,7 +576,9 @@ export default function MasksAndZonesView({
rel="noopener noreferrer"
className="inline"
>
Documentation{" "}
{t(
"masksAndZones.motionMasks.desc.documentation",
)}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -586,7 +590,7 @@ export default function MasksAndZonesView({
<Button
variant="secondary"
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
aria-label="Add a new motion mask"
aria-label={t("masksAndZones.motionMasks.add")}
onClick={() => {
setEditPane("motion_mask");
handleNewPolygon("motion_mask");
@@ -595,7 +599,9 @@ export default function MasksAndZonesView({
<LuPlus />
</Button>
</TooltipTrigger>
<TooltipContent>Add Motion Mask</TooltipContent>
<TooltipContent>
{t("masksAndZones.motionMasks.add")}
</TooltipContent>
</Tooltip>
</div>
{allPolygons
@@ -627,16 +633,12 @@ export default function MasksAndZonesView({
<HoverCard>
<HoverCardTrigger asChild>
<div className="text-md cursor-default">
Object Masks
{t("masksAndZones.objectMasks.label")}
</div>
</HoverCardTrigger>
<HoverCardContent>
<div className="my-2 flex flex-col gap-2 text-sm text-primary-variant">
<p>
Object filter masks are used to filter out false
positives for a given object type based on
location.
</p>
<p>{t("masksAndZones.objectMasks.desc")}</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/masks#object-filter-masks"
@@ -644,7 +646,7 @@ export default function MasksAndZonesView({
rel="noopener noreferrer"
className="inline"
>
Documentation{" "}
{t("masksAndZones.objectMasks.documentation")}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -656,7 +658,7 @@ export default function MasksAndZonesView({
<Button
variant="secondary"
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
aria-label="Add a new object mask"
aria-label={t("masksAndZones.objectMasks.add")}
onClick={() => {
setEditPane("object_mask");
handleNewPolygon("object_mask");
@@ -665,7 +667,9 @@ export default function MasksAndZonesView({
<LuPlus />
</Button>
</TooltipTrigger>
<TooltipContent>Add Object Mask</TooltipContent>
<TooltipContent>
{t("masksAndZones.objectMasks.add")}
</TooltipContent>
</Tooltip>
</div>
{allPolygons
+41 -30
View File
@@ -21,6 +21,7 @@ import { Separator } from "@/components/ui/separator";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { Trans, useTranslation } from "react-i18next";
type MotionTunerViewProps = {
selectedCamera: string;
@@ -37,6 +38,7 @@ export default function MotionTunerView({
selectedCamera,
setUnsavedChanges,
}: MotionTunerViewProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [changedValue, setChangedValue] = useState(false);
@@ -117,20 +119,29 @@ export default function MotionTunerView({
)
.then((res) => {
if (res.status === 200) {
toast.success("Motion settings have been saved.", {
toast.success(t("motionDetectionTuner.toast.success"), {
position: "top-center",
});
setChangedValue(false);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
toast.error(
t("toast.save.error", {
errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
t("toast.save.error", {
errorMessage: error.response.data.message,
ns: "common",
}),
{ position: "top-center" },
);
})
@@ -143,6 +154,7 @@ export default function MotionTunerView({
motionSettings.contour_area,
motionSettings.improve_contrast,
selectedCamera,
t,
]);
const onCancel = useCallback(() => {
@@ -167,8 +179,8 @@ export default function MotionTunerView({
}, [changedValue, selectedCamera]);
useEffect(() => {
document.title = "Motion Tuner - Frigate";
}, []);
document.title = t("documentTitle.motionTuner");
}, [t]);
if (!cameraConfig && !selectedCamera) {
return <ActivityIndicator />;
@@ -179,14 +191,10 @@ export default function MotionTunerView({
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-3/12">
<Heading as="h3" className="my-2">
Motion Detection Tuner
{t("motionDetectionTuner.title")}
</Heading>
<div className="my-3 space-y-3 text-sm text-muted-foreground">
<p>
Frigate uses motion detection as a first line check to see if there
is anything happening in the frame worth checking with object
detection.
</p>
<p>{t("motionDetectionTuner.desc")}</p>
<div className="flex items-center text-primary">
<Link
@@ -195,7 +203,7 @@ export default function MotionTunerView({
rel="noopener noreferrer"
className="inline"
>
Read the Motion Tuning Guide{" "}
{t("motionDetectionTuner.desc.documentation")}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -205,14 +213,12 @@ export default function MotionTunerView({
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<Label htmlFor="motion-threshold" className="text-md">
Threshold
{t("motionDetectionTuner.Threshold")}
</Label>
<div className="my-2 text-sm text-muted-foreground">
<p>
The threshold value dictates how much of a change in a pixel's
luminance is required to be considered motion.{" "}
<em>Default: 30</em>
</p>
<Trans ns="views/settings">
motionDetectionTuner.Threshold.desc
</Trans>
</div>
</div>
<div className="flex flex-row justify-between">
@@ -236,12 +242,13 @@ export default function MotionTunerView({
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<Label htmlFor="motion-threshold" className="text-md">
Contour Area
{t("motionDetectionTuner.contourArea")}
</Label>
<div className="my-2 text-sm text-muted-foreground">
<p>
The contour area value is used to decide which groups of
changed pixels qualify as motion. <em>Default: 10</em>
<Trans ns="views/settings">
motionDetectionTuner.contourArea.desc
</Trans>
</p>
</div>
</div>
@@ -266,9 +273,13 @@ export default function MotionTunerView({
<Separator className="my-2 flex bg-secondary" />
<div className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="improve-contrast">Improve Contrast</Label>
<Label htmlFor="improve-contrast">
{t("motionDetectionTuner.improveContrast")}
</Label>
<div className="text-sm text-muted-foreground">
Improve contrast for darker scenes. <em>Default: ON</em>
<Trans ns="views/settings">
motionDetectionTuner.improveContrast.desc
</Trans>
</div>
</div>
<Switch
@@ -286,25 +297,25 @@ export default function MotionTunerView({
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label="Reset"
aria-label={t("button.reset", { ns: "common" })}
onClick={onCancel}
>
Reset
{t("button.reset", { ns: "common" })}
</Button>
<Button
variant="select"
disabled={!changedValue || isLoading}
className="flex flex-1"
aria-label="Save"
aria-label={t("button.save", { ns: "common" })}
onClick={saveToConfig}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
"Save"
t("button.save", { ns: "common" })
)}
</Button>
</div>

Some files were not shown because too many files have changed in this diff Show More