mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-02 17:12:16 +03:00
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:
co-authored by
Josh Hawkins
parent
db541abed4
commit
d34533981f
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user