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