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"