mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-27 06:09:01 +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
@@ -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]) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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] : [];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createContext, useContext, useState, useEffect, useMemo } from "react";
|
||||
import i18next from "i18next";
|
||||
|
||||
type LanguageProviderState = {
|
||||
language: string;
|
||||
systemLanguage: string;
|
||||
setLanguage: (language: string) => void;
|
||||
};
|
||||
|
||||
const initialState: LanguageProviderState = {
|
||||
language: i18next.language || "en",
|
||||
systemLanguage: "en",
|
||||
setLanguage: () => null,
|
||||
};
|
||||
|
||||
const LanguageProviderContext =
|
||||
createContext<LanguageProviderState>(initialState);
|
||||
|
||||
export function LanguageProvider({
|
||||
children,
|
||||
defaultLanguage = "en",
|
||||
storageKey = "frigate-ui-language",
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
defaultLanguage?: string;
|
||||
storageKey?: string;
|
||||
}) {
|
||||
const [language, setLanguage] = useState<string>(() => {
|
||||
try {
|
||||
const storedData = localStorage.getItem(storageKey);
|
||||
const newLanguage = storedData || defaultLanguage;
|
||||
i18next.changeLanguage(newLanguage);
|
||||
return newLanguage;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error retrieving language data from storage:", error);
|
||||
return defaultLanguage;
|
||||
}
|
||||
});
|
||||
|
||||
const systemLanguage = useMemo<string>(() => {
|
||||
if (typeof window === "undefined") return "en";
|
||||
return window.navigator.language;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (language === systemLanguage) return;
|
||||
i18next.changeLanguage(language);
|
||||
}, [language, systemLanguage]);
|
||||
|
||||
const value = {
|
||||
language,
|
||||
systemLanguage,
|
||||
setLanguage: (language: string) => {
|
||||
localStorage.setItem(storageKey, language);
|
||||
setLanguage(language);
|
||||
window.location.reload();
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</LanguageProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useLanguage = () => {
|
||||
const context = useContext(LanguageProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useLanguage must be used within a LanguageProvider");
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { ApiProvider } from "@/api";
|
||||
import { IconContext } from "react-icons";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { StatusBarMessagesProvider } from "@/context/statusbar-provider";
|
||||
import { LanguageProvider } from "./language-provider";
|
||||
import { StreamingSettingsProvider } from "./streaming-settings-provider";
|
||||
import { AuthProvider } from "./auth-context";
|
||||
|
||||
@@ -18,15 +19,17 @@ function providers({ children }: TProvidersProps) {
|
||||
<AuthProvider>
|
||||
<ApiProvider>
|
||||
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
|
||||
<TooltipProvider>
|
||||
<IconContext.Provider value={{ size: "20" }}>
|
||||
<StatusBarMessagesProvider>
|
||||
<StreamingSettingsProvider>
|
||||
{children}
|
||||
</StreamingSettingsProvider>
|
||||
</StatusBarMessagesProvider>
|
||||
</IconContext.Provider>
|
||||
</TooltipProvider>
|
||||
<LanguageProvider>
|
||||
<TooltipProvider>
|
||||
<IconContext.Provider value={{ size: "20" }}>
|
||||
<StatusBarMessagesProvider>
|
||||
<StreamingSettingsProvider>
|
||||
{children}
|
||||
</StreamingSettingsProvider>
|
||||
</StatusBarMessagesProvider>
|
||||
</IconContext.Provider>
|
||||
</TooltipProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</ApiProvider>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -23,9 +23,7 @@ export const colorSchemes: ColorScheme[] = [
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const friendlyColorSchemeName = (className: string): string => {
|
||||
const words = className.split("-").slice(1); // Exclude the first word (e.g., 'theme')
|
||||
return words
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
return "menu.theme." + words.join(".");
|
||||
};
|
||||
|
||||
type ThemeProviderProps = {
|
||||
|
||||
@@ -54,7 +54,7 @@ export function useCameraActivity(
|
||||
// handle camera activity
|
||||
|
||||
const hasActiveObjects = useMemo(
|
||||
() => objects.filter((obj) => !obj.stationary).length > 0,
|
||||
() => objects?.filter((obj) => !obj?.stationary)?.length > 0,
|
||||
[objects],
|
||||
);
|
||||
|
||||
|
||||
@@ -31,35 +31,35 @@ export default function useNavigation(
|
||||
id: ID_LIVE,
|
||||
variant,
|
||||
icon: FaVideo,
|
||||
title: "Live",
|
||||
title: "menu.live",
|
||||
url: "/",
|
||||
},
|
||||
{
|
||||
id: ID_REVIEW,
|
||||
variant,
|
||||
icon: MdVideoLibrary,
|
||||
title: "Review",
|
||||
title: "menu.review",
|
||||
url: "/review",
|
||||
},
|
||||
{
|
||||
id: ID_EXPLORE,
|
||||
variant,
|
||||
icon: IoSearch,
|
||||
title: "Explore",
|
||||
title: "menu.explore",
|
||||
url: "/explore",
|
||||
},
|
||||
{
|
||||
id: ID_EXPORT,
|
||||
variant,
|
||||
icon: FaCompactDisc,
|
||||
title: "Export",
|
||||
title: "menu.export",
|
||||
url: "/export",
|
||||
},
|
||||
{
|
||||
id: ID_PLAYGROUND,
|
||||
variant,
|
||||
icon: LuConstruction,
|
||||
title: "UI Playground",
|
||||
title: "menu.uiPlayground",
|
||||
url: "/playground",
|
||||
enabled: ENV !== "production",
|
||||
},
|
||||
@@ -67,11 +67,11 @@ export default function useNavigation(
|
||||
id: ID_FACE_LIBRARY,
|
||||
variant,
|
||||
icon: TbFaceId,
|
||||
title: "Face Library",
|
||||
title: "menu.faceLibrary",
|
||||
url: "/faces",
|
||||
enabled: isDesktop && config?.face_recognition.enabled,
|
||||
},
|
||||
] as NavData[],
|
||||
[config?.face_recognition.enabled, variant],
|
||||
[config?.face_recognition?.enabled, variant],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ import useDeepMemo from "./use-deep-memo";
|
||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { useFrigateStats } from "@/api/ws";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function useStats(stats: FrigateStats | undefined) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const memoizedStats = useDeepMemo(stats);
|
||||
@@ -72,7 +75,10 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
|
||||
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
|
||||
problems.push({
|
||||
text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high FFMPEG CPU usage (${ffmpegAvg}%)`,
|
||||
text: t("stats.ffmpegHighCpuUsage", {
|
||||
camera: capitalizeFirstLetter(name.replaceAll("_", " ")),
|
||||
ffmpegAvg,
|
||||
}),
|
||||
color: "text-danger",
|
||||
relevantLink: "/system#cameras",
|
||||
});
|
||||
@@ -80,7 +86,10 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
|
||||
if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) {
|
||||
problems.push({
|
||||
text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high detect CPU usage (${detectAvg}%)`,
|
||||
text: t("stats.detectHighCpuUsage", {
|
||||
camera: capitalizeFirstLetter(name.replaceAll("_", " ")),
|
||||
detectAvg,
|
||||
}),
|
||||
color: "text-danger",
|
||||
relevantLink: "/system#cameras",
|
||||
});
|
||||
@@ -88,7 +97,7 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
});
|
||||
|
||||
return problems;
|
||||
}, [config, memoizedStats]);
|
||||
}, [config, memoizedStats, t]);
|
||||
|
||||
return { potentialProblems };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
import "@/utils/i18n";
|
||||
import "react-i18next";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaExclamationTriangle } from "react-icons/fa";
|
||||
|
||||
export default function AccessDenied() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
useEffect(() => {
|
||||
document.title = "Access Denied - Frigate";
|
||||
}, []);
|
||||
document.title = t("accessDenied.documentTitle");
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center text-center">
|
||||
<FaExclamationTriangle className="mb-4 size-8" />
|
||||
<Heading as="h2" className="mb-2">
|
||||
Access Denied
|
||||
{t("accessDenied.title")}
|
||||
</Heading>
|
||||
<p className="text-primary-variant">
|
||||
You don't have permission to view this page.
|
||||
</p>
|
||||
<p className="text-primary-variant">{t("accessDenied.desc")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { toast } from "sonner";
|
||||
import { LuCopy, LuSave } from "react-icons/lu";
|
||||
import { MdOutlineRestartAlt } from "react-icons/md";
|
||||
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRestart } from "@/api/ws";
|
||||
|
||||
type SaveOptions = "saveonly" | "restart";
|
||||
@@ -24,11 +25,12 @@ type ApiErrorResponse = {
|
||||
};
|
||||
|
||||
function ConfigEditor() {
|
||||
const { t } = useTranslation(["views/configEditor"]);
|
||||
const apiHost = useApiHost();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Config Editor - Frigate";
|
||||
}, []);
|
||||
document.title = t("documentTitle");
|
||||
}, [t]);
|
||||
|
||||
const { data: config } = useSWR<string>("config/raw");
|
||||
|
||||
@@ -64,7 +66,7 @@ function ConfigEditor() {
|
||||
toast.success(response.data.message, { position: "top-center" });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Error saving config", { position: "top-center" });
|
||||
toast.error(t("toast.error.savingError"), { position: "top-center" });
|
||||
|
||||
const axiosError = error as AxiosError<ApiErrorResponse>;
|
||||
const errorMessage =
|
||||
@@ -76,7 +78,7 @@ function ConfigEditor() {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
},
|
||||
[editorRef],
|
||||
[editorRef, t],
|
||||
);
|
||||
|
||||
const handleCopyConfig = useCallback(async () => {
|
||||
@@ -85,8 +87,10 @@ function ConfigEditor() {
|
||||
}
|
||||
|
||||
copy(editorRef.current.getValue());
|
||||
toast.success("Config copied to clipboard.", { position: "top-center" });
|
||||
}, [editorRef]);
|
||||
toast.success(t("toast.success.copyToClipboard"), {
|
||||
position: "top-center",
|
||||
});
|
||||
}, [editorRef, t]);
|
||||
|
||||
const handleSaveAndRestart = useCallback(async () => {
|
||||
try {
|
||||
@@ -208,38 +212,38 @@ function ConfigEditor() {
|
||||
<div className="relative h-full overflow-hidden">
|
||||
<div className="mr-1 flex items-center justify-between">
|
||||
<Heading as="h2" className="mb-0 ml-1 md:ml-0">
|
||||
Config Editor
|
||||
{t("configEditor")}
|
||||
</Heading>
|
||||
<div className="flex flex-row gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Copy config"
|
||||
aria-label={t("copyConfig")}
|
||||
onClick={() => handleCopyConfig()}
|
||||
>
|
||||
<LuCopy className="text-secondary-foreground" />
|
||||
<span className="hidden md:block">Copy Config</span>
|
||||
<span className="hidden md:block">{t("copyConfig")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Save and restart"
|
||||
aria-label={t("saveAndRestart")}
|
||||
onClick={handleSaveAndRestart}
|
||||
>
|
||||
<div className="relative size-5">
|
||||
<LuSave className="absolute left-0 top-0 size-3 text-secondary-foreground" />
|
||||
<MdOutlineRestartAlt className="absolute size-4 translate-x-1 translate-y-1/2 text-secondary-foreground" />
|
||||
</div>
|
||||
<span className="hidden md:block">Save & Restart</span>
|
||||
<span className="hidden md:block">{t("saveAndRestart")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Save only without restarting"
|
||||
aria-label={t("saveOnly")}
|
||||
onClick={() => onHandleSaveConfig("saveonly")}
|
||||
>
|
||||
<LuSave className="text-secondary-foreground" />
|
||||
<span className="hidden md:block">Save Only</span>
|
||||
<span className="hidden md:block">{t("saveOnly")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,9 +23,12 @@ import EventView from "@/views/events/EventView";
|
||||
import { RecordingView } from "@/views/recording/RecordingView";
|
||||
import axios from "axios";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
|
||||
export default function Events() {
|
||||
const { t } = useTranslation(["views/events"]);
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
@@ -77,11 +80,11 @@ export default function Events() {
|
||||
|
||||
useEffect(() => {
|
||||
if (recording) {
|
||||
document.title = "Recordings - Frigate";
|
||||
document.title = t("recordings.documentTitle");
|
||||
} else {
|
||||
document.title = `Review - Frigate`;
|
||||
document.title = t("documentTitle");
|
||||
}
|
||||
}, [recording, severity]);
|
||||
}, [recording, severity, t]);
|
||||
|
||||
// review filter
|
||||
|
||||
|
||||
+38
-21
@@ -15,6 +15,7 @@ import { formatSecondsToDuration } from "@/utils/dateUtil";
|
||||
import SearchView from "@/views/search/SearchView";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
|
||||
import { TbExclamationCircle } from "react-icons/tb";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -27,6 +28,8 @@ const API_LIMIT = 25;
|
||||
export default function Explore() {
|
||||
// search field handler
|
||||
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
@@ -201,7 +204,9 @@ export default function Explore() {
|
||||
revalidateAll: false,
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
`Error fetching tracked objects: ${error.response.data.message}`,
|
||||
t("fetchingTrackedObjectsFailed", {
|
||||
errorMessage: error.response.data.message,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
@@ -357,13 +362,12 @@ export default function Explore() {
|
||||
<div className="flex max-w-96 flex-col items-center justify-center space-y-3 rounded-lg bg-background/50 p-5">
|
||||
<div className="my-5 flex flex-col items-center gap-2 text-xl">
|
||||
<TbExclamationCircle className="mb-3 size-10" />
|
||||
<div>Explore is Unavailable</div>
|
||||
<div>{t("exploreIsUnavailable.title")}</div>
|
||||
</div>
|
||||
{embeddingsReindexing && allModelsLoaded && (
|
||||
<>
|
||||
<div className="text-center text-primary-variant">
|
||||
Explore can be used after tracked object embeddings have
|
||||
finished reindexing.
|
||||
{t("exploreIsUnavailable.embeddingsReindexing.context")}
|
||||
</div>
|
||||
<div className="pt-5 text-center">
|
||||
<AnimatedCircularProgressBar
|
||||
@@ -379,29 +383,35 @@ export default function Explore() {
|
||||
<div className="mb-3 flex flex-col items-center justify-center gap-1">
|
||||
<div className="text-primary-variant">
|
||||
{reindexState.time_remaining === -1
|
||||
? "Starting up..."
|
||||
: "Estimated time remaining:"}
|
||||
? t(
|
||||
"exploreIsUnavailable.embeddingsReindexing.startingUp",
|
||||
)
|
||||
: t(
|
||||
"exploreIsUnavailable.embeddingsReindexing.estimatedTime",
|
||||
)}
|
||||
</div>
|
||||
{reindexState.time_remaining >= 0 &&
|
||||
(formatSecondsToDuration(reindexState.time_remaining) ||
|
||||
"Finishing shortly")}
|
||||
t(
|
||||
"exploreIsUnavailable.embeddingsReindexing.finishingShortly",
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-row items-center justify-center gap-3">
|
||||
<span className="text-primary-variant">
|
||||
Thumbnails embedded:
|
||||
t("exploreIsUnavailable.embeddingsReindexing.step.thumbnailsEmbedded")
|
||||
</span>
|
||||
{reindexState.thumbnails}
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-center gap-3">
|
||||
<span className="text-primary-variant">
|
||||
Descriptions embedded:
|
||||
t("exploreIsUnavailable.embeddingsReindexing.step.descriptionsEmbedded")
|
||||
</span>
|
||||
{reindexState.descriptions}
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-center gap-3">
|
||||
<span className="text-primary-variant">
|
||||
Tracked objects processed:
|
||||
t("exploreIsUnavailable.embeddingsReindexing.step.trackedObjectsProcessed")
|
||||
</span>
|
||||
{reindexState.processed_objects} /{" "}
|
||||
{reindexState.total_objects}
|
||||
@@ -412,26 +422,32 @@ export default function Explore() {
|
||||
{!allModelsLoaded && (
|
||||
<>
|
||||
<div className="text-center text-primary-variant">
|
||||
Frigate is downloading the necessary embeddings models to
|
||||
support the Semantic Search feature. This may take several
|
||||
minutes depending on the speed of your network connection.
|
||||
{t("exploreIsUnavailable.downloadingModels.context")}
|
||||
</div>
|
||||
<div className="flex w-96 flex-col gap-2 py-5">
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
{renderModelStateIcon(visionModelState)}
|
||||
Vision model
|
||||
{t(
|
||||
"exploreIsUnavailable.downloadingModels.setup.visionModel",
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
{renderModelStateIcon(visionFeatureExtractorState)}
|
||||
Vision model feature extractor
|
||||
{t(
|
||||
"exploreIsUnavailable.downloadingModels.setup.visionModelFeatureExtractor",
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
{renderModelStateIcon(textModelState)}
|
||||
Text model
|
||||
{t(
|
||||
"exploreIsUnavailable.downloadingModels.setup.textModel",
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
{renderModelStateIcon(textTokenizerState)}
|
||||
Text tokenizer
|
||||
{t(
|
||||
"exploreIsUnavailable.downloadingModels.setup.textTokenizer",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(textModelState === "error" ||
|
||||
@@ -439,12 +455,11 @@ export default function Explore() {
|
||||
visionModelState === "error" ||
|
||||
visionFeatureExtractorState === "error") && (
|
||||
<div className="my-3 max-w-96 text-center text-danger">
|
||||
An error has occurred. Check Frigate logs.
|
||||
{t("exploreIsUnavailable.downloadingModels.error")}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center text-primary-variant">
|
||||
You may want to reindex the embeddings of your tracked objects
|
||||
once the models are downloaded.
|
||||
{t("exploreIsUnavailable.downloadingModels.tips.context")}
|
||||
</div>
|
||||
<div className="flex items-center text-primary-variant">
|
||||
<Link
|
||||
@@ -453,7 +468,9 @@ export default function Explore() {
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
{t(
|
||||
"exploreIsUnavailable.downloadingModels.tips.documentation",
|
||||
)}{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
+16
-10
@@ -17,18 +17,22 @@ import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DeleteClipType, Export } from "@/types/export";
|
||||
import axios from "axios";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LuFolderX } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
function Exports() {
|
||||
const { t } = useTranslation(["views/exports"]);
|
||||
const { data: exports, mutate } = useSWR<Export[]>("exports");
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Export - Frigate";
|
||||
}, []);
|
||||
document.title = t("documentTitle");
|
||||
}, [t]);
|
||||
|
||||
// Search
|
||||
|
||||
@@ -97,12 +101,12 @@ function Exports() {
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to rename export: ${errorMessage}`, {
|
||||
toast.error(t("toast.error.renameExportFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
},
|
||||
[mutate],
|
||||
[mutate, t],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -115,20 +119,22 @@ function Exports() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Export</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("deleteExport")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete {deleteClip?.exportName}?
|
||||
{t("deleteExport.desc", { exportName: deleteClip?.exportName })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
className="text-white"
|
||||
aria-label="Delete Export"
|
||||
variant="destructive"
|
||||
onClick={() => onHandleDelete()}
|
||||
>
|
||||
Delete
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -176,7 +182,7 @@ function Exports() {
|
||||
<div className="flex w-full items-center justify-center p-2">
|
||||
<Input
|
||||
className="text-md w-full bg-muted md:w-1/3"
|
||||
placeholder="Search"
|
||||
placeholder={t("search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
@@ -204,7 +210,7 @@ function Exports() {
|
||||
) : (
|
||||
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
|
||||
<LuFolderX className="size-16" />
|
||||
No exports found
|
||||
{t("noExports")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -25,18 +25,21 @@ import { cn } from "@/lib/utils";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import axios from "axios";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuImagePlus, LuRefreshCw, LuScanFace, LuTrash2 } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
export default function FaceLibrary() {
|
||||
const { t } = useTranslation(["views/faceLibrary"]);
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// title
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Face Library - Frigate";
|
||||
}, []);
|
||||
document.title = t("documentTitle");
|
||||
}, [t]);
|
||||
|
||||
const [page, setPage] = useState<string>();
|
||||
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
||||
@@ -94,7 +97,7 @@ export default function FaceLibrary() {
|
||||
if (resp.status == 200) {
|
||||
setUpload(false);
|
||||
refreshFaces();
|
||||
toast.success("Successfully uploaded image.", {
|
||||
toast.success(t("toast.success.uploadedImage"), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
@@ -104,12 +107,12 @@ export default function FaceLibrary() {
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to upload image: ${errorMessage}`, {
|
||||
toast.error(t("toast.error.uploadingImageFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
},
|
||||
[pageToggle, refreshFaces],
|
||||
[pageToggle, refreshFaces, t],
|
||||
);
|
||||
|
||||
const onAddName = useCallback(
|
||||
@@ -124,7 +127,7 @@ export default function FaceLibrary() {
|
||||
if (resp.status == 200) {
|
||||
setAddFace(false);
|
||||
refreshFaces();
|
||||
toast.success("Successfully add face library.", {
|
||||
toast.success(t("toast.success.addFaceLibrary"), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
@@ -134,12 +137,12 @@ export default function FaceLibrary() {
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to set face name: ${errorMessage}`, {
|
||||
toast.error(t("toast.error.addFaceLibraryFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
},
|
||||
[refreshFaces],
|
||||
[refreshFaces, t],
|
||||
);
|
||||
|
||||
// face multiselect
|
||||
@@ -176,7 +179,7 @@ export default function FaceLibrary() {
|
||||
setSelectedFaces([]);
|
||||
|
||||
if (resp.status == 200) {
|
||||
toast.success(`Successfully deleted face.`, {
|
||||
toast.success(t("toast.success.deletedFace"), {
|
||||
position: "top-center",
|
||||
});
|
||||
refreshFaces();
|
||||
@@ -187,11 +190,11 @@ export default function FaceLibrary() {
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to delete: ${errorMessage}`, {
|
||||
toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}, [selectedFaces, refreshFaces]);
|
||||
}, [selectedFaces, refreshFaces, t]);
|
||||
|
||||
// keyboard
|
||||
|
||||
@@ -219,15 +222,15 @@ export default function FaceLibrary() {
|
||||
|
||||
<UploadImageDialog
|
||||
open={upload}
|
||||
title="Upload Face Image"
|
||||
description={`Upload an image to scan for faces and include for ${pageToggle}`}
|
||||
title={t("uploadFaceImage.title")}
|
||||
description={t("uploadFaceImage.desc", { pageToggle })}
|
||||
setOpen={setUpload}
|
||||
onSave={onUploadImage}
|
||||
/>
|
||||
|
||||
<TextEntryDialog
|
||||
title="Create Face Library"
|
||||
description="Create a new face library"
|
||||
title={t("createFaceLibrary.title")}
|
||||
description={t("createFaceLibrary.desc")}
|
||||
open={addFace}
|
||||
setOpen={setAddFace}
|
||||
onSave={onAddName}
|
||||
@@ -253,9 +256,9 @@ export default function FaceLibrary() {
|
||||
value="train"
|
||||
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == "train" ? "" : "*:text-muted-foreground"}`}
|
||||
data-nav-item="train"
|
||||
aria-label="Select train"
|
||||
aria-label={t("train.aria")}
|
||||
>
|
||||
<div>Train</div>
|
||||
<div>{t("train.title")}</div>
|
||||
</ToggleGroupItem>
|
||||
<div>|</div>
|
||||
</>
|
||||
@@ -267,7 +270,7 @@ export default function FaceLibrary() {
|
||||
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
value={item}
|
||||
data-nav-item={item}
|
||||
aria-label={`Select ${item}`}
|
||||
aria-label={t("selectItem", { item })}
|
||||
>
|
||||
<div className="capitalize">
|
||||
{item} ({faceData[item].length})
|
||||
@@ -282,19 +285,19 @@ export default function FaceLibrary() {
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button className="flex gap-2" onClick={() => onDelete()}>
|
||||
<LuTrash2 className="size-7 rounded-md p-1 text-secondary-foreground" />
|
||||
Delete Face Attempts
|
||||
{t("button.deleteFaceAttempts")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button className="flex gap-2" onClick={() => setAddFace(true)}>
|
||||
<LuScanFace className="size-7 rounded-md p-1 text-secondary-foreground" />
|
||||
Add Face
|
||||
{t("button.addFace")}
|
||||
</Button>
|
||||
{pageToggle != "train" && (
|
||||
<Button className="flex gap-2" onClick={() => setUpload(true)}>
|
||||
<LuImagePlus className="size-7 rounded-md p-1 text-secondary-foreground" />
|
||||
Upload Image
|
||||
{t("button.uploadImage")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -370,6 +373,7 @@ function FaceAttempt({
|
||||
onClick,
|
||||
onRefresh,
|
||||
}: FaceAttemptProps) {
|
||||
const { t } = useTranslation(["views/faceLibrary"]);
|
||||
const data = useMemo(() => {
|
||||
const parts = image.split("-");
|
||||
|
||||
@@ -386,7 +390,7 @@ function FaceAttempt({
|
||||
.post(`/faces/train/${trainName}/classify`, { training_file: image })
|
||||
.then((resp) => {
|
||||
if (resp.status == 200) {
|
||||
toast.success(`Successfully trained face.`, {
|
||||
toast.success(t("toast.success.trainedFace"), {
|
||||
position: "top-center",
|
||||
});
|
||||
onRefresh();
|
||||
@@ -397,12 +401,12 @@ function FaceAttempt({
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to train: ${errorMessage}`, {
|
||||
toast.error(t("toast.error.trainFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
},
|
||||
[image, onRefresh],
|
||||
[image, onRefresh, t],
|
||||
);
|
||||
|
||||
const onReprocess = useCallback(() => {
|
||||
@@ -410,7 +414,7 @@ function FaceAttempt({
|
||||
.post(`/faces/reprocess`, { training_file: image })
|
||||
.then((resp) => {
|
||||
if (resp.status == 200) {
|
||||
toast.success(`Successfully updated face score.`, {
|
||||
toast.success(t("toast.success.updatedFaceScore"), {
|
||||
position: "top-center",
|
||||
});
|
||||
onRefresh();
|
||||
@@ -421,11 +425,11 @@ function FaceAttempt({
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to update face score: ${errorMessage}`, {
|
||||
toast.error(t("toast.error.updateFaceScoreFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}, [image, onRefresh]);
|
||||
}, [image, onRefresh, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -463,7 +467,7 @@ function FaceAttempt({
|
||||
</TooltipTrigger>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Train Face as:</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>{t("trainFaceAs")}</DropdownMenuLabel>
|
||||
{faceNames.map((faceName) => (
|
||||
<DropdownMenuItem
|
||||
key={faceName}
|
||||
@@ -475,7 +479,7 @@ function FaceAttempt({
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipContent>Train Face as Person</TooltipContent>
|
||||
<TooltipContent>{t("trainFaceAsPerson")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
@@ -484,7 +488,7 @@ function FaceAttempt({
|
||||
onClick={() => onReprocess()}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reprocess Face</TooltipContent>
|
||||
<TooltipContent>{t("button.reprocessFace")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -519,12 +523,13 @@ type FaceImageProps = {
|
||||
onRefresh: () => void;
|
||||
};
|
||||
function FaceImage({ name, image, onRefresh }: FaceImageProps) {
|
||||
const { t } = useTranslation(["views/faceLibrary"]);
|
||||
const onDelete = useCallback(() => {
|
||||
axios
|
||||
.post(`/faces/${name}/delete`, { ids: [image] })
|
||||
.then((resp) => {
|
||||
if (resp.status == 200) {
|
||||
toast.success(`Successfully deleted face.`, {
|
||||
toast.success(t("toast.success.deletedFace"), {
|
||||
position: "top-center",
|
||||
});
|
||||
onRefresh();
|
||||
@@ -535,11 +540,11 @@ function FaceImage({ name, image, onRefresh }: FaceImageProps) {
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to delete: ${errorMessage}`, {
|
||||
toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}, [name, image, onRefresh]);
|
||||
}, [name, image, onRefresh, t]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col rounded-lg">
|
||||
@@ -559,7 +564,7 @@ function FaceImage({ name, image, onRefresh }: FaceImageProps) {
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete Face Attempt</TooltipContent>
|
||||
<TooltipContent>{t("button.deleteFaceAttempts")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+11
-4
@@ -9,10 +9,13 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
|
||||
import LiveCameraView from "@/views/live/LiveCameraView";
|
||||
import LiveDashboardView from "@/views/live/LiveDashboardView";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
function Live() {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// selection
|
||||
@@ -64,13 +67,17 @@ function Live() {
|
||||
.split("_")
|
||||
.filter((text) => text)
|
||||
.map((text) => text[0].toUpperCase() + text.substring(1));
|
||||
document.title = `${capitalized.join(" ")} - Live - Frigate`;
|
||||
document.title = t("documentTitle.withCamera", {
|
||||
camera: capitalized.join(" "),
|
||||
});
|
||||
} else if (cameraGroup && cameraGroup != "default") {
|
||||
document.title = `${cameraGroup[0].toUpperCase()}${cameraGroup.substring(1)} - Live - Frigate`;
|
||||
document.title = t("documentTitle.withCamera", {
|
||||
camera: `${cameraGroup[0].toUpperCase()}${cameraGroup.substring(1)}`,
|
||||
});
|
||||
} else {
|
||||
document.title = "Live - Frigate";
|
||||
document.title = t("documentTitle", { ns: "views/live" });
|
||||
}
|
||||
}, [cameraGroup, selectedCameraName]);
|
||||
}, [cameraGroup, selectedCameraName, t]);
|
||||
|
||||
// settings
|
||||
|
||||
|
||||
+33
-24
@@ -34,8 +34,10 @@ import { debounce } from "lodash";
|
||||
import { isIOS, isMobile } from "react-device-detect";
|
||||
import { isPWA } from "@/utils/isPWA";
|
||||
import { isInIframe } from "@/utils/isIFrame";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function Logs() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const [logService, setLogService] = useState<LogType>("frigate");
|
||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||
const lazyLogWrapperRef = useRef<HTMLDivElement>(null);
|
||||
@@ -47,8 +49,8 @@ function Logs() {
|
||||
const lastFetchedIndexRef = useRef(-1);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`;
|
||||
}, [logService]);
|
||||
document.title = t("documentTitle.logs." + logService);
|
||||
}, [logService, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tabsRef.current) {
|
||||
@@ -104,13 +106,16 @@ function Logs() {
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "An unknown error occurred";
|
||||
toast.error(`Error fetching logs: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("logs.toast.error.fetchingLogsFailed", { errorMessage }),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
[logService, filterLines],
|
||||
[logService, filterLines, t],
|
||||
);
|
||||
|
||||
const fetchInitialLogs = useCallback(async () => {
|
||||
@@ -132,13 +137,13 @@ function Logs() {
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "An unknown error occurred";
|
||||
toast.error(`Error fetching logs: ${errorMessage}`, {
|
||||
toast.error(t("logs.toast.error.fetchingLogsFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [logService, filterLines, filterSeverity]);
|
||||
}, [logService, filterLines, filterSeverity, t]);
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
@@ -203,10 +208,12 @@ function Logs() {
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unknown error occurred";
|
||||
toast.error(`Error while streaming logs: ${errorMessage}`);
|
||||
toast.error(
|
||||
t("logs.toast.error.whileStreamingLogs", { errorMessage }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}, [logService, filterSeverity]);
|
||||
}, [logService, filterSeverity, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
@@ -285,13 +292,13 @@ function Logs() {
|
||||
fetchInitialLogs()
|
||||
.then(() => {
|
||||
copy(logs.join("\n"));
|
||||
toast.success("Copied logs to clipboard");
|
||||
toast.success(t("logs.copy.success"));
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Could not copy logs to clipboard");
|
||||
toast.error(t("logs.copy.error"));
|
||||
});
|
||||
}
|
||||
}, [logs, fetchInitialLogs]);
|
||||
}, [logs, fetchInitialLogs, t]);
|
||||
|
||||
const handleDownloadLogs = useCallback(() => {
|
||||
axios
|
||||
@@ -496,23 +503,25 @@ function Logs() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="flex items-center justify-between gap-2"
|
||||
aria-label="Copy logs to clipboard"
|
||||
aria-label={t("logs.copy.label")}
|
||||
size="sm"
|
||||
onClick={handleCopyLogs}
|
||||
>
|
||||
<FaCopy className="text-secondary-foreground" />
|
||||
<div className="hidden text-primary md:block">
|
||||
Copy to Clipboard
|
||||
{t("logs.copy.label")}
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center justify-between gap-2"
|
||||
aria-label="Download logs"
|
||||
aria-label={t("logs.download.label")}
|
||||
size="sm"
|
||||
onClick={handleDownloadLogs}
|
||||
>
|
||||
<FaDownload className="text-secondary-foreground" />
|
||||
<div className="hidden text-primary md:block">Download</div>
|
||||
<div className="hidden text-primary md:block">
|
||||
{t("button.download", { ns: "common" })}
|
||||
</div>
|
||||
</Button>
|
||||
<LogSettingsButton
|
||||
selectedLabels={filterSeverity}
|
||||
@@ -527,8 +536,10 @@ function Logs() {
|
||||
<div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
|
||||
<div className="col-span-3 lg:col-span-2">
|
||||
<div className="flex w-full flex-row items-center">
|
||||
<div className="ml-1 min-w-16 capitalize lg:min-w-20">Type</div>
|
||||
<div className="mr-3">Timestamp</div>
|
||||
<div className="ml-1 min-w-16 capitalize lg:min-w-20">
|
||||
{t("logs.type.label")}
|
||||
</div>
|
||||
<div className="mr-3">{t("logs.type.timestamp")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -537,7 +548,7 @@ function Logs() {
|
||||
logService == "frigate" ? "col-span-2" : "col-span-1",
|
||||
)}
|
||||
>
|
||||
Tag
|
||||
{t("logs.type.tag")}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -547,7 +558,7 @@ function Logs() {
|
||||
: "md:col-span-8 lg:col-span-9",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1">Message</div>
|
||||
<div className="flex flex-1">{t("logs.type.message")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -566,9 +577,7 @@ function Logs() {
|
||||
<TooltipTrigger>
|
||||
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Logs are streaming from the server
|
||||
</TooltipContent>
|
||||
<TooltipContent>{t("logs.tips")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function NoMatch() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
useEffect(() => {
|
||||
document.title = "Not Found - Frigate";
|
||||
}, []);
|
||||
document.title = t("notFound.documentTitle");
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading as="h2">404</Heading>
|
||||
<p>Page not found</p>
|
||||
<Heading as="h2">{t("notFound.title")}</Heading>
|
||||
<p>{t("notFound.desc")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+42
-33
@@ -43,13 +43,14 @@ import { useInitialCameraState } from "@/api/ws";
|
||||
import { isInIframe } from "@/utils/isIFrame";
|
||||
import { isPWA } from "@/utils/isPWA";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const allSettingsViews = [
|
||||
"UI settings",
|
||||
"classification settings",
|
||||
"camera settings",
|
||||
"masks / zones",
|
||||
"motion tuner",
|
||||
"uiSettings",
|
||||
"classificationSettings",
|
||||
"cameraSettings",
|
||||
"masksAndZones",
|
||||
"motionTuner",
|
||||
"debug",
|
||||
"users",
|
||||
"notifications",
|
||||
@@ -57,7 +58,8 @@ const allSettingsViews = [
|
||||
type SettingsType = (typeof allSettingsViews)[number];
|
||||
|
||||
export default function Settings() {
|
||||
const [page, setPage] = useState<SettingsType>("UI settings");
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [page, setPage] = useState<SettingsType>("uiSettings");
|
||||
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -69,7 +71,7 @@ export default function Settings() {
|
||||
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const allowedViewsForViewer: SettingsType[] = ["UI settings", "debug"];
|
||||
const allowedViewsForViewer: SettingsType[] = ["uiSettings", "debug"];
|
||||
const visibleSettingsViews = !isAdmin
|
||||
? allowedViewsForViewer
|
||||
: allSettingsViews;
|
||||
@@ -133,7 +135,7 @@ export default function Settings() {
|
||||
setSelectedCamera(firstEnabledCamera.name);
|
||||
} else if (
|
||||
!cameraEnabledStates[selectedCamera] &&
|
||||
page !== "camera settings"
|
||||
page !== "cameraSettings"
|
||||
) {
|
||||
// Switch to first enabled camera if current one is disabled, unless on "camera settings" page
|
||||
const firstEnabledCamera =
|
||||
@@ -163,8 +165,8 @@ export default function Settings() {
|
||||
useSearchEffect("page", (page: string) => {
|
||||
if (allSettingsViews.includes(page as SettingsType)) {
|
||||
// Restrict viewer to UI settings
|
||||
if (!isAdmin && !["UI settings", "debug"].includes(page)) {
|
||||
setPage("UI settings");
|
||||
if (!isAdmin && !["uiSettings", "debug"].includes(page)) {
|
||||
setPage("uiSettings");
|
||||
} else {
|
||||
setPage(page as SettingsType);
|
||||
}
|
||||
@@ -183,8 +185,8 @@ export default function Settings() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Settings - Frigate";
|
||||
}, []);
|
||||
document.title = t("documentTitle.default");
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col p-2">
|
||||
@@ -199,8 +201,8 @@ export default function Settings() {
|
||||
onValueChange={(value: SettingsType) => {
|
||||
if (value) {
|
||||
// Restrict viewer navigation
|
||||
if (!isAdmin && !["UI settings", "debug"].includes(value)) {
|
||||
setPageToggle("UI settings");
|
||||
if (!isAdmin && !["uiSettings", "debug"].includes(value)) {
|
||||
setPageToggle("uiSettings");
|
||||
} else {
|
||||
setPageToggle(value);
|
||||
}
|
||||
@@ -210,12 +212,15 @@ export default function Settings() {
|
||||
{visibleSettingsViews.map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item}
|
||||
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "UI settings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "uiSettings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
value={item}
|
||||
data-nav-item={item}
|
||||
aria-label={`Select ${item}`}
|
||||
aria-label={t("selectItem", {
|
||||
item: t("menu." + item),
|
||||
ns: "common",
|
||||
})}
|
||||
>
|
||||
<div className="capitalize">{item}</div>
|
||||
<div className="capitalize">{t("menu." + item)}</div>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
@@ -223,11 +228,11 @@ export default function Settings() {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{(page == "debug" ||
|
||||
page == "camera settings" ||
|
||||
page == "masks / zones" ||
|
||||
page == "motion tuner") && (
|
||||
page == "cameraSettings" ||
|
||||
page == "masksAndZones" ||
|
||||
page == "motionTuner") && (
|
||||
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
||||
{page == "masks / zones" && (
|
||||
{page == "masksAndZones" && (
|
||||
<ZoneMaskFilterButton
|
||||
selectedZoneMask={filterZoneMask}
|
||||
updateZoneMaskFilter={setFilterZoneMask}
|
||||
@@ -244,27 +249,27 @@ export default function Settings() {
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
|
||||
{page == "UI settings" && <UiSettingsView />}
|
||||
{page == "classification settings" && (
|
||||
{page == "uiSettings" && <UiSettingsView />}
|
||||
{page == "classificationSettings" && (
|
||||
<ClassificationSettingsView setUnsavedChanges={setUnsavedChanges} />
|
||||
)}
|
||||
{page == "debug" && (
|
||||
<ObjectSettingsView selectedCamera={selectedCamera} />
|
||||
)}
|
||||
{page == "camera settings" && (
|
||||
{page == "cameraSettings" && (
|
||||
<CameraSettingsView
|
||||
selectedCamera={selectedCamera}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
/>
|
||||
)}
|
||||
{page == "masks / zones" && (
|
||||
{page == "masksAndZones" && (
|
||||
<MasksAndZonesView
|
||||
selectedCamera={selectedCamera}
|
||||
selectedZoneMask={filterZoneMask}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
/>
|
||||
)}
|
||||
{page == "motion tuner" && (
|
||||
{page == "motionTuner" && (
|
||||
<MotionTunerView
|
||||
selectedCamera={selectedCamera}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
@@ -282,17 +287,19 @@ export default function Settings() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>You have unsaved changes.</AlertDialogTitle>
|
||||
<AlertDialogTitle>
|
||||
{t("dialog.unsavedChanges.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Do you want to save your changes before continuing?
|
||||
{t("dialog.unsavedChanges.desc")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => handleDialog(false)}>
|
||||
Cancel
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleDialog(true)}>
|
||||
Save
|
||||
{t("button.save", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -317,6 +324,8 @@ function CameraSelectButton({
|
||||
cameraEnabledStates,
|
||||
currentPage,
|
||||
}: CameraSelectButtonProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!allCameras.length) {
|
||||
@@ -332,7 +341,7 @@ function CameraSelectButton({
|
||||
<FaVideo className="text-background dark:text-primary" />
|
||||
<div className="hidden text-background dark:text-primary md:block">
|
||||
{selectedCamera == undefined
|
||||
? "No Camera"
|
||||
? t("cameraSetting.noCamera")
|
||||
: selectedCamera.replaceAll("_", " ")}
|
||||
</div>
|
||||
</Button>
|
||||
@@ -342,7 +351,7 @@ function CameraSelectButton({
|
||||
{isMobile && (
|
||||
<>
|
||||
<DropdownMenuLabel className="flex justify-center">
|
||||
Camera
|
||||
{t("cameraSetting.camera")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
@@ -351,7 +360,7 @@ function CameraSelectButton({
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{allCameras.map((item) => {
|
||||
const isEnabled = cameraEnabledStates[item.name];
|
||||
const isCameraSettingsPage = currentPage === "camera settings";
|
||||
const isCameraSettingsPage = currentPage === "cameraSettings";
|
||||
return (
|
||||
<FilterSwitch
|
||||
key={item.name}
|
||||
|
||||
@@ -12,15 +12,16 @@ import Logo from "@/components/Logo";
|
||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||
import CameraMetrics from "@/views/system/CameraMetrics";
|
||||
import { useHashState } from "@/hooks/use-overlay-state";
|
||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import FeatureMetrics from "@/views/system/FeatureMetrics";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const allMetrics = ["general", "features", "storage", "cameras"] as const;
|
||||
type SystemMetric = (typeof allMetrics)[number];
|
||||
|
||||
function System() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
@@ -52,9 +53,9 @@ function System() {
|
||||
|
||||
useEffect(() => {
|
||||
if (pageToggle) {
|
||||
document.title = `${capitalizeFirstLetter(pageToggle)} Stats - Frigate`;
|
||||
document.title = t("documentTitle." + pageToggle);
|
||||
}
|
||||
}, [pageToggle]);
|
||||
}, [pageToggle, t]);
|
||||
|
||||
// stats collection
|
||||
|
||||
@@ -91,7 +92,9 @@ function System() {
|
||||
{item == "features" && <LuSearchCode className="size-4" />}
|
||||
{item == "storage" && <LuHardDrive className="size-4" />}
|
||||
{item == "cameras" && <FaVideo className="size-4" />}
|
||||
{isDesktop && <div className="capitalize">{item}</div>}
|
||||
{isDesktop && (
|
||||
<div className="capitalize">{t(item + ".title")}</div>
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
@@ -99,13 +102,14 @@ function System() {
|
||||
<div className="flex h-full items-center">
|
||||
{lastUpdated && (
|
||||
<div className="h-full content-center text-sm text-muted-foreground">
|
||||
Last refreshed: <TimeAgo time={lastUpdated * 1000} dense />
|
||||
{t("lastRefreshed")}
|
||||
<TimeAgo time={lastUpdated * 1000} dense />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-end gap-2">
|
||||
<div className="h-full content-center font-medium">System</div>
|
||||
<div className="h-full content-center font-medium">{t("title")}</div>
|
||||
{statsSnapshot && (
|
||||
<div className="h-full content-center text-sm text-muted-foreground">
|
||||
{statsSnapshot.service.version}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { t } from "i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function shareOrCopy(url: string, title?: string) {
|
||||
@@ -9,7 +10,7 @@ export function shareOrCopy(url: string, title?: string) {
|
||||
});
|
||||
} else {
|
||||
copy(url);
|
||||
toast.success("Copied URL to clipboard.", {
|
||||
toast.success(t("toast.copyUrlToClipboard"), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import HttpBackend from "i18next-http-backend";
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.use(HttpBackend)
|
||||
.init({
|
||||
fallbackLng: "en", // use en if detected lng is not available
|
||||
|
||||
backend: {
|
||||
loadPath: "/locales/{{lng}}/{{ns}}.json",
|
||||
},
|
||||
|
||||
ns: [
|
||||
"common",
|
||||
"objects",
|
||||
"audio",
|
||||
"components/camera",
|
||||
"components/dialog",
|
||||
"components/filter",
|
||||
"components/icons",
|
||||
"components/player",
|
||||
"views/events",
|
||||
"views/explore",
|
||||
"views/live",
|
||||
"views/settings",
|
||||
"views/system",
|
||||
"views/exports",
|
||||
"views/explore",
|
||||
],
|
||||
defaultNS: "common",
|
||||
|
||||
react: {
|
||||
transSupportBasicHtmlNodes: true,
|
||||
transKeepBasicHtmlNodesFor: [
|
||||
"br",
|
||||
"strong",
|
||||
"i",
|
||||
"em",
|
||||
"li",
|
||||
"p",
|
||||
"code",
|
||||
"span",
|
||||
"p",
|
||||
"ul",
|
||||
"li",
|
||||
"ol",
|
||||
],
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false, // react already safes from xss
|
||||
},
|
||||
keySeparator: ".",
|
||||
parseMissingKeyHandler: (key: string) => {
|
||||
const parts = key.split(".");
|
||||
|
||||
// Handle special cases for objects and audio
|
||||
if (parts[0] === "object" || parts[0] === "audio") {
|
||||
return (
|
||||
parts[1]
|
||||
?.split("_")
|
||||
.map(
|
||||
(word) =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
|
||||
)
|
||||
.join(" ") || key
|
||||
);
|
||||
}
|
||||
|
||||
// For nested keys, try to make them more readable
|
||||
if (parts.length > 1) {
|
||||
const lastPart = parts[parts.length - 1];
|
||||
return lastPart
|
||||
.split("_")
|
||||
.map(
|
||||
(word) =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
// For single keys, just capitalize and format
|
||||
return key
|
||||
.split("_")
|
||||
.map(
|
||||
(word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
|
||||
)
|
||||
.join(" ");
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||
import { t } from "i18next";
|
||||
|
||||
export function getLifecycleItemDescription(
|
||||
lifecycleItem: ObjectLifecycleSequence,
|
||||
) {
|
||||
// can't use useTranslation here
|
||||
const label = (
|
||||
(Array.isArray(lifecycleItem.data.sub_label)
|
||||
? lifecycleItem.data.sub_label[0]
|
||||
@@ -11,37 +13,63 @@ export function getLifecycleItemDescription(
|
||||
|
||||
switch (lifecycleItem.class_type) {
|
||||
case "visible":
|
||||
return `${label} detected`;
|
||||
return t("objectLifecycle.lifecycleItemDesc.visible", {
|
||||
label,
|
||||
ns: "views/explore",
|
||||
});
|
||||
case "entered_zone":
|
||||
return `${label} entered ${lifecycleItem.data.zones
|
||||
.join(" and ")
|
||||
.replaceAll("_", " ")}`;
|
||||
return t("objectLifecycle.lifecycleItemDesc.entered_zone", {
|
||||
label,
|
||||
ns: "views/explore",
|
||||
zones: lifecycleItem.data.zones.join(" and ").replaceAll("_", " "),
|
||||
});
|
||||
case "active":
|
||||
return `${label} became active`;
|
||||
return t("objectLifecycle.lifecycleItemDesc.active", {
|
||||
label,
|
||||
ns: "views/explore",
|
||||
});
|
||||
case "stationary":
|
||||
return `${label} became stationary`;
|
||||
return t("objectLifecycle.lifecycleItemDesc.stationary", {
|
||||
label,
|
||||
ns: "views/explore",
|
||||
});
|
||||
case "attribute": {
|
||||
let title = "";
|
||||
if (
|
||||
lifecycleItem.data.attribute == "face" ||
|
||||
lifecycleItem.data.attribute == "license_plate"
|
||||
) {
|
||||
title = `${lifecycleItem.data.attribute.replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
)} detected for ${label}`;
|
||||
title = t(
|
||||
"objectLifecycle.lifecycleItemDesc.attribute.faceOrLicense_plate",
|
||||
{
|
||||
label,
|
||||
attribute: lifecycleItem.data.attribute.replaceAll("_", " "),
|
||||
ns: "views/explore",
|
||||
},
|
||||
);
|
||||
} else {
|
||||
title = `${
|
||||
lifecycleItem.data.label
|
||||
} recognized as ${lifecycleItem.data.attribute.replaceAll("_", " ")}`;
|
||||
title = t("objectLifecycle.lifecycleItemDesc.attribute.other", {
|
||||
label: lifecycleItem.data.label,
|
||||
attribute: lifecycleItem.data.attribute.replaceAll("_", " "),
|
||||
ns: "views/explore",
|
||||
});
|
||||
}
|
||||
return title;
|
||||
}
|
||||
case "gone":
|
||||
return `${label} left`;
|
||||
return t("objectLifecycle.lifecycleItemDesc.gone", {
|
||||
label,
|
||||
ns: "views/explore",
|
||||
});
|
||||
case "heard":
|
||||
return `${label} heard`;
|
||||
return t("objectLifecycle.lifecycleItemDesc.heard", {
|
||||
label,
|
||||
ns: "views/explore",
|
||||
});
|
||||
case "external":
|
||||
return `${label} detected`;
|
||||
return t("objectLifecycle.lifecycleItemDesc.external", {
|
||||
label,
|
||||
ns: "views/explore",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,9 @@ import { FilterList, LAST_24_HOURS_KEY } from "@/types/filter";
|
||||
import { GiSoundWaves } from "react-icons/gi";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import ReviewDetailDialog from "@/components/overlay/detail/ReviewDetailDialog";
|
||||
|
||||
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type EventViewProps = {
|
||||
reviewItems?: SegmentedReviewData;
|
||||
@@ -94,6 +96,7 @@ export default function EventView({
|
||||
pullLatestData,
|
||||
updateFilter,
|
||||
}: EventViewProps) {
|
||||
const { t } = useTranslation(["views/events"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -198,8 +201,10 @@ export default function EventView({
|
||||
.then((response) => {
|
||||
if (response.status == 200) {
|
||||
toast.success(
|
||||
"Successfully started export. View the file in the /exports folder.",
|
||||
{ position: "top-center" },
|
||||
t("export.toast.success", { ns: "components/dialog" }),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
@@ -208,12 +213,18 @@ export default function EventView({
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to start export: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("export.toast.error.failed", {
|
||||
ns: "components/dialog",
|
||||
message: errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
[reviewItems],
|
||||
[reviewItems, t],
|
||||
);
|
||||
|
||||
const [motionOnly, setMotionOnly] = useState(false);
|
||||
@@ -266,7 +277,7 @@ export default function EventView({
|
||||
<ToggleGroupItem
|
||||
className={cn(severityToggle != "alert" && "text-muted-foreground")}
|
||||
value="alert"
|
||||
aria-label="Select alerts"
|
||||
aria-label={t("alerts")}
|
||||
>
|
||||
{isMobileOnly ? (
|
||||
<div
|
||||
@@ -285,7 +296,7 @@ export default function EventView({
|
||||
<>
|
||||
<MdCircle className="size-2 text-severity_alert md:mr-[10px]" />
|
||||
<div className="hidden md:flex md:flex-row md:items-center">
|
||||
Alerts
|
||||
{t("alerts")}
|
||||
{reviewCounts.alert > -1 ? (
|
||||
` ∙ ${reviewCounts.alert}`
|
||||
) : (
|
||||
@@ -300,7 +311,7 @@ export default function EventView({
|
||||
severityToggle != "detection" && "text-muted-foreground",
|
||||
)}
|
||||
value="detection"
|
||||
aria-label="Select detections"
|
||||
aria-label={t("detections")}
|
||||
>
|
||||
{isMobileOnly ? (
|
||||
<div
|
||||
@@ -321,7 +332,7 @@ export default function EventView({
|
||||
<>
|
||||
<MdCircle className="size-2 text-severity_detection md:mr-[10px]" />
|
||||
<div className="hidden md:flex md:flex-row md:items-center">
|
||||
Detections
|
||||
{t("detections")}
|
||||
{reviewCounts.detection > -1 ? (
|
||||
` ∙ ${reviewCounts.detection}`
|
||||
) : (
|
||||
@@ -337,14 +348,14 @@ export default function EventView({
|
||||
severityToggle != "significant_motion" && "text-muted-foreground",
|
||||
)}
|
||||
value="significant_motion"
|
||||
aria-label="Select motion"
|
||||
aria-label={t("motion.label")}
|
||||
>
|
||||
{isMobileOnly ? (
|
||||
<GiSoundWaves className="size-6 rotate-90 text-severity_significant_motion" />
|
||||
) : (
|
||||
<>
|
||||
<MdCircle className="size-2 text-severity_significant_motion md:mr-[10px]" />
|
||||
<div className="hidden md:block">Motion</div>
|
||||
<div className="hidden md:block">{t("motion.label")}</div>
|
||||
</>
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
@@ -461,6 +472,8 @@ function DetectionReview({
|
||||
setSelectedReviews,
|
||||
pullLatestData,
|
||||
}: DetectionReviewProps) {
|
||||
const { t } = useTranslation(["views/events"]);
|
||||
|
||||
const reviewTimelineRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// detail
|
||||
@@ -712,7 +725,7 @@ function DetectionReview({
|
||||
{!loading && currentItems?.length === 0 && (
|
||||
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
|
||||
<LuFolderCheck className="size-16" />
|
||||
There are no {severity.replace(/_/g, " ")}s to review
|
||||
{t("empty." + severity.replace(/_/g, " "))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -779,14 +792,14 @@ function DetectionReview({
|
||||
<div className="col-span-full flex items-center justify-center">
|
||||
<Button
|
||||
className="text-white"
|
||||
aria-label="Mark these items as reviewed"
|
||||
aria-label={t("markTheseItemsAsReviewed")}
|
||||
variant="select"
|
||||
onClick={() => {
|
||||
setSelectedReviews([]);
|
||||
markAllItemsAsReviewed(currentItems ?? []);
|
||||
}}
|
||||
>
|
||||
Mark these items as reviewed
|
||||
{t("markTheseItemsAsReviewed")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -862,6 +875,7 @@ function MotionReview({
|
||||
motionOnly = false,
|
||||
onOpenRecording,
|
||||
}: MotionReviewProps) {
|
||||
const { t } = useTranslation(["views/events"]);
|
||||
const segmentDuration = 30;
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
@@ -1051,7 +1065,7 @@ function MotionReview({
|
||||
return (
|
||||
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
|
||||
<LuFolderX className="size-16" />
|
||||
No motion data found
|
||||
{t("empty.motion")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import TimeAgo from "@/components/dynamic/TimeAgo";
|
||||
import SearchResultActions from "@/components/menu/SearchResultActions";
|
||||
import { SearchTab } from "@/components/overlay/detail/SearchDetailDialog";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ExploreViewProps = {
|
||||
searchDetail: SearchResult | undefined;
|
||||
@@ -35,11 +36,12 @@ export default function ExploreView({
|
||||
setSimilaritySearch,
|
||||
onSelectSearch,
|
||||
}: ExploreViewProps) {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
// title
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Explore - Frigate";
|
||||
}, []);
|
||||
document.title = t("documentTitle");
|
||||
}, [t]);
|
||||
|
||||
// data
|
||||
|
||||
@@ -137,6 +139,7 @@ function ThumbnailRow({
|
||||
setSimilaritySearch,
|
||||
onSelectSearch,
|
||||
}: ThumbnailRowType) {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSearch = (label: string) => {
|
||||
@@ -149,15 +152,13 @@ function ThumbnailRow({
|
||||
return (
|
||||
<div className="rounded-lg bg-background_alt p-2 md:px-4">
|
||||
<div className="flex flex-row items-center text-lg capitalize">
|
||||
{objectType.replaceAll("_", " ")}
|
||||
{t(objectType, { ns: "objects" })}
|
||||
{searchResults && (
|
||||
<span className="ml-3 text-sm text-secondary-foreground">
|
||||
(
|
||||
{
|
||||
{t("trackedObjectsCount", {
|
||||
// @ts-expect-error we know this is correct
|
||||
searchResults[0].event_count
|
||||
}{" "}
|
||||
tracked objects){" "}
|
||||
count: searchResults[0].event_count,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{isValidating && <ActivityIndicator className="ml-2 size-4" />}
|
||||
@@ -225,7 +226,7 @@ function ExploreThumbnailImage({
|
||||
};
|
||||
|
||||
const handleShowObjectLifecycle = () => {
|
||||
onSelectSearch(event, false, "object lifecycle");
|
||||
onSelectSearch(event, false, "object_lifecycle");
|
||||
};
|
||||
|
||||
const handleShowSnapshot = () => {
|
||||
|
||||
@@ -50,6 +50,7 @@ import { Toaster } from "@/components/ui/sonner";
|
||||
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
|
||||
import LiveContextMenu from "@/components/menu/LiveContextMenu";
|
||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type DraggableGridLayoutProps = {
|
||||
cameras: CameraConfig[];
|
||||
@@ -79,6 +80,7 @@ export default function DraggableGridLayout({
|
||||
fullscreen,
|
||||
toggleFullscreen,
|
||||
}: DraggableGridLayoutProps) {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
||||
|
||||
@@ -658,7 +660,9 @@ export default function DraggableGridLayout({
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isEditMode ? "Exit Editing" : "Edit Layout"}
|
||||
{isEditMode
|
||||
? t("editLayout.exitEdit")
|
||||
: t("editLayout.label")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{!isEditMode && (
|
||||
@@ -676,7 +680,9 @@ export default function DraggableGridLayout({
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isEditMode ? "Exit Editing" : "Edit Camera Group"}
|
||||
{isEditMode
|
||||
? t("editLayout.exitEdit")
|
||||
: t("editLayout.group.label")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -694,7 +700,9 @@ export default function DraggableGridLayout({
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{fullscreen ? "Exit Fullscreen" : "Fullscreen"}
|
||||
{fullscreen
|
||||
? t("button.exitFullscreen", { ns: "common" })
|
||||
: t("button.fullscreen", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
isSafari,
|
||||
useMobileOrientation,
|
||||
} from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaCompress, FaExpand } from "react-icons/fa";
|
||||
import { IoMdArrowBack } from "react-icons/io";
|
||||
import { LuPictureInPicture } from "react-icons/lu";
|
||||
@@ -32,6 +33,7 @@ export default function LiveBirdseyeView({
|
||||
fullscreen,
|
||||
toggleFullscreen,
|
||||
}: LiveBirdseyeViewProps) {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const navigate = useNavigate();
|
||||
const { isPortrait } = useMobileOrientation();
|
||||
@@ -144,12 +146,16 @@ export default function LiveBirdseyeView({
|
||||
{!fullscreen ? (
|
||||
<Button
|
||||
className={`flex items-center gap-2 rounded-lg ${isMobile ? "ml-2" : "ml-0"}`}
|
||||
aria-label="Go Back"
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
size={isMobile ? "icon" : "sm"}
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<IoMdArrowBack className="size-5" />
|
||||
{isDesktop && <div className="text-primary">Back</div>}
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
@@ -164,7 +170,11 @@ export default function LiveBirdseyeView({
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={fullscreen ? FaCompress : FaExpand}
|
||||
isActive={fullscreen}
|
||||
title={fullscreen ? "Close" : "Fullscreen"}
|
||||
title={
|
||||
fullscreen
|
||||
? t("button.close", { ns: "common" })
|
||||
: t("button.fullscreen", { ns: "common" })
|
||||
}
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
)}
|
||||
@@ -174,7 +184,11 @@ export default function LiveBirdseyeView({
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={LuPictureInPicture}
|
||||
isActive={pip}
|
||||
title={pip ? "Close" : "Picture in Picture"}
|
||||
title={
|
||||
pip
|
||||
? t("button.close", { ns: "common" })
|
||||
: t("button.pictureInPicture", { ns: "common" })
|
||||
}
|
||||
onClick={() => {
|
||||
if (!pip) {
|
||||
setPip(true);
|
||||
|
||||
@@ -103,6 +103,7 @@ import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
|
||||
import useSWR from "swr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSessionPersistence } from "@/hooks/use-session-persistence";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -117,6 +118,7 @@ import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
type LiveCameraViewProps = {
|
||||
config?: FrigateConfig;
|
||||
@@ -132,6 +134,7 @@ export default function LiveCameraView({
|
||||
fullscreen,
|
||||
toggleFullscreen,
|
||||
}: LiveCameraViewProps) {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
const navigate = useNavigate();
|
||||
const { isPortrait } = useMobileOrientation();
|
||||
const mainRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -427,16 +430,20 @@ export default function LiveCameraView({
|
||||
>
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
aria-label="Go back"
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && <div className="text-primary">Back</div>}
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
aria-label="Show historical footage"
|
||||
aria-label={t("history.label")}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigate("review", {
|
||||
@@ -452,7 +459,11 @@ export default function LiveCameraView({
|
||||
}}
|
||||
>
|
||||
<LuHistory className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && <div className="text-primary">History</div>}
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("button.history", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -465,13 +476,15 @@ export default function LiveCameraView({
|
||||
{fullscreen && (
|
||||
<Button
|
||||
className="bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-primary"
|
||||
aria-label="Go back"
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-secondary-foreground">Back</div>
|
||||
<div className="text-secondary-foreground">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
@@ -481,7 +494,11 @@ export default function LiveCameraView({
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={fullscreen ? FaCompress : FaExpand}
|
||||
isActive={fullscreen}
|
||||
title={fullscreen ? "Close" : "Fullscreen"}
|
||||
title={
|
||||
fullscreen
|
||||
? t("button.close", { ns: "common" })
|
||||
: t("button.fullscreen", { ns: "common" })
|
||||
}
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
)}
|
||||
@@ -491,7 +508,11 @@ export default function LiveCameraView({
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={LuPictureInPicture}
|
||||
isActive={pip}
|
||||
title={pip ? "Close" : "Picture in Picture"}
|
||||
title={
|
||||
pip
|
||||
? t("button.close", { ns: "common" })
|
||||
: t("button.pictureInPicture", { ns: "common" })
|
||||
}
|
||||
onClick={() => {
|
||||
if (!pip) {
|
||||
setPip(true);
|
||||
@@ -667,6 +688,7 @@ function PtzControlPanel({
|
||||
clickOverlay: boolean;
|
||||
setClickOverlay: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
const { data: ptz } = useSWR<CameraPtzInfo>(`${camera}/ptz/info`);
|
||||
|
||||
const { send: sendPtz } = usePtzCommand(camera);
|
||||
@@ -752,7 +774,7 @@ function PtzControlPanel({
|
||||
{ptz?.features?.includes("pt") && (
|
||||
<>
|
||||
<TooltipButton
|
||||
label="Move camera left"
|
||||
label={t("ptz.move.left.label")}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendPtz("MOVE_LEFT");
|
||||
@@ -767,7 +789,7 @@ function PtzControlPanel({
|
||||
<FaAngleLeft />
|
||||
</TooltipButton>
|
||||
<TooltipButton
|
||||
label="Move camera up"
|
||||
label={t("ptz.move.up.label")}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendPtz("MOVE_UP");
|
||||
@@ -782,7 +804,7 @@ function PtzControlPanel({
|
||||
<FaAngleUp />
|
||||
</TooltipButton>
|
||||
<TooltipButton
|
||||
label="Move camera down"
|
||||
label={t("ptz.move.down.label")}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendPtz("MOVE_DOWN");
|
||||
@@ -797,7 +819,7 @@ function PtzControlPanel({
|
||||
<FaAngleDown />
|
||||
</TooltipButton>
|
||||
<TooltipButton
|
||||
label="Move camera right"
|
||||
label={t("ptz.move.right.label")}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendPtz("MOVE_RIGHT");
|
||||
@@ -816,7 +838,7 @@ function PtzControlPanel({
|
||||
{ptz?.features?.includes("zoom") && (
|
||||
<>
|
||||
<TooltipButton
|
||||
label="Zoom in"
|
||||
label={t("ptz.zoom.in.label")}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendPtz("ZOOM_IN");
|
||||
@@ -831,7 +853,7 @@ function PtzControlPanel({
|
||||
<MdZoomIn />
|
||||
</TooltipButton>
|
||||
<TooltipButton
|
||||
label="Zoom out"
|
||||
label={t("ptz.zoom.out.label")}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendPtz("ZOOM_OUT");
|
||||
@@ -854,14 +876,19 @@ function PtzControlPanel({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={`${clickOverlay ? "text-selected" : "text-primary"}`}
|
||||
aria-label="Click in the frame to center the camera"
|
||||
aria-label={t("ptz.move.clickMove.label")}
|
||||
onClick={() => setClickOverlay(!clickOverlay)}
|
||||
>
|
||||
<TbViewfinder />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{clickOverlay ? "Disable" : "Enable"} click to move</p>
|
||||
<p>
|
||||
{clickOverlay
|
||||
? t("ptz.move.clickMove.disable")
|
||||
: t("ptz.move.clickMove.enable")}{" "}
|
||||
click to move
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -872,7 +899,7 @@ function PtzControlPanel({
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenu modal={!isDesktop}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-label="PTZ camera presets">
|
||||
<Button aria-label={t("ptz.presets")}>
|
||||
<BsThreeDotsVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -894,7 +921,7 @@ function PtzControlPanel({
|
||||
</DropdownMenu>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>PTZ camera presets</p>
|
||||
<p>{t("ptz.presets")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -904,6 +931,7 @@ function PtzControlPanel({
|
||||
}
|
||||
|
||||
function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
const rankMap = { all: 0, motion: 1, active_objects: 2 };
|
||||
const getValidMode = (retain?: { mode?: string }): keyof typeof rankMap => {
|
||||
const mode = retain?.mode;
|
||||
@@ -918,13 +946,25 @@ function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
|
||||
? recordRetainMode
|
||||
: alertsRetainMode;
|
||||
|
||||
const source = effectiveRetainMode === recordRetainMode ? "camera" : "alerts";
|
||||
const source =
|
||||
effectiveRetainMode === recordRetainMode
|
||||
? t("camera", { ns: "views/events" })
|
||||
: t("alerts", { ns: "views/events" });
|
||||
|
||||
return effectiveRetainMode !== "all" ? (
|
||||
<div>
|
||||
Your {source} recording retention configuration is set to{" "}
|
||||
<code>mode: {effectiveRetainMode}</code>, so this on-demand recording will
|
||||
only keep segments with {effectiveRetainMode.replaceAll("_", " ")}.
|
||||
<Trans
|
||||
ns="views/live"
|
||||
values={{
|
||||
source,
|
||||
effectiveRetainMode,
|
||||
effectiveRetainModeName: t(
|
||||
"effectiveRetainMode.modes." + effectiveRetainMode,
|
||||
),
|
||||
}}
|
||||
>
|
||||
effectiveRetainMode.notAllTips
|
||||
</Trans>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
@@ -967,6 +1007,8 @@ function FrigateCameraFeatures({
|
||||
supports2WayTalk,
|
||||
cameraEnabled,
|
||||
}: FrigateCameraFeaturesProps) {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
|
||||
const { payload: detectState, send: sendDetect } = useDetectState(
|
||||
camera.name,
|
||||
);
|
||||
@@ -1010,14 +1052,9 @@ function FrigateCameraFeatures({
|
||||
setIsRecording(true);
|
||||
const toastId = toast.success(
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="font-semibold">
|
||||
Started manual on-demand recording.
|
||||
</div>
|
||||
<div className="font-semibold">{t("manualRecording.started")}</div>
|
||||
{!camera.record.enabled || camera.record.alerts.retain.days == 0 ? (
|
||||
<div>
|
||||
Since recording is disabled or restricted in the config for this
|
||||
camera, only a snapshot will be saved.
|
||||
</div>
|
||||
<div>{t("manualRecording.recordDisabledTips")}</div>
|
||||
) : (
|
||||
<OnDemandRetentionMessage camera={camera} />
|
||||
)}
|
||||
@@ -1030,11 +1067,11 @@ function FrigateCameraFeatures({
|
||||
setActiveToastId(toastId);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to start manual on-demand recording.", {
|
||||
toast.error(t("manualRecording.failedToStart"), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
}, [camera]);
|
||||
}, [camera, t]);
|
||||
|
||||
const endEvent = useCallback(() => {
|
||||
if (activeToastId) {
|
||||
@@ -1047,16 +1084,16 @@ function FrigateCameraFeatures({
|
||||
});
|
||||
recordingEventIdRef.current = null;
|
||||
setIsRecording(false);
|
||||
toast.success("Ended manual on-demand recording.", {
|
||||
toast.success(t("manualRecording.ended"), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to end manual on-demand recording.", {
|
||||
toast.error(t("manualRecording.failedToEnd"), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
}, [activeToastId]);
|
||||
}, [activeToastId, t]);
|
||||
|
||||
const handleEventButtonClick = useCallback(() => {
|
||||
if (isRecording) {
|
||||
@@ -1092,7 +1129,9 @@ function FrigateCameraFeatures({
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={enabledState == "ON" ? LuPower : LuPowerOff}
|
||||
isActive={enabledState == "ON"}
|
||||
title={`${enabledState == "ON" ? "Disable" : "Enable"} Camera`}
|
||||
title={
|
||||
enabledState == "ON" ? t("camera.disable") : t("camera.enable")
|
||||
}
|
||||
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
|
||||
disabled={false}
|
||||
/>
|
||||
@@ -1101,7 +1140,9 @@ function FrigateCameraFeatures({
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={detectState == "ON" ? MdPersonSearch : MdPersonOff}
|
||||
isActive={detectState == "ON"}
|
||||
title={`${detectState == "ON" ? "Disable" : "Enable"} Detect`}
|
||||
title={
|
||||
detectState == "ON" ? t("detect.disable") : t("detect.enable")
|
||||
}
|
||||
onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
|
||||
disabled={!cameraEnabled}
|
||||
/>
|
||||
@@ -1110,7 +1151,11 @@ function FrigateCameraFeatures({
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={recordState == "ON" ? LuVideo : LuVideoOff}
|
||||
isActive={recordState == "ON"}
|
||||
title={`${recordState == "ON" ? "Disable" : "Enable"} Recording`}
|
||||
title={
|
||||
recordState == "ON"
|
||||
? t("recording.disable")
|
||||
: t("recording.enable")
|
||||
}
|
||||
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
|
||||
disabled={!cameraEnabled}
|
||||
/>
|
||||
@@ -1119,7 +1164,11 @@ function FrigateCameraFeatures({
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={snapshotState == "ON" ? MdPhotoCamera : MdNoPhotography}
|
||||
isActive={snapshotState == "ON"}
|
||||
title={`${snapshotState == "ON" ? "Disable" : "Enable"} Snapshots`}
|
||||
title={
|
||||
snapshotState == "ON"
|
||||
? t("snapshots.disable")
|
||||
: t("snapshots.enable")
|
||||
}
|
||||
onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")}
|
||||
disabled={!cameraEnabled}
|
||||
/>
|
||||
@@ -1129,7 +1178,11 @@ function FrigateCameraFeatures({
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={audioState == "ON" ? LuEar : LuEarOff}
|
||||
isActive={audioState == "ON"}
|
||||
title={`${audioState == "ON" ? "Disable" : "Enable"} Audio Detect`}
|
||||
title={
|
||||
audioState == "ON"
|
||||
? t("audioDetect.disable")
|
||||
: t("audioDetect.enable")
|
||||
}
|
||||
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
|
||||
disabled={!cameraEnabled}
|
||||
/>
|
||||
@@ -1142,7 +1195,11 @@ function FrigateCameraFeatures({
|
||||
autotrackingState == "ON" ? TbViewfinder : TbViewfinderOff
|
||||
}
|
||||
isActive={autotrackingState == "ON"}
|
||||
title={`${autotrackingState == "ON" ? "Disable" : "Enable"} Autotracking`}
|
||||
title={
|
||||
autotrackingState == "ON"
|
||||
? t("autotracking.disable")
|
||||
: t("autotracking.enable")
|
||||
}
|
||||
onClick={() =>
|
||||
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
@@ -1159,7 +1216,7 @@ function FrigateCameraFeatures({
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={isRecording ? TbRecordMail : TbRecordMailOff}
|
||||
isActive={isRecording}
|
||||
title={`${isRecording ? "Stop" : "Start"} on-demand recording`}
|
||||
title={t("manualRecording." + (isRecording ? "stop" : "start"))}
|
||||
onClick={handleEventButtonClick}
|
||||
disabled={!cameraEnabled}
|
||||
/>
|
||||
@@ -1180,20 +1237,29 @@ function FrigateCameraFeatures({
|
||||
<div className="flex flex-col gap-5 p-4">
|
||||
{!isRestreamed && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Stream</Label>
|
||||
<Label>
|
||||
{t("streaming.label", { ns: "components/dialog" })}
|
||||
</Label>
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>Restreaming is not enabled for this camera.</div>
|
||||
<div>
|
||||
{t("streaming.restreaming.NotEnabled", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
Set up go2rtc for additional live view options and audio
|
||||
for this camera.
|
||||
{t("streaming.restreaming.desc", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/live"
|
||||
@@ -1201,7 +1267,9 @@ function FrigateCameraFeatures({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
{t("streaming.restreaming.readTheDocumentation", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -1213,7 +1281,9 @@ function FrigateCameraFeatures({
|
||||
{isRestreamed &&
|
||||
Object.values(camera.live.streams).length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="streaming-method">Stream</Label>
|
||||
<Label htmlFor="streaming-method">
|
||||
{t("stream.title")}
|
||||
</Label>
|
||||
<Select
|
||||
value={streamName}
|
||||
onValueChange={(value) => {
|
||||
@@ -1248,22 +1318,23 @@ function FrigateCameraFeatures({
|
||||
{supportsAudioOutput ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>Audio is available for this stream</div>
|
||||
<div>{t("stream.audio.available")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>Audio is unavailable for this stream</div>
|
||||
<div>{t("stream.audio.unavailable")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
Audio must be output from your camera and
|
||||
configured in go2rtc for this stream.
|
||||
{t("stream.audio.tips")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/live"
|
||||
@@ -1271,7 +1342,7 @@ function FrigateCameraFeatures({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
{t("stream.audio.tips.documentation")}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -1288,26 +1359,23 @@ function FrigateCameraFeatures({
|
||||
{supports2WayTalk ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>
|
||||
Two-way talk is available for this stream
|
||||
</div>
|
||||
<div>{t("stream.twoWayTalk.available")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>
|
||||
Two-way talk is unavailable for this stream
|
||||
</div>
|
||||
<div>{t("stream.twoWayTalk.available")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
Your device must suppport the feature and
|
||||
WebRTC must be configured for two-way talk.
|
||||
{t("stream.twoWayTalk.tips")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/live/#webrtc-extra-configuration"
|
||||
@@ -1315,7 +1383,9 @@ function FrigateCameraFeatures({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
{t(
|
||||
"stream.twoWayTalk.tips.documentation",
|
||||
)}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -1332,20 +1402,19 @@ function FrigateCameraFeatures({
|
||||
<IoIosWarning className="mr-1 size-8 text-danger" />
|
||||
|
||||
<p className="text-sm">
|
||||
Live view is in low-bandwidth mode due to buffering
|
||||
or stream errors.
|
||||
{t("stream.lowBandwidth.tips")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
aria-label="Reset the stream"
|
||||
aria-label={t("stream.lowBandwidth.resetStream")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setLowBandwidth(false)}
|
||||
>
|
||||
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
|
||||
<div className="text-primary-variant">
|
||||
Reset stream
|
||||
{t("stream.lowBandwidth.resetStream")}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1359,7 +1428,7 @@ function FrigateCameraFeatures({
|
||||
className="mx-0 cursor-pointer text-primary"
|
||||
htmlFor="backgroundplay"
|
||||
>
|
||||
Play in background
|
||||
{t("stream.playInBackground.label")}
|
||||
</Label>
|
||||
<Switch
|
||||
className="ml-1"
|
||||
@@ -1371,8 +1440,7 @@ function FrigateCameraFeatures({
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enable this option to continue streaming when the player is
|
||||
hidden.
|
||||
{t("stream.playInBackground.tips")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1382,7 +1450,9 @@ function FrigateCameraFeatures({
|
||||
className="mx-0 cursor-pointer text-primary"
|
||||
htmlFor="showstats"
|
||||
>
|
||||
Show stream stats
|
||||
{t("streaming.showStats.label", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</Label>
|
||||
<Switch
|
||||
className="ml-1"
|
||||
@@ -1392,13 +1462,12 @@ function FrigateCameraFeatures({
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enable this option to show stream statistics as an overlay on
|
||||
the camera feed.
|
||||
{t("streaming.showStats.desc", { ns: "components/dialog" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-sm font-medium leading-none">
|
||||
Debug View
|
||||
{t("streaming.debugView", { ns: "components/dialog" })}
|
||||
<LuExternalLink
|
||||
onClick={() =>
|
||||
navigate(`/settings?page=debug&camera=${camera.name}`)
|
||||
@@ -1427,7 +1496,7 @@ function FrigateCameraFeatures({
|
||||
variant="primary"
|
||||
Icon={FaCog}
|
||||
isActive={false}
|
||||
title={`${camera} Settings`}
|
||||
title={t("cameraSettings.title", { camera })}
|
||||
/>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="rounded-2xl px-2 py-4">
|
||||
@@ -1435,14 +1504,14 @@ function FrigateCameraFeatures({
|
||||
{isAdmin && (
|
||||
<>
|
||||
<FilterSwitch
|
||||
label="Camera Enabled"
|
||||
label={t("cameraSettings.cameraEnabled")}
|
||||
isChecked={enabledState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
<FilterSwitch
|
||||
label="Object Detection"
|
||||
label={t("cameraSettings.objectDetection")}
|
||||
isChecked={detectState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendDetect(detectState == "ON" ? "OFF" : "ON")
|
||||
@@ -1450,7 +1519,7 @@ function FrigateCameraFeatures({
|
||||
/>
|
||||
{recordingEnabled && (
|
||||
<FilterSwitch
|
||||
label="Recording"
|
||||
label={t("cameraSettings.recording")}
|
||||
isChecked={recordState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendRecord(recordState == "ON" ? "OFF" : "ON")
|
||||
@@ -1458,7 +1527,7 @@ function FrigateCameraFeatures({
|
||||
/>
|
||||
)}
|
||||
<FilterSwitch
|
||||
label="Snapshots"
|
||||
label={t("cameraSettings.snapshots")}
|
||||
isChecked={snapshotState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
|
||||
@@ -1466,7 +1535,7 @@ function FrigateCameraFeatures({
|
||||
/>
|
||||
{audioDetectEnabled && (
|
||||
<FilterSwitch
|
||||
label="Audio Detection"
|
||||
label={t("cameraSettings.audioDetection")}
|
||||
isChecked={audioState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAudio(audioState == "ON" ? "OFF" : "ON")
|
||||
@@ -1475,7 +1544,7 @@ function FrigateCameraFeatures({
|
||||
)}
|
||||
{autotrackingEnabled && (
|
||||
<FilterSwitch
|
||||
label="Autotracking"
|
||||
label={t("cameraSettings.autotracking")}
|
||||
isChecked={autotrackingState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
||||
@@ -1489,20 +1558,27 @@ function FrigateCameraFeatures({
|
||||
<div className="mt-3 flex flex-col gap-5">
|
||||
{!isRestreamed && (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<Label>Stream</Label>
|
||||
<Label>{t("streaming", { ns: "components/dialog" })}</Label>
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>Restreaming is not enabled for this camera.</div>
|
||||
<div>
|
||||
{t("streaming.restreaming.disabled", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
Set up go2rtc for additional live view options and audio for
|
||||
this camera.
|
||||
{t("streaming.restreaming.desc", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/live"
|
||||
@@ -1510,7 +1586,9 @@ function FrigateCameraFeatures({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
{t("streaming.restreaming.readTheDocumentation", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -1521,7 +1599,7 @@ function FrigateCameraFeatures({
|
||||
)}
|
||||
{isRestreamed && Object.values(camera.live.streams).length > 0 && (
|
||||
<div className="mt-1 p-2">
|
||||
<div className="mb-1 text-sm">Stream</div>
|
||||
<div className="mb-1 text-sm">{t("stream.title")}</div>
|
||||
<Select
|
||||
value={streamName}
|
||||
onValueChange={(value) => {
|
||||
@@ -1555,22 +1633,23 @@ function FrigateCameraFeatures({
|
||||
{supportsAudioOutput ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>Audio is available for this stream</div>
|
||||
<div>{t("stream.audio.available")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>Audio is unavailable for this stream</div>
|
||||
<div>{t("stream.audio.unavailable")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 text-xs">
|
||||
Audio must be output from your camera and configured
|
||||
in go2rtc for this stream.
|
||||
{t("stream.audio.tips")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/live"
|
||||
@@ -1578,7 +1657,7 @@ function FrigateCameraFeatures({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
{t("stream.audio.tips.documentation")}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -1595,22 +1674,23 @@ function FrigateCameraFeatures({
|
||||
{supports2WayTalk ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>Two-way talk is available for this stream</div>
|
||||
<div>{t("stream.twoWayTalk.available")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>Two-way talk is unavailable for this stream</div>
|
||||
<div>{t("stream.twoWayTalk.unavailable")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 text-xs">
|
||||
Your device must suppport the feature and WebRTC
|
||||
must be configured for two-way talk.
|
||||
{t("stream.twoWayTalk.tips")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/live/#webrtc-extra-configuration"
|
||||
@@ -1618,7 +1698,7 @@ function FrigateCameraFeatures({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
{t("stream.twoWayTalk.tips.documentation")}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -1633,10 +1713,7 @@ function FrigateCameraFeatures({
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<IoIosWarning className="mr-1 size-8 text-danger" />
|
||||
|
||||
<p className="text-sm">
|
||||
Live view is in low-bandwidth mode due to buffering or
|
||||
stream errors.
|
||||
</p>
|
||||
<p className="text-sm">{t("stream.lowBandwidth.tips")}</p>
|
||||
</div>
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
@@ -1646,7 +1723,9 @@ function FrigateCameraFeatures({
|
||||
onClick={() => setLowBandwidth(false)}
|
||||
>
|
||||
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
|
||||
<div className="text-primary-variant">Reset stream</div>
|
||||
<div className="text-primary-variant">
|
||||
{t("stream.lowBandwidth.resetStream")}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1654,7 +1733,7 @@ function FrigateCameraFeatures({
|
||||
)}
|
||||
<div className="flex flex-col gap-1 px-2">
|
||||
<div className="mb-1 text-sm font-medium leading-none">
|
||||
On-Demand Recording
|
||||
{t("manualRecording.title")}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleEventButtonClick}
|
||||
@@ -1663,46 +1742,43 @@ function FrigateCameraFeatures({
|
||||
isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
|
||||
)}
|
||||
>
|
||||
{isRecording ? "End" : "Start"} on-demand recording
|
||||
{t("manualRecording." + isRecording ? "end" : "start")}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Start a manual event based on this camera's recording retention
|
||||
settings.
|
||||
{t("manualRecording.tips")}
|
||||
</p>
|
||||
</div>
|
||||
{isRestreamed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FilterSwitch
|
||||
label="Play in Background"
|
||||
label={t("manualRecording.playInBackground.label")}
|
||||
isChecked={playInBackground}
|
||||
onCheckedChange={(checked) => {
|
||||
setPlayInBackground(checked);
|
||||
}}
|
||||
/>
|
||||
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
|
||||
Enable this option to continue streaming when the player is
|
||||
hidden.
|
||||
{t("manualRecording.playInBackground.desc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FilterSwitch
|
||||
label="Show Stats"
|
||||
label={t("manualRecording.showStats.label")}
|
||||
isChecked={showStats}
|
||||
onCheckedChange={(checked) => {
|
||||
setShowStats(checked);
|
||||
}}
|
||||
/>
|
||||
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
|
||||
Enable this option to show stream statistics as an overlay on
|
||||
the camera feed.
|
||||
{t("manualRecording.showStats.desc")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="mb-3 flex flex-col gap-1 px-2">
|
||||
<div className="flex items-center justify-between text-sm font-medium leading-none">
|
||||
Debug View
|
||||
{t("manualRecording.debugView")}
|
||||
<LuExternalLink
|
||||
onClick={() =>
|
||||
navigate(`/settings?page=debug&camera=${camera.name}`)
|
||||
|
||||
@@ -43,6 +43,7 @@ import useCameraLiveMode from "@/hooks/use-camera-live-mode";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import LiveContextMenu from "@/components/menu/LiveContextMenu";
|
||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type LiveDashboardViewProps = {
|
||||
cameras: CameraConfig[];
|
||||
@@ -60,6 +61,8 @@ export default function LiveDashboardView({
|
||||
fullscreen,
|
||||
toggleFullscreen,
|
||||
}: LiveDashboardViewProps) {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// layout
|
||||
@@ -471,7 +474,9 @@ export default function LiveDashboardView({
|
||||
}
|
||||
const streamName =
|
||||
currentGroupStreamingSettings?.[camera.name]?.streamName ||
|
||||
Object.values(camera.live.streams)?.[0];
|
||||
camera?.live?.streams
|
||||
? Object?.values(camera?.live?.streams)?.[0]
|
||||
: "";
|
||||
const autoLive =
|
||||
currentGroupStreamingSettings?.[camera.name]?.streamType !==
|
||||
"no-streaming";
|
||||
@@ -560,7 +565,9 @@ export default function LiveDashboardView({
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{fullscreen ? "Exit Fullscreen" : "Fullscreen"}
|
||||
{fullscreen
|
||||
? t("button.exitFullscreen", { ns: "common" })
|
||||
: t("button.fullscreen", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||
import { useTimezone } from "@/hooks/use-date-utils";
|
||||
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type RecordingViewProps = {
|
||||
startCamera: string;
|
||||
@@ -72,6 +73,7 @@ export function RecordingView({
|
||||
filter,
|
||||
updateFilter,
|
||||
}: RecordingViewProps) {
|
||||
const { t } = useTranslation(["views/events"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const navigate = useNavigate();
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -393,12 +395,16 @@ export function RecordingView({
|
||||
<div className={cn("flex items-center gap-2")}>
|
||||
<Button
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
aria-label="Go back"
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && <div className="text-primary">Back</div>}
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
@@ -409,7 +415,11 @@ export function RecordingView({
|
||||
}}
|
||||
>
|
||||
<FaVideo className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && <div className="text-primary">Live</div>}
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("menu.live", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
@@ -482,16 +492,16 @@ export function RecordingView({
|
||||
<ToggleGroupItem
|
||||
className={`${timelineType == "timeline" ? "" : "text-muted-foreground"}`}
|
||||
value="timeline"
|
||||
aria-label="Select timeline"
|
||||
aria-label={t("timeline.aria")}
|
||||
>
|
||||
<div className="">Timeline</div>
|
||||
<div className="">{t("timeline")}</div>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
className={`${timelineType == "events" ? "" : "text-muted-foreground"}`}
|
||||
value="events"
|
||||
aria-label="Select events"
|
||||
aria-label={t("events.aria")}
|
||||
>
|
||||
<div className="">Events</div>
|
||||
<div className="">{t("events.label")}</div>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
) : (
|
||||
@@ -689,6 +699,7 @@ function Timeline({
|
||||
setScrubbing,
|
||||
setExportRange,
|
||||
}: TimelineProps) {
|
||||
const { t } = useTranslation(["views/events"]);
|
||||
const internalTimelineRef = useRef<HTMLDivElement>(null);
|
||||
const selectedTimelineRef = timelineRef || internalTimelineRef;
|
||||
|
||||
@@ -798,7 +809,7 @@ function Timeline({
|
||||
>
|
||||
{mainCameraReviewItems.length === 0 ? (
|
||||
<div className="mt-5 text-center text-primary">
|
||||
No events found for this time period.
|
||||
{t("events.noFoundForTimePeriod")}
|
||||
</div>
|
||||
) : (
|
||||
mainCameraReviewItems.map((review) => {
|
||||
|
||||
@@ -22,7 +22,7 @@ import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { isEqual } from "lodash";
|
||||
import { formatDateToLocaleString } from "@/utils/dateUtil";
|
||||
import SearchThumbnailFooter from "@/components/card/SearchThumbnailFooter";
|
||||
import SearchSettings from "@/components/settings/SearchSettings";
|
||||
import ExploreSettings from "@/components/settings/SearchSettings";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import Chip from "@/components/indicators/Chip";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import SearchActionGroup from "@/components/filter/SearchActionGroup";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type SearchViewProps = {
|
||||
search: string;
|
||||
@@ -70,6 +71,7 @@ export default function SearchView({
|
||||
setColumns,
|
||||
setDefaultView,
|
||||
}: SearchViewProps) {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
@@ -495,7 +497,7 @@ export default function SearchView({
|
||||
filter={searchFilter}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
/>
|
||||
<SearchSettings
|
||||
<ExploreSettings
|
||||
columns={columns}
|
||||
setColumns={setColumns}
|
||||
defaultView={defaultView}
|
||||
@@ -531,7 +533,7 @@ export default function SearchView({
|
||||
{uniqueResults?.length == 0 && !isLoading && (
|
||||
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
|
||||
<LuSearchX className="size-16" />
|
||||
No Tracked Objects Found
|
||||
{t("noTrackedObjects")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -616,7 +618,7 @@ export default function SearchView({
|
||||
}}
|
||||
refreshResults={refresh}
|
||||
showObjectLifecycle={() =>
|
||||
onSelectSearch(value, false, "object lifecycle")
|
||||
onSelectSearch(value, false, "object_lifecycle")
|
||||
}
|
||||
showSnapshot={() =>
|
||||
onSelectSearch(value, false, "snapshot")
|
||||
|
||||
@@ -13,6 +13,7 @@ import { toast } from "sonner";
|
||||
import DeleteUserDialog from "@/components/overlay/DeleteUserDialog";
|
||||
import { HiTrash } from "react-icons/hi";
|
||||
import { FaUserEdit } from "react-icons/fa";
|
||||
|
||||
import { LuPlus, LuShield, LuUserCog } from "react-icons/lu";
|
||||
import {
|
||||
Table,
|
||||
@@ -30,8 +31,10 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import RoleChangeDialog from "@/components/overlay/RoleChangeDialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function AuthenticationView() {
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { data: users, mutate: mutateUsers } = useSWR<User[]>("users");
|
||||
|
||||
@@ -46,30 +49,38 @@ export default function AuthenticationView() {
|
||||
>();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Authentication Settings - Frigate";
|
||||
}, []);
|
||||
document.title = t("documentTitle.authentication");
|
||||
}, [t]);
|
||||
|
||||
const onSavePassword = useCallback((user: string, password: string) => {
|
||||
axios
|
||||
.put(`users/${user}/password`, { password })
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
setShowSetPassword(false);
|
||||
toast.success("Password updated successfully", {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to save password: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
const onSavePassword = useCallback(
|
||||
(user: string, password: string) => {
|
||||
axios
|
||||
.put(`users/${user}/password`, { password })
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
setShowSetPassword(false);
|
||||
toast.success(t("users.toast.success.updatePassword"), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("users.toast.error.setPasswordFailed", {
|
||||
errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const onCreate = (
|
||||
user: string,
|
||||
@@ -85,7 +96,7 @@ export default function AuthenticationView() {
|
||||
users?.push({ username: user, role: role });
|
||||
return users;
|
||||
}, false);
|
||||
toast.success(`User ${user} created successfully`, {
|
||||
toast.success(t("users.toast.success.createUser", { user }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
@@ -95,9 +106,14 @@ export default function AuthenticationView() {
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to create user: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("users.toast.error.createUserFailed", {
|
||||
errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -111,7 +127,7 @@ export default function AuthenticationView() {
|
||||
(users) => users?.filter((u) => u.username !== user),
|
||||
false,
|
||||
);
|
||||
toast.success(`User ${user} deleted successfully`, {
|
||||
toast.success(t("users.toast.success.deleteUser", { user }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
@@ -121,9 +137,14 @@ export default function AuthenticationView() {
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to delete user: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("users.toast.error.deleteUserFailed", {
|
||||
errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -142,7 +163,7 @@ export default function AuthenticationView() {
|
||||
),
|
||||
false,
|
||||
);
|
||||
toast.success(`Role updated for ${user}`, {
|
||||
toast.success(t("users.toast.success.roleUpdated", { user }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
@@ -152,9 +173,14 @@ export default function AuthenticationView() {
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to update role: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("users.toast.error.roleUpdateFailed", {
|
||||
errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -173,20 +199,20 @@ export default function AuthenticationView() {
|
||||
<div className="mb-5 flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start">
|
||||
<Heading as="h3" className="my-2">
|
||||
User Management
|
||||
{t("users.management")}
|
||||
</Heading>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage this Frigate instance's user accounts.
|
||||
{t("users.management.desc")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="flex items-center gap-2 self-start sm:self-auto"
|
||||
aria-label="Add a new user"
|
||||
aria-label={t("users.addUser")}
|
||||
variant="default"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add User
|
||||
{t("users.addUser")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -195,16 +221,20 @@ export default function AuthenticationView() {
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px]">Username</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead className="w-[250px]">
|
||||
{t("users.table.username")}
|
||||
</TableHead>
|
||||
<TableHead>{t("users.table.role")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("users.table.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-24 text-center">
|
||||
No users found.
|
||||
{t("users.table.noUsers")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -231,7 +261,9 @@ export default function AuthenticationView() {
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{user.role || "viewer"}
|
||||
{t("role." + (user.role || "viewer"), {
|
||||
ns: "common",
|
||||
})}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -255,12 +287,12 @@ export default function AuthenticationView() {
|
||||
>
|
||||
<LuUserCog className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
Role
|
||||
{t("role.title", { ns: "common" })}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Change user role</p>
|
||||
<p>{t("users.table.changeRole")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -278,12 +310,12 @@ export default function AuthenticationView() {
|
||||
>
|
||||
<FaUserEdit className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
Password
|
||||
{t("users.table.password")}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Update password</p>
|
||||
<p>{t("users.updatePassword")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -301,12 +333,12 @@ export default function AuthenticationView() {
|
||||
>
|
||||
<HiTrash className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
Delete
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete user</p>
|
||||
<p>{t("users.table.deleteUser")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { LuExternalLink } from "react-icons/lu";
|
||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAlertsState, useDetectionsState, useEnabledState } from "@/api/ws";
|
||||
@@ -45,6 +46,8 @@ export default function CameraSettingsView({
|
||||
selectedCamera,
|
||||
setUnsavedChanges,
|
||||
}: CameraSettingsViewProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
|
||||
@@ -76,18 +79,18 @@ export default function CameraSettingsView({
|
||||
const alertsLabels = useMemo(() => {
|
||||
return cameraConfig?.review.alerts.labels
|
||||
? cameraConfig.review.alerts.labels
|
||||
.map((label) => label.replaceAll("_", " "))
|
||||
.map((label) => t(label, { ns: "objects" }))
|
||||
.join(", ")
|
||||
: "";
|
||||
}, [cameraConfig]);
|
||||
}, [cameraConfig, t]);
|
||||
|
||||
const detectionsLabels = useMemo(() => {
|
||||
return cameraConfig?.review.detections.labels
|
||||
? cameraConfig.review.detections.labels
|
||||
.map((label) => label.replaceAll("_", " "))
|
||||
.map((label) => t(label, { ns: "objects" }))
|
||||
.join(", ")
|
||||
: "";
|
||||
}, [cameraConfig]);
|
||||
}, [cameraConfig, t]);
|
||||
|
||||
// form
|
||||
|
||||
@@ -157,17 +160,20 @@ export default function CameraSettingsView({
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
`Review classification configuration has been saved. Restart Frigate to apply changes.`,
|
||||
toast.success(t("camera.reviewClassification.toast.success"), {
|
||||
position: "top-center",
|
||||
});
|
||||
updateConfig();
|
||||
} else {
|
||||
toast.error(
|
||||
t("toast.save.error", {
|
||||
errorMessage: res.statusText,
|
||||
ns: "common",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
updateConfig();
|
||||
} else {
|
||||
toast.error(`Failed to save config changes: ${res.statusText}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -175,15 +181,21 @@ export default function CameraSettingsView({
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("toast.save.error", {
|
||||
errorMessage,
|
||||
ns: "common",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
},
|
||||
[updateConfig, setIsLoading, selectedCamera, cameraConfig],
|
||||
[updateConfig, setIsLoading, selectedCamera, cameraConfig, t],
|
||||
);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
@@ -252,13 +264,13 @@ export default function CameraSettingsView({
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||
<Heading as="h3" className="my-2">
|
||||
Camera Settings
|
||||
<Trans ns="views/settings">camera.title</Trans>
|
||||
</Heading>
|
||||
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
Streams
|
||||
<Trans ns="views/settings">camera.streams.title</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
@@ -271,19 +283,18 @@ export default function CameraSettingsView({
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="camera-enabled">Enable</Label>
|
||||
<Label htmlFor="camera-enabled">
|
||||
<Trans>button.enabled</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
Disabling a camera completely stops Frigate's processing of this
|
||||
camera's streams. Detection, recording, and debugging will be
|
||||
unavailable.
|
||||
<br /> <em>Note: This does not disable go2rtc restreams.</em>
|
||||
<Trans ns="views/settings">camera.streams.desc</Trans>
|
||||
</div>
|
||||
<Separator className="mb-2 mt-4 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
Review
|
||||
<Trans ns="views/settings">camera.review.title</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
||||
@@ -297,7 +308,9 @@ export default function CameraSettingsView({
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="alerts-enabled">Alerts</Label>
|
||||
<Label htmlFor="alerts-enabled">
|
||||
<Trans ns="views/settings">camera.review.alerts</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -311,12 +324,13 @@ export default function CameraSettingsView({
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="detections-enabled">Detections</Label>
|
||||
<Label htmlFor="detections-enabled">
|
||||
<Trans ns="views/settings">camera.review.detections</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
Enable/disable alerts and detections for this camera. When
|
||||
disabled, no new review items will be generated.
|
||||
<Trans ns="views/settings">camera.review.desc</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -324,16 +338,15 @@ export default function CameraSettingsView({
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
Review Classification
|
||||
<Trans ns="views/settings">camera.reviewClassification.title</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Frigate categorizes review items as Alerts and Detections. By
|
||||
default, all <em>person</em> and <em>car</em> objects are
|
||||
considered Alerts. You can refine categorization of your review
|
||||
items by configuring required zones for them.
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.desc
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
@@ -342,7 +355,9 @@ export default function CameraSettingsView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the Documentation{" "}
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.readTheDocumentation
|
||||
</Trans>{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -371,11 +386,15 @@ export default function CameraSettingsView({
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<FormLabel className="flex flex-row items-center text-base">
|
||||
Alerts{" "}
|
||||
<Trans ns="views/settings">
|
||||
camera.review.alerts
|
||||
</Trans>
|
||||
<MdCircle className="ml-3 size-2 text-severity_alert" />
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Select zones for Alerts
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.selectAlertsZones
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</div>
|
||||
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
|
||||
@@ -424,20 +443,37 @@ export default function CameraSettingsView({
|
||||
</>
|
||||
) : (
|
||||
<div className="font-normal text-destructive">
|
||||
No zones are defined for this camera.
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.noDefinedZones
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
<div className="text-sm">
|
||||
All {alertsLabels} objects
|
||||
{watchedAlertsZones && watchedAlertsZones.length > 0
|
||||
? ` detected in ${watchedAlertsZones.map((zone) => capitalizeFirstLetter(zone).replaceAll("_", " ")).join(", ")}`
|
||||
: ""}{" "}
|
||||
on{" "}
|
||||
{capitalizeFirstLetter(
|
||||
cameraConfig?.name ?? "",
|
||||
).replaceAll("_", " ")}{" "}
|
||||
will be shown as Alerts.
|
||||
? t(
|
||||
"camera.reviewClassification.zoneObjectAlertsTips",
|
||||
{
|
||||
alertsLabels,
|
||||
zone: watchedAlertsZones
|
||||
.map((zone) =>
|
||||
capitalizeFirstLetter(zone).replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
),
|
||||
)
|
||||
.join(", "),
|
||||
cameraName: capitalizeFirstLetter(
|
||||
cameraConfig?.name ?? "",
|
||||
).replaceAll("_", " "),
|
||||
},
|
||||
)
|
||||
: t("camera.reviewClassification.objectAlertsTips", {
|
||||
alertsLabels,
|
||||
cameraName: capitalizeFirstLetter(
|
||||
cameraConfig?.name ?? "",
|
||||
).replaceAll("_", " "),
|
||||
})}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -452,12 +488,16 @@ export default function CameraSettingsView({
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<FormLabel className="flex flex-row items-center text-base">
|
||||
Detections{" "}
|
||||
<Trans ns="views/settings">
|
||||
camera.review.detections
|
||||
</Trans>
|
||||
<MdCircle className="ml-3 size-2 text-severity_detection" />
|
||||
</FormLabel>
|
||||
{selectDetections && (
|
||||
<FormDescription>
|
||||
Select zones for Detections
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.selectDetectionsZones
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
)}
|
||||
</div>
|
||||
@@ -520,7 +560,9 @@ export default function CameraSettingsView({
|
||||
htmlFor="select-detections"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Limit detections to specific zones
|
||||
<Trans ns="views/settings">
|
||||
camera.reviewClassification.limitDetections
|
||||
</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -528,22 +570,59 @@ export default function CameraSettingsView({
|
||||
)}
|
||||
|
||||
<div className="text-sm">
|
||||
All {detectionsLabels} objects{" "}
|
||||
<em>not classified as Alerts</em>{" "}
|
||||
{watchedDetectionsZones &&
|
||||
watchedDetectionsZones.length > 0
|
||||
? ` that are detected in ${watchedDetectionsZones.map((zone) => capitalizeFirstLetter(zone).replaceAll("_", " ")).join(", ")}`
|
||||
: ""}{" "}
|
||||
on{" "}
|
||||
{capitalizeFirstLetter(
|
||||
cameraConfig?.name ?? "",
|
||||
).replaceAll("_", " ")}{" "}
|
||||
will be shown as Detections
|
||||
{(!selectDetections ||
|
||||
(watchedDetectionsZones &&
|
||||
watchedDetectionsZones.length === 0)) &&
|
||||
", regardless of zone"}
|
||||
.
|
||||
watchedDetectionsZones.length > 0 ? (
|
||||
!selectDetections ? (
|
||||
<Trans
|
||||
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
zone: watchedDetectionsZones
|
||||
.map((zone) =>
|
||||
capitalizeFirstLetter(zone).replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
),
|
||||
)
|
||||
.join(", "),
|
||||
cameraName: capitalizeFirstLetter(
|
||||
cameraConfig?.name ?? "",
|
||||
).replaceAll("_", " "),
|
||||
}}
|
||||
ns="views/settings"
|
||||
></Trans>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
zone: watchedDetectionsZones
|
||||
.map((zone) =>
|
||||
capitalizeFirstLetter(zone).replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
),
|
||||
)
|
||||
.join(", "),
|
||||
cameraName: capitalizeFirstLetter(
|
||||
cameraConfig?.name ?? "",
|
||||
).replaceAll("_", " "),
|
||||
}}
|
||||
ns="views/settings"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="camera.reviewClassification.objectDetectionsTips"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
cameraName: capitalizeFirstLetter(
|
||||
cameraConfig?.name ?? "",
|
||||
).replaceAll("_", " "),
|
||||
}}
|
||||
ns="views/settings"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -554,26 +633,28 @@ export default function CameraSettingsView({
|
||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label="Cancel"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
<Trans>button.cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
disabled={isLoading}
|
||||
className="flex flex-1"
|
||||
aria-label="Save"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>Saving...</span>
|
||||
<span>
|
||||
<Trans>button.saving</Trans>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
"Save"
|
||||
<Trans>button.save</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
type ClassificationSettings = {
|
||||
search: {
|
||||
@@ -41,6 +42,7 @@ type ClassificationSettingsViewProps = {
|
||||
export default function ClassificationSettingsView({
|
||||
setUnsavedChanges,
|
||||
}: ClassificationSettingsViewProps) {
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
const [changedValue, setChangedValue] = useState(false);
|
||||
@@ -141,15 +143,18 @@ export default function ClassificationSettingsView({
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success("Classification settings have been saved.", {
|
||||
toast.success(t("classification.toast.success"), {
|
||||
position: "top-center",
|
||||
});
|
||||
setChangedValue(false);
|
||||
updateConfig();
|
||||
} else {
|
||||
toast.error(`Failed to save config changes: ${res.statusText}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("classification.toast.error", { errorMessage: res.statusText }),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -157,14 +162,14 @@ export default function ClassificationSettingsView({
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||
toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [updateConfig, classificationSettings.search]);
|
||||
}, [updateConfig, classificationSettings.search, t]);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
setClassificationSettings(origSearchSettings);
|
||||
@@ -188,8 +193,8 @@ export default function ClassificationSettingsView({
|
||||
}, [changedValue]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Classification Settings - Frigate";
|
||||
}, []);
|
||||
document.title = t("documentTitle.classification");
|
||||
}, [t]);
|
||||
|
||||
if (!config) {
|
||||
return <ActivityIndicator />;
|
||||
@@ -200,19 +205,15 @@ export default function ClassificationSettingsView({
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||
<Heading as="h3" className="my-2">
|
||||
Classification Settings
|
||||
{t("classification.title")}
|
||||
</Heading>
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
<Heading as="h4" className="my-2">
|
||||
Semantic Search
|
||||
{t("classification.semanticSearch.title")}
|
||||
</Heading>
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Semantic Search in Frigate allows you to find tracked objects
|
||||
within your review items using either the image itself, a
|
||||
user-defined text description, or an automatically generated one.
|
||||
</p>
|
||||
<p>{t("classification.semanticSearch.desc")}</p>
|
||||
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
@@ -221,7 +222,7 @@ export default function ClassificationSettingsView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the Documentation
|
||||
{t("classification.semanticSearch.readTheDocumentation")}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -242,7 +243,9 @@ export default function ClassificationSettingsView({
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enabled">Enabled</Label>
|
||||
<Label htmlFor="enabled">
|
||||
{t("button.enabled", { ns: "common" })}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -259,31 +262,38 @@ export default function ClassificationSettingsView({
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="reindex">Re-Index On Startup</Label>
|
||||
<Label htmlFor="reindex">
|
||||
{t("classification.semanticSearch.reindexOnStartup.label")}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
Re-indexing will reprocess all thumbnails and descriptions (if
|
||||
enabled) and apply the embeddings on each startup.{" "}
|
||||
<em>Don't forget to disable the option after restarting!</em>
|
||||
<Trans ns="views/settings">
|
||||
classification.semanticSearch.reindexOnStartup.desc
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col space-y-6">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">Model Size</div>
|
||||
<div className="text-md">
|
||||
{t("classification.semanticSearch.modelSize.label")}
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
The size of the model used for Semantic Search embeddings.
|
||||
<Trans ns="views/settings">
|
||||
classification.semanticSearch.modelSize.desc
|
||||
</Trans>
|
||||
</p>
|
||||
<ul className="list-disc pl-5 text-sm">
|
||||
<li>
|
||||
Using <em>small</em> employs a quantized version of the
|
||||
model that uses less RAM and runs faster on CPU with a very
|
||||
negligible difference in embedding quality.
|
||||
<Trans ns="views/settings">
|
||||
classification.semanticSearch.modelSize.small.desc
|
||||
</Trans>
|
||||
</li>
|
||||
<li>
|
||||
Using <em>large</em> employs the full Jina model and will
|
||||
automatically run on the GPU if applicable.
|
||||
<Trans ns="views/settings">
|
||||
classification.semanticSearch.modelSize.large.desc
|
||||
</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -309,7 +319,7 @@ export default function ClassificationSettingsView({
|
||||
className="cursor-pointer"
|
||||
value={size}
|
||||
>
|
||||
{size}
|
||||
{t("classification.semanticSearch.modelSize." + size)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
@@ -322,16 +332,11 @@ export default function ClassificationSettingsView({
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
Face Recognition
|
||||
{t("classification.faceRecognition.title")}
|
||||
</Heading>
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Face recognition allows people to be assigned names and when
|
||||
their face is recognized Frigate will assign the person's name
|
||||
as a sub label. This information is included in the UI, filters,
|
||||
as well as in notifications.
|
||||
</p>
|
||||
<p>{t("classification.faceRecognition.desc")}</p>
|
||||
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
@@ -340,7 +345,7 @@ export default function ClassificationSettingsView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the Documentation
|
||||
{t("classification.faceRecognition.readTheDocumentation")}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -361,7 +366,9 @@ export default function ClassificationSettingsView({
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enabled">Enabled</Label>
|
||||
<Label htmlFor="enabled">
|
||||
{t("button.enabled", { ns: "common" })}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -369,18 +376,11 @@ export default function ClassificationSettingsView({
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
License Plate Recognition
|
||||
{t("classification.licensePlateRecognition.title")}
|
||||
</Heading>
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Frigate can recognize license plates on vehicles and
|
||||
automatically add the detected characters to the
|
||||
recognized_license_plate field or a known name as a sub_label to
|
||||
objects that are of type car. A common use case may be to read
|
||||
the license plates of cars pulling into a driveway or cars
|
||||
passing by on a street.
|
||||
</p>
|
||||
<p>{t("classification.licensePlateRecognition.desc")}</p>
|
||||
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
@@ -389,7 +389,9 @@ export default function ClassificationSettingsView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the Documentation
|
||||
{t(
|
||||
"classification.licensePlateRecognition.readTheDocumentation",
|
||||
)}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -410,7 +412,9 @@ export default function ClassificationSettingsView({
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enabled">Enabled</Label>
|
||||
<Label htmlFor="enabled">
|
||||
{t("button.enabled", { ns: "common" })}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -420,10 +424,10 @@ export default function ClassificationSettingsView({
|
||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label="Reset"
|
||||
aria-label={t("button.reset", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Reset
|
||||
{t("button.reset", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
@@ -435,10 +439,10 @@ export default function ClassificationSettingsView({
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>Saving...</span>
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
"Save"
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,9 @@ import PolygonItem from "@/components/settings/PolygonItem";
|
||||
import { Link } from "react-router-dom";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
|
||||
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type MasksAndZoneViewProps = {
|
||||
selectedCamera: string;
|
||||
@@ -50,6 +52,7 @@ export default function MasksAndZonesView({
|
||||
selectedZoneMask,
|
||||
setUnsavedChanges,
|
||||
}: MasksAndZoneViewProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
|
||||
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
|
||||
@@ -182,8 +185,8 @@ export default function MasksAndZonesView({
|
||||
setActivePolygonIndex(undefined);
|
||||
setHoveredPolygonIndex(null);
|
||||
setUnsavedChanges(false);
|
||||
document.title = "Mask and Zone Editor - Frigate";
|
||||
}, [allPolygons, setUnsavedChanges]);
|
||||
document.title = t("documentTitle.masksAndZones");
|
||||
}, [allPolygons, setUnsavedChanges, t]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
setAllPolygons([...(editingPolygons ?? [])]);
|
||||
@@ -218,12 +221,16 @@ export default function MasksAndZonesView({
|
||||
.map((point) => `${point[0]},${point[1]}`)
|
||||
.join(","),
|
||||
);
|
||||
toast.success(`Copied coordinates for ${poly.name} to clipboard.`);
|
||||
toast.success(
|
||||
t("masksAndZones.toast.success.copyCoordinates", {
|
||||
polyName: poly.name,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error("Could not copy coordinates to clipboard.");
|
||||
toast.error(t("masksAndZones.toast.error.copyCoordinatesFailed"));
|
||||
}
|
||||
},
|
||||
[allPolygons, scaledHeight, scaledWidth],
|
||||
[allPolygons, scaledHeight, scaledWidth, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -418,8 +425,8 @@ export default function MasksAndZonesView({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Mask and Zone Editor - Frigate";
|
||||
}, []);
|
||||
document.title = t("documentTitle.masksAndZones");
|
||||
}, [t]);
|
||||
|
||||
if (!cameraConfig && !selectedCamera) {
|
||||
return <ActivityIndicator />;
|
||||
@@ -480,7 +487,7 @@ export default function MasksAndZonesView({
|
||||
{editPane === undefined && (
|
||||
<>
|
||||
<Heading as="h3" className="my-2">
|
||||
Masks / Zones
|
||||
{t("menu.masksAndZones")}
|
||||
</Heading>
|
||||
<div className="flex w-full flex-col">
|
||||
{(selectedZoneMask === undefined ||
|
||||
@@ -489,15 +496,13 @@ export default function MasksAndZonesView({
|
||||
<div className="my-3 flex flex-row items-center justify-between">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="text-md cursor-default">Zones</div>
|
||||
<div className="text-md cursor-default">
|
||||
{t("masksAndZones.zones.label")}
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className="my-2 flex flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Zones allow you to define a specific area of the
|
||||
frame so you can determine whether or not an
|
||||
object is within a particular area.
|
||||
</p>
|
||||
<p>{t("masksAndZones.zones.desc")}</p>
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/zones"
|
||||
@@ -505,7 +510,7 @@ export default function MasksAndZonesView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Documentation{" "}
|
||||
{t("masksAndZones.zones.desc.documentation")}{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -517,7 +522,7 @@ export default function MasksAndZonesView({
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
||||
aria-label="Add a new zone"
|
||||
aria-label={t("masksAndZones.zones.add")}
|
||||
onClick={() => {
|
||||
setEditPane("zone");
|
||||
handleNewPolygon("zone");
|
||||
@@ -526,7 +531,9 @@ export default function MasksAndZonesView({
|
||||
<LuPlus />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add Zone</TooltipContent>
|
||||
<TooltipContent>
|
||||
{t("masksAndZones.zones.add")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{allPolygons
|
||||
@@ -556,17 +563,12 @@ export default function MasksAndZonesView({
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="text-md cursor-default">
|
||||
Motion Masks
|
||||
{t("masksAndZones.motionMasks.label")}
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className="my-2 flex flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Motion masks are used to prevent unwanted types
|
||||
of motion from triggering detection. Over
|
||||
masking will make it more difficult for objects
|
||||
to be tracked.
|
||||
</p>
|
||||
<p>{t("masksAndZones.motionMasks.desc")}</p>
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/masks#motion-masks"
|
||||
@@ -574,7 +576,9 @@ export default function MasksAndZonesView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Documentation{" "}
|
||||
{t(
|
||||
"masksAndZones.motionMasks.desc.documentation",
|
||||
)}{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -586,7 +590,7 @@ export default function MasksAndZonesView({
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
||||
aria-label="Add a new motion mask"
|
||||
aria-label={t("masksAndZones.motionMasks.add")}
|
||||
onClick={() => {
|
||||
setEditPane("motion_mask");
|
||||
handleNewPolygon("motion_mask");
|
||||
@@ -595,7 +599,9 @@ export default function MasksAndZonesView({
|
||||
<LuPlus />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add Motion Mask</TooltipContent>
|
||||
<TooltipContent>
|
||||
{t("masksAndZones.motionMasks.add")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{allPolygons
|
||||
@@ -627,16 +633,12 @@ export default function MasksAndZonesView({
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="text-md cursor-default">
|
||||
Object Masks
|
||||
{t("masksAndZones.objectMasks.label")}
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className="my-2 flex flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
Object filter masks are used to filter out false
|
||||
positives for a given object type based on
|
||||
location.
|
||||
</p>
|
||||
<p>{t("masksAndZones.objectMasks.desc")}</p>
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/masks#object-filter-masks"
|
||||
@@ -644,7 +646,7 @@ export default function MasksAndZonesView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Documentation{" "}
|
||||
{t("masksAndZones.objectMasks.documentation")}{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -656,7 +658,7 @@ export default function MasksAndZonesView({
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
||||
aria-label="Add a new object mask"
|
||||
aria-label={t("masksAndZones.objectMasks.add")}
|
||||
onClick={() => {
|
||||
setEditPane("object_mask");
|
||||
handleNewPolygon("object_mask");
|
||||
@@ -665,7 +667,9 @@ export default function MasksAndZonesView({
|
||||
<LuPlus />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add Object Mask</TooltipContent>
|
||||
<TooltipContent>
|
||||
{t("masksAndZones.objectMasks.add")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{allPolygons
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
type MotionTunerViewProps = {
|
||||
selectedCamera: string;
|
||||
@@ -37,6 +38,7 @@ export default function MotionTunerView({
|
||||
selectedCamera,
|
||||
setUnsavedChanges,
|
||||
}: MotionTunerViewProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
const [changedValue, setChangedValue] = useState(false);
|
||||
@@ -117,20 +119,29 @@ export default function MotionTunerView({
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success("Motion settings have been saved.", {
|
||||
toast.success(t("motionDetectionTuner.toast.success"), {
|
||||
position: "top-center",
|
||||
});
|
||||
setChangedValue(false);
|
||||
updateConfig();
|
||||
} else {
|
||||
toast.error(`Failed to save config changes: ${res.statusText}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("toast.save.error", {
|
||||
errorMessage: res.statusText,
|
||||
ns: "common",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
`Failed to save config changes: ${error.response.data.message}`,
|
||||
t("toast.save.error", {
|
||||
errorMessage: error.response.data.message,
|
||||
ns: "common",
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
})
|
||||
@@ -143,6 +154,7 @@ export default function MotionTunerView({
|
||||
motionSettings.contour_area,
|
||||
motionSettings.improve_contrast,
|
||||
selectedCamera,
|
||||
t,
|
||||
]);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
@@ -167,8 +179,8 @@ export default function MotionTunerView({
|
||||
}, [changedValue, selectedCamera]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Motion Tuner - Frigate";
|
||||
}, []);
|
||||
document.title = t("documentTitle.motionTuner");
|
||||
}, [t]);
|
||||
|
||||
if (!cameraConfig && !selectedCamera) {
|
||||
return <ActivityIndicator />;
|
||||
@@ -179,14 +191,10 @@ export default function MotionTunerView({
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-3/12">
|
||||
<Heading as="h3" className="my-2">
|
||||
Motion Detection Tuner
|
||||
{t("motionDetectionTuner.title")}
|
||||
</Heading>
|
||||
<div className="my-3 space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Frigate uses motion detection as a first line check to see if there
|
||||
is anything happening in the frame worth checking with object
|
||||
detection.
|
||||
</p>
|
||||
<p>{t("motionDetectionTuner.desc")}</p>
|
||||
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
@@ -195,7 +203,7 @@ export default function MotionTunerView({
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the Motion Tuning Guide{" "}
|
||||
{t("motionDetectionTuner.desc.documentation")}{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -205,14 +213,12 @@ export default function MotionTunerView({
|
||||
<div className="mt-2 space-y-6">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="motion-threshold" className="text-md">
|
||||
Threshold
|
||||
{t("motionDetectionTuner.Threshold")}
|
||||
</Label>
|
||||
<div className="my-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
The threshold value dictates how much of a change in a pixel's
|
||||
luminance is required to be considered motion.{" "}
|
||||
<em>Default: 30</em>
|
||||
</p>
|
||||
<Trans ns="views/settings">
|
||||
motionDetectionTuner.Threshold.desc
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between">
|
||||
@@ -236,12 +242,13 @@ export default function MotionTunerView({
|
||||
<div className="mt-2 space-y-6">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="motion-threshold" className="text-md">
|
||||
Contour Area
|
||||
{t("motionDetectionTuner.contourArea")}
|
||||
</Label>
|
||||
<div className="my-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
The contour area value is used to decide which groups of
|
||||
changed pixels qualify as motion. <em>Default: 10</em>
|
||||
<Trans ns="views/settings">
|
||||
motionDetectionTuner.contourArea.desc
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,9 +273,13 @@ export default function MotionTunerView({
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="improve-contrast">Improve Contrast</Label>
|
||||
<Label htmlFor="improve-contrast">
|
||||
{t("motionDetectionTuner.improveContrast")}
|
||||
</Label>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Improve contrast for darker scenes. <em>Default: ON</em>
|
||||
<Trans ns="views/settings">
|
||||
motionDetectionTuner.improveContrast.desc
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -286,25 +297,25 @@ export default function MotionTunerView({
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label="Reset"
|
||||
aria-label={t("button.reset", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Reset
|
||||
{t("button.reset", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
disabled={!changedValue || isLoading}
|
||||
className="flex flex-1"
|
||||
aria-label="Save"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
onClick={saveToConfig}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>Saving...</span>
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
"Save"
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user