mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-03 06:50:58 +00:00
Compare commits
4 Commits
645c84bc1a
...
cee5d6e5ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cee5d6e5ee | ||
|
|
78c1694451 | ||
|
|
f9b2db4405 | ||
|
|
b6e0e5698a |
10
web/package-lock.json
generated
10
web/package-lock.json
generated
@ -38,6 +38,7 @@
|
|||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"date-fns-tz": "^3.2.0",
|
||||||
"embla-carousel-react": "^8.2.0",
|
"embla-carousel-react": "^8.2.0",
|
||||||
"framer-motion": "^11.5.4",
|
"framer-motion": "^11.5.4",
|
||||||
"hls.js": "^1.5.20",
|
"hls.js": "^1.5.20",
|
||||||
@ -4399,6 +4400,15 @@
|
|||||||
"url": "https://github.com/sponsors/kossnocorp"
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns-tz": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"date-fns": "^3.0.0 || ^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||||
|
|||||||
@ -44,6 +44,7 @@
|
|||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"date-fns-tz": "^3.2.0",
|
||||||
"embla-carousel-react": "^8.2.0",
|
"embla-carousel-react": "^8.2.0",
|
||||||
"framer-motion": "^11.5.4",
|
"framer-motion": "^11.5.4",
|
||||||
"hls.js": "^1.5.20",
|
"hls.js": "^1.5.20",
|
||||||
|
|||||||
@ -41,22 +41,34 @@
|
|||||||
"second_one": "{{time}} second",
|
"second_one": "{{time}} second",
|
||||||
"second_other": "{{time}} seconds",
|
"second_other": "{{time}} seconds",
|
||||||
"formattedTimestamp": {
|
"formattedTimestamp": {
|
||||||
"12hour": "%b %-d, %I:%M:%S %p",
|
"12hour": "MMM d, h:mm:ss aaa",
|
||||||
"24hour": "%b %-d, %H:%M:%S"
|
"24hour": "MMM d, HH:mm:ss"
|
||||||
},
|
},
|
||||||
"formattedTimestamp2": {
|
"formattedTimestamp2": {
|
||||||
"12hour": "%m/%d %I:%M:%S%P",
|
"12hour": "MM/dd h:mm:ssa",
|
||||||
"24hour": "%d %b %H:%M:%S"
|
"24hour": "d MMM HH:mm:ss"
|
||||||
},
|
},
|
||||||
"formattedTimestampExcludeSeconds": {
|
"formattedTimestampHourMinute": {
|
||||||
"12hour": "%b %-d, %I:%M %p",
|
"12hour": "h:mm aaa",
|
||||||
"24hour": "%b %-d, %H:%M"
|
"24hour": "HH:mm"
|
||||||
},
|
},
|
||||||
"formattedTimestampWithYear": {
|
"formattedTimestampHourMinuteSecond": {
|
||||||
"12hour": "%b %-d %Y, %I:%M %p",
|
"12hour": "h:mm:ss aaa",
|
||||||
"24hour": "%b %-d %Y, %H:%M"
|
"24hour": "HH:mm:ss"
|
||||||
},
|
},
|
||||||
"formattedTimestampOnlyMonthAndDay": "%b %-d"
|
"formattedTimestampMonthDayHourMinute": {
|
||||||
|
"12hour": "MMM d, h:mm aaa",
|
||||||
|
"24hour": "MMM d, HH:mm"
|
||||||
|
},
|
||||||
|
"formattedTimestampMonthDayYearHourMinute": {
|
||||||
|
"12hour": "MMM d yyyy, h:mm aaa",
|
||||||
|
"24hour": "MMM d yyyy, HH:mm"
|
||||||
|
},
|
||||||
|
"formattedTimestampMonthDay": "MMM d",
|
||||||
|
"formattedTimestampFilename": {
|
||||||
|
"12hour": "MM-dd-yy-h-mm-ss-a",
|
||||||
|
"24hour": "MM-dd-yy-HH-mm-ss"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"unit": {
|
"unit": {
|
||||||
"speed": {
|
"speed": {
|
||||||
|
|||||||
@ -31,5 +31,6 @@
|
|||||||
"label": "View new review items",
|
"label": "View new review items",
|
||||||
"button": "New Items To Review"
|
"button": "New Items To Review"
|
||||||
},
|
},
|
||||||
|
"selected": "{{count}} selected",
|
||||||
"camera": "Camera"
|
"camera": "Camera"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -136,6 +136,7 @@
|
|||||||
"desc": "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.",
|
"desc": "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.",
|
||||||
"readTheDocumentation": "Read the Documentation"
|
"readTheDocumentation": "Read the Documentation"
|
||||||
},
|
},
|
||||||
|
"restart_required": "Restart required (Classification settings changed)",
|
||||||
"toast": {
|
"toast": {
|
||||||
"success": "Classification settings have been saved. Restart Frigate to apply your changes.",
|
"success": "Classification settings have been saved. Restart Frigate to apply your changes.",
|
||||||
"error": "Failed to save config changes: {{errorMessage}}"
|
"error": "Failed to save config changes: {{errorMessage}}"
|
||||||
@ -178,6 +179,7 @@
|
|||||||
"filter": {
|
"filter": {
|
||||||
"all": "All Masks and Zones"
|
"all": "All Masks and Zones"
|
||||||
},
|
},
|
||||||
|
"restart_required": "Restart required (masks/zones changed)",
|
||||||
"toast": {
|
"toast": {
|
||||||
"success": {
|
"success": {
|
||||||
"copyCoordinates": "Copied coordinates for {{polyName}} to clipboard."
|
"copyCoordinates": "Copied coordinates for {{polyName}} to clipboard."
|
||||||
@ -583,6 +585,7 @@
|
|||||||
"loadingAvailableModels": "Loading available models...",
|
"loadingAvailableModels": "Loading available models...",
|
||||||
"modelSelect": "Your available models on Frigate+ can be selected here. Note that only models compatible with your current detector configuration can be selected."
|
"modelSelect": "Your available models on Frigate+ can be selected here. Note that only models compatible with your current detector configuration can be selected."
|
||||||
},
|
},
|
||||||
|
"restart_required": "Restart required (Frigate+ model changed)",
|
||||||
"toast": {
|
"toast": {
|
||||||
"success": "Frigate+ settings have been saved. Restart Frigate to apply changes.",
|
"success": "Frigate+ settings have been saved. Restart Frigate to apply changes.",
|
||||||
"error": "Failed to save config changes: {{errorMessage}}"
|
"error": "Failed to save config changes: {{errorMessage}}"
|
||||||
|
|||||||
@ -153,11 +153,17 @@
|
|||||||
"title": "Enrichments",
|
"title": "Enrichments",
|
||||||
"infPerSecond": "Inferences Per Second",
|
"infPerSecond": "Inferences Per Second",
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
|
"image_embedding": "Image Embedding",
|
||||||
|
"text_embedding": "Text Embedding",
|
||||||
|
"face_recognition": "Face Recognition",
|
||||||
|
"plate_recognition": "Plate Recognition",
|
||||||
"image_embedding_speed": "Image Embedding Speed",
|
"image_embedding_speed": "Image Embedding Speed",
|
||||||
"face_embedding_speed": "Face Embedding Speed",
|
"face_embedding_speed": "Face Embedding Speed",
|
||||||
"face_recognition_speed": "Face Recognition Speed",
|
"face_recognition_speed": "Face Recognition Speed",
|
||||||
"plate_recognition_speed": "Plate Recognition Speed",
|
"plate_recognition_speed": "Plate Recognition Speed",
|
||||||
"text_embedding_speed": "Text Embedding Speed"
|
"text_embedding_speed": "Text Embedding Speed",
|
||||||
|
"yolov9_plate_detection_speed": "YOLOv9 Plate Detection Speed",
|
||||||
|
"yolov9_plate_detection": "YOLOv9 Plate Detection"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,12 +6,13 @@ import Sidebar from "@/components/navigation/Sidebar";
|
|||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import Statusbar from "./components/Statusbar";
|
import Statusbar from "./components/Statusbar";
|
||||||
import Bottombar from "./components/navigation/Bottombar";
|
import Bottombar from "./components/navigation/Bottombar";
|
||||||
import { Suspense, lazy } from "react";
|
import React, { Suspense, lazy } from "react";
|
||||||
import { Redirect } from "./components/navigation/Redirect";
|
import { Redirect } from "./components/navigation/Redirect";
|
||||||
import { cn } from "./lib/utils";
|
import { cn } from "./lib/utils";
|
||||||
import { isPWA } from "./utils/isPWA";
|
import { isPWA } from "./utils/isPWA";
|
||||||
import ProtectedRoute from "@/components/auth/ProtectedRoute";
|
import ProtectedRoute from "@/components/auth/ProtectedRoute";
|
||||||
import { AuthProvider } from "@/context/auth-context";
|
import { AuthProvider } from "@/context/auth-context";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const Live = lazy(() => import("@/pages/Live"));
|
const Live = lazy(() => import("@/pages/Live"));
|
||||||
const Events = lazy(() => import("@/pages/Events"));
|
const Events = lazy(() => import("@/pages/Events"));
|
||||||
@ -26,6 +27,13 @@ const Logs = lazy(() => import("@/pages/Logs"));
|
|||||||
const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
|
const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
|
// Set the lang attribute on the html element when language changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
document.documentElement.lang = i18n.language;
|
||||||
|
}, [i18n.language]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Providers>
|
<Providers>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
|||||||
@ -4,6 +4,10 @@ import { FaDownload } from "react-icons/fa";
|
|||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
type DownloadVideoButtonProps = {
|
type DownloadVideoButtonProps = {
|
||||||
source: string;
|
source: string;
|
||||||
@ -19,10 +23,17 @@ export function DownloadVideoButton({
|
|||||||
className,
|
className,
|
||||||
}: DownloadVideoButtonProps) {
|
}: DownloadVideoButtonProps) {
|
||||||
const { t } = useTranslation(["components/input"]);
|
const { t } = useTranslation(["components/input"]);
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const locale = useDateLocale();
|
||||||
|
|
||||||
|
const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
||||||
|
const format = useMemo(() => {
|
||||||
|
return t(`time.formattedTimestampFilename.${timeFormat}`, { ns: "common" });
|
||||||
|
}, [t, timeFormat]);
|
||||||
|
|
||||||
const formattedDate = formatUnixTimestampToDateTime(startTime, {
|
const formattedDate = formatUnixTimestampToDateTime(startTime, {
|
||||||
strftime_fmt: "%D-%T",
|
date_format: format,
|
||||||
time_style: "medium",
|
locale,
|
||||||
date_style: "medium",
|
|
||||||
});
|
});
|
||||||
const filename = `${camera}_${formattedDate}.mp4`;
|
const filename = `${camera}_${formattedDate}.mp4`;
|
||||||
|
|
||||||
|
|||||||
@ -222,7 +222,7 @@ export default function ExportCard({
|
|||||||
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
||||||
)}
|
)}
|
||||||
<div className="rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 h-[20%] rounded-lg bg-gradient-to-t from-black/60 to-transparent md:rounded-2xl">
|
<div className="rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 h-[20%] rounded-lg bg-gradient-to-t from-black/60 to-transparent md:rounded-2xl">
|
||||||
<div className="mx-3 flex h-full items-end justify-between pb-1 text-sm capitalize text-white">
|
<div className="mx-3 flex h-full items-end justify-between pb-1 text-sm text-white smart-capitalize">
|
||||||
{exportedRecording.name.replaceAll("_", " ")}
|
{exportedRecording.name.replaceAll("_", " ")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -52,7 +52,9 @@ export default function ReviewCard({
|
|||||||
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
|
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
|
||||||
const formattedDate = useFormattedTimestamp(
|
const formattedDate = useFormattedTimestamp(
|
||||||
event.start_time,
|
event.start_time,
|
||||||
config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
|
config?.ui.time_format == "24hour"
|
||||||
|
? t("time.formattedTimestampHourMinute.24hour", { ns: "common" })
|
||||||
|
: t("time.formattedTimestampHourMinute.12hour", { ns: "common" }),
|
||||||
config?.ui.timezone,
|
config?.ui.timezone,
|
||||||
);
|
);
|
||||||
const isSelected = useMemo(
|
const isSelected = useMemo(
|
||||||
@ -173,7 +175,7 @@ export default function ReviewCard({
|
|||||||
<div className="font-extra-light text-xs">{formattedDate}</div>
|
<div className="font-extra-light text-xs">{formattedDate}</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="capitalize">
|
<TooltipContent className="smart-capitalize">
|
||||||
{[
|
{[
|
||||||
...new Set([
|
...new Set([
|
||||||
...(event.data.objects || []),
|
...(event.data.objects || []),
|
||||||
|
|||||||
@ -146,7 +146,7 @@ export default function SearchThumbnail({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</div>
|
</div>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent className="capitalize">
|
<TooltipContent className="smart-capitalize">
|
||||||
{[searchResult.sub_label ?? objectLabel]
|
{[searchResult.sub_label ?? objectLabel]
|
||||||
.filter(
|
.filter(
|
||||||
(item) => item !== undefined && !item.includes("-verified"),
|
(item) => item !== undefined && !item.includes("-verified"),
|
||||||
|
|||||||
@ -32,8 +32,8 @@ export default function SearchThumbnailFooter({
|
|||||||
const formattedDate = useFormattedTimestamp(
|
const formattedDate = useFormattedTimestamp(
|
||||||
searchResult.start_time,
|
searchResult.start_time,
|
||||||
config?.ui.time_format == "24hour"
|
config?.ui.time_format == "24hour"
|
||||||
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
|
? t("time.formattedTimestampMonthDayHourMinute.24hour", { ns: "common" })
|
||||||
: t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }),
|
: t("time.formattedTimestampMonthDayHourMinute.12hour", { ns: "common" }),
|
||||||
config?.ui.timezone,
|
config?.ui.timezone,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export default function CalendarFilterButton({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const selectedDate = useFormattedTimestamp(
|
const selectedDate = useFormattedTimestamp(
|
||||||
day == undefined ? 0 : day?.getTime() / 1000 + 1,
|
day == undefined ? 0 : day?.getTime() / 1000 + 1,
|
||||||
t("time.formattedTimestampOnlyMonthAndDay", { ns: "common" }),
|
t("time.formattedTimestampMonthDay", { ns: "common" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
@ -103,7 +103,7 @@ export function CalendarRangeFilterButton({
|
|||||||
const selectedDate = useFormattedRange(
|
const selectedDate = useFormattedRange(
|
||||||
range?.from == undefined ? 0 : range.from.getTime() / 1000 + 1,
|
range?.from == undefined ? 0 : range.from.getTime() / 1000 + 1,
|
||||||
range?.to == undefined ? 0 : range.to.getTime() / 1000 - 1,
|
range?.to == undefined ? 0 : range.to.getTime() / 1000 - 1,
|
||||||
t("time.formattedTimestampOnlyMonthAndDay", { ns: "common" }),
|
t("time.formattedTimestampMonthDay", { ns: "common" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
|
|||||||
@ -196,7 +196,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent className="capitalize" side="right">
|
<TooltipContent className="smart-capitalize" side="right">
|
||||||
{name}
|
{name}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
@ -847,7 +847,7 @@ export function CameraGroupEdit({
|
|||||||
<FormControl key={camera}>
|
<FormControl key={camera}>
|
||||||
<div className="flex items-center justify-between gap-1">
|
<div className="flex items-center justify-between gap-1">
|
||||||
<Label
|
<Label
|
||||||
className="mx-2 w-full cursor-pointer capitalize text-primary"
|
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
|
||||||
htmlFor={camera.replaceAll("_", " ")}
|
htmlFor={camera.replaceAll("_", " ")}
|
||||||
>
|
>
|
||||||
{camera.replaceAll("_", " ")}
|
{camera.replaceAll("_", " ")}
|
||||||
|
|||||||
@ -62,7 +62,7 @@ export function CamerasFilterButton({
|
|||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-2 capitalize"
|
className="flex items-center gap-2 smart-capitalize"
|
||||||
aria-label={t("cameras.label")}
|
aria-label={t("cameras.label")}
|
||||||
variant={selectedCameras?.length == undefined ? "default" : "select"}
|
variant={selectedCameras?.length == undefined ? "default" : "select"}
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -172,7 +172,7 @@ export function CamerasFilterContent({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={name}
|
key={name}
|
||||||
className="w-full cursor-pointer rounded-lg px-2 py-0.5 text-sm capitalize text-primary hover:bg-muted"
|
className="w-full cursor-pointer rounded-lg px-2 py-0.5 text-sm text-primary smart-capitalize hover:bg-muted"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentCameras([...conf.cameras]);
|
setCurrentCameras([...conf.cameras]);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export default function FilterSwitch({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-1">
|
<div className="flex items-center justify-between gap-1">
|
||||||
<Label
|
<Label
|
||||||
className={`mx-2 w-full cursor-pointer capitalize text-primary ${disabled ? "text-secondary-foreground" : ""}`}
|
className={`mx-2 w-full cursor-pointer text-primary smart-capitalize ${disabled ? "text-secondary-foreground" : ""}`}
|
||||||
htmlFor={label}
|
htmlFor={label}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@ -126,7 +126,7 @@ export function GeneralFilterContent({
|
|||||||
{["debug", "info", "warning", "error"].map((item) => (
|
{["debug", "info", "warning", "error"].map((item) => (
|
||||||
<div className="flex items-center justify-between" key={item}>
|
<div className="flex items-center justify-between" key={item}>
|
||||||
<Label
|
<Label
|
||||||
className="mx-2 w-full cursor-pointer capitalize text-primary"
|
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
|
||||||
htmlFor={item}
|
htmlFor={item}
|
||||||
>
|
>
|
||||||
{item.replaceAll("_", " ")}
|
{item.replaceAll("_", " ")}
|
||||||
|
|||||||
@ -95,7 +95,12 @@ export default function ReviewActionGroup({
|
|||||||
|
|
||||||
<div className="absolute inset-x-2 inset-y-0 flex items-center justify-between gap-2 bg-background py-2 md:left-auto">
|
<div className="absolute inset-x-2 inset-y-0 flex items-center justify-between gap-2 bg-background py-2 md:left-auto">
|
||||||
<div className="mx-1 flex items-center justify-center text-sm text-muted-foreground">
|
<div className="mx-1 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
<div className="p-1">{`${selectedReviews.length} selected`}</div>
|
<div className="p-1">
|
||||||
|
{t("selected", {
|
||||||
|
ns: "views/events",
|
||||||
|
count: selectedReviews.length,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<div className="p-1">{"|"}</div>
|
<div className="p-1">{"|"}</div>
|
||||||
<div
|
<div
|
||||||
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
|
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
|
||||||
|
|||||||
@ -354,7 +354,7 @@ function GeneralFilterButton({
|
|||||||
variant={
|
variant={
|
||||||
selectedLabels?.length || selectedZones?.length ? "select" : "default"
|
selectedLabels?.length || selectedZones?.length ? "select" : "default"
|
||||||
}
|
}
|
||||||
className="flex items-center gap-2 capitalize"
|
className="flex items-center gap-2 smart-capitalize"
|
||||||
aria-label={t("filter")}
|
aria-label={t("filter")}
|
||||||
>
|
>
|
||||||
<FaFilter
|
<FaFilter
|
||||||
|
|||||||
@ -108,7 +108,12 @@ export default function SearchActionGroup({
|
|||||||
|
|
||||||
<div className="absolute inset-x-2 inset-y-0 flex items-center justify-between gap-2 bg-background py-2 md:left-auto">
|
<div className="absolute inset-x-2 inset-y-0 flex items-center justify-between gap-2 bg-background py-2 md:left-auto">
|
||||||
<div className="mx-1 flex items-center justify-center text-sm text-muted-foreground">
|
<div className="mx-1 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
<div className="p-1">{`${selectedObjects.length} selected`}</div>
|
<div className="p-1">
|
||||||
|
{t("selected", {
|
||||||
|
ns: "views/events",
|
||||||
|
count: selectedObjects.length,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<div className="p-1">{"|"}</div>
|
<div className="p-1">{"|"}</div>
|
||||||
<div
|
<div
|
||||||
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
|
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
|
||||||
|
|||||||
@ -285,7 +285,7 @@ function GeneralFilterButton({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={selectedLabels?.length ? "select" : "default"}
|
variant={selectedLabels?.length ? "select" : "default"}
|
||||||
className="flex items-center gap-2 capitalize"
|
className="flex items-center gap-2 smart-capitalize"
|
||||||
aria-label={t("labels.label")}
|
aria-label={t("labels.label")}
|
||||||
>
|
>
|
||||||
<MdLabel
|
<MdLabel
|
||||||
@ -457,7 +457,7 @@ function SortTypeButton({
|
|||||||
? "select"
|
? "select"
|
||||||
: "default"
|
: "default"
|
||||||
}
|
}
|
||||||
className="flex items-center gap-2 capitalize"
|
className="flex items-center gap-2 smart-capitalize"
|
||||||
aria-label={t("labels.label")}
|
aria-label={t("labels.label")}
|
||||||
>
|
>
|
||||||
<MdSort
|
<MdSort
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export function ZoneMaskFilterButton({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={selectedZoneMask?.length ? "select" : "default"}
|
variant={selectedZoneMask?.length ? "select" : "default"}
|
||||||
className="flex items-center gap-2 capitalize"
|
className="flex items-center gap-2 smart-capitalize"
|
||||||
aria-label={t("zoneMask.filterBy")}
|
aria-label={t("zoneMask.filterBy")}
|
||||||
>
|
>
|
||||||
<FaFilter
|
<FaFilter
|
||||||
@ -96,7 +96,7 @@ export function GeneralFilterContent({
|
|||||||
{["zone", "motion_mask", "object_mask"].map((item) => (
|
{["zone", "motion_mask", "object_mask"].map((item) => (
|
||||||
<div key={item} className="flex items-center justify-between">
|
<div key={item} className="flex items-center justify-between">
|
||||||
<Label
|
<Label
|
||||||
className="mx-2 w-full cursor-pointer capitalize text-primary"
|
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
|
||||||
htmlFor={item}
|
htmlFor={item}
|
||||||
>
|
>
|
||||||
{t(
|
{t(
|
||||||
|
|||||||
@ -191,7 +191,7 @@ export function CombinedStorageGraph({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{series.map((item) => (
|
{series.map((item) => (
|
||||||
<TableRow key={item.name}>
|
<TableRow key={item.name}>
|
||||||
<TableCell className="flex flex-row items-center gap-2 font-medium capitalize">
|
<TableCell className="flex flex-row items-center gap-2 font-medium smart-capitalize">
|
||||||
{" "}
|
{" "}
|
||||||
<div
|
<div
|
||||||
className="size-3 rounded-md"
|
className="size-3 rounded-md"
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useTheme } from "@/context/theme-provider";
|
import { useTheme } from "@/context/theme-provider";
|
||||||
|
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
@ -24,7 +25,7 @@ export function CameraLineGraph({
|
|||||||
updateTimes,
|
updateTimes,
|
||||||
data,
|
data,
|
||||||
}: CameraLineGraphProps) {
|
}: CameraLineGraphProps) {
|
||||||
const { t } = useTranslation(["views/system"]);
|
const { t } = useTranslation(["views/system", "common"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
});
|
});
|
||||||
@ -43,18 +44,27 @@ export function CameraLineGraph({
|
|||||||
|
|
||||||
const { theme, systemTheme } = useTheme();
|
const { theme, systemTheme } = useTheme();
|
||||||
|
|
||||||
|
const locale = useDateLocale();
|
||||||
|
|
||||||
|
const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
||||||
|
const format = useMemo(() => {
|
||||||
|
return t(`time.formattedTimestampHourMinute.${timeFormat}`, {
|
||||||
|
ns: "common",
|
||||||
|
});
|
||||||
|
}, [t, timeFormat]);
|
||||||
|
|
||||||
const formatTime = useCallback(
|
const formatTime = useCallback(
|
||||||
(val: unknown) => {
|
(val: unknown) => {
|
||||||
return formatUnixTimestampToDateTime(
|
return formatUnixTimestampToDateTime(
|
||||||
updateTimes[Math.round(val as number)],
|
updateTimes[Math.round(val as number)],
|
||||||
{
|
{
|
||||||
timezone: config?.ui.timezone,
|
timezone: config?.ui.timezone,
|
||||||
strftime_fmt:
|
date_format: format,
|
||||||
config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
|
locale,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[config, updateTimes],
|
[config?.ui.timezone, format, locale, updateTimes],
|
||||||
);
|
);
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
@ -170,18 +180,28 @@ export function EventsPerSecondsLineGraph({
|
|||||||
[data],
|
[data],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const locale = useDateLocale();
|
||||||
|
const { t } = useTranslation(["common"]);
|
||||||
|
|
||||||
|
const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
||||||
|
const format = useMemo(() => {
|
||||||
|
return t(`time.formattedTimestampHourMinute.${timeFormat}`, {
|
||||||
|
ns: "common",
|
||||||
|
});
|
||||||
|
}, [t, timeFormat]);
|
||||||
|
|
||||||
const formatTime = useCallback(
|
const formatTime = useCallback(
|
||||||
(val: unknown) => {
|
(val: unknown) => {
|
||||||
return formatUnixTimestampToDateTime(
|
return formatUnixTimestampToDateTime(
|
||||||
updateTimes[Math.round(val as number) - 1],
|
updateTimes[Math.round(val as number) - 1],
|
||||||
{
|
{
|
||||||
timezone: config?.ui.timezone,
|
timezone: config?.ui.timezone,
|
||||||
strftime_fmt:
|
date_format: format,
|
||||||
config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
|
locale,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[config, updateTimes],
|
[config?.ui.timezone, format, locale, updateTimes],
|
||||||
);
|
);
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { useTheme } from "@/context/theme-provider";
|
import { useTheme } from "@/context/theme-provider";
|
||||||
|
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { Threshold } from "@/types/graph";
|
import { Threshold } from "@/types/graph";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import Chart from "react-apexcharts";
|
import Chart from "react-apexcharts";
|
||||||
import { isMobileOnly } from "react-device-detect";
|
import { isMobileOnly } from "react-device-detect";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
type ThresholdBarGraphProps = {
|
type ThresholdBarGraphProps = {
|
||||||
@ -45,6 +47,16 @@ export function ThresholdBarGraph({
|
|||||||
|
|
||||||
const { theme, systemTheme } = useTheme();
|
const { theme, systemTheme } = useTheme();
|
||||||
|
|
||||||
|
const locale = useDateLocale();
|
||||||
|
const { t } = useTranslation(["common"]);
|
||||||
|
|
||||||
|
const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
||||||
|
const format = useMemo(() => {
|
||||||
|
return t(`time.formattedTimestampHourMinute.${timeFormat}`, {
|
||||||
|
ns: "common",
|
||||||
|
});
|
||||||
|
}, [t, timeFormat]);
|
||||||
|
|
||||||
const formatTime = useCallback(
|
const formatTime = useCallback(
|
||||||
(val: unknown) => {
|
(val: unknown) => {
|
||||||
const dateIndex = Math.round(val as number);
|
const dateIndex = Math.round(val as number);
|
||||||
@ -53,17 +65,16 @@ export function ThresholdBarGraph({
|
|||||||
if (dateIndex < 0) {
|
if (dateIndex < 0) {
|
||||||
timeOffset = 5 * Math.abs(dateIndex);
|
timeOffset = 5 * Math.abs(dateIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatUnixTimestampToDateTime(
|
return formatUnixTimestampToDateTime(
|
||||||
updateTimes[Math.max(1, dateIndex) - 1] - timeOffset,
|
updateTimes[Math.max(1, dateIndex) - 1] - timeOffset,
|
||||||
{
|
{
|
||||||
timezone: config?.ui.timezone,
|
timezone: config?.ui.timezone,
|
||||||
strftime_fmt:
|
date_format: format,
|
||||||
config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
|
locale,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[config, updateTimes],
|
[config?.ui.timezone, format, locale, updateTimes],
|
||||||
);
|
);
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
|
|||||||
@ -72,7 +72,7 @@ export function LogChip({ severity, onClickSeverity }: LogChipProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="min-w-16 lg:min-w-20">
|
<div className="min-w-16 lg:min-w-20">
|
||||||
<span
|
<span
|
||||||
className={`rounded-md px-1 py-[1px] text-xs capitalize ${onClickSeverity ? "cursor-pointer" : ""} ${severityClassName}`}
|
className={`rounded-md px-1 py-[1px] text-xs smart-capitalize ${onClickSeverity ? "cursor-pointer" : ""} ${severityClassName}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
|||||||
@ -816,7 +816,7 @@ export default function InputWithTags({
|
|||||||
.map((value, index) => (
|
.map((value, index) => (
|
||||||
<span
|
<span
|
||||||
key={`${filterType}-${index}`}
|
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"
|
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800 smart-capitalize"
|
||||||
>
|
>
|
||||||
{t("filter.label." + filterType)}:{" "}
|
{t("filter.label." + filterType)}:{" "}
|
||||||
{filterType === "labels"
|
{filterType === "labels"
|
||||||
@ -838,7 +838,7 @@ export default function InputWithTags({
|
|||||||
: !(filterType == "event_id" && isSimilaritySearch) && (
|
: !(filterType == "event_id" && isSimilaritySearch) && (
|
||||||
<span
|
<span
|
||||||
key={filterType}
|
key={filterType}
|
||||||
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm capitalize text-green-800"
|
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800 smart-capitalize"
|
||||||
>
|
>
|
||||||
{filterType === "event_id"
|
{filterType === "event_id"
|
||||||
? t("trackedObjectId")
|
? t("trackedObjectId")
|
||||||
|
|||||||
@ -45,6 +45,7 @@ import {
|
|||||||
useNotificationSuspend,
|
useNotificationSuspend,
|
||||||
} from "@/api/ws";
|
} from "@/api/ws";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||||
|
|
||||||
type LiveContextMenuProps = {
|
type LiveContextMenuProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -235,18 +236,25 @@ export default function LiveContextMenu({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const locale = useDateLocale();
|
||||||
|
|
||||||
const formatSuspendedUntil = (timestamp: string) => {
|
const formatSuspendedUntil = (timestamp: string) => {
|
||||||
// Some languages require a change in word order
|
// Some languages require a change in word order
|
||||||
if (timestamp === "0") return t("time.untilForRestart", { ns: "common" });
|
if (timestamp === "0") return t("time.untilForRestart", { ns: "common" });
|
||||||
|
|
||||||
const time = formatUnixTimestampToDateTime(Number.parseInt(timestamp), {
|
const time = formatUnixTimestampToDateTime(parseInt(timestamp), {
|
||||||
time_style: "medium",
|
time_style: "medium",
|
||||||
date_style: "medium",
|
date_style: "medium",
|
||||||
timezone: config?.ui.timezone,
|
timezone: config?.ui.timezone,
|
||||||
strftime_fmt:
|
date_format:
|
||||||
config?.ui.time_format == "24hour"
|
config?.ui.time_format == "24hour"
|
||||||
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
|
? t("time.formattedTimestampMonthDayHourMinute.24hour", {
|
||||||
: t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }),
|
ns: "common",
|
||||||
|
})
|
||||||
|
: t("time.formattedTimestampMonthDayHourMinute.12hour", {
|
||||||
|
ns: "common",
|
||||||
|
}),
|
||||||
|
locale: locale,
|
||||||
});
|
});
|
||||||
return t("time.untilForTime", { ns: "common", time });
|
return t("time.untilForTime", { ns: "common", time });
|
||||||
};
|
};
|
||||||
@ -257,7 +265,7 @@ export default function LiveContextMenu({
|
|||||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<div className="flex flex-col items-start gap-1 py-1 pl-2">
|
<div className="flex flex-col items-start gap-1 py-1 pl-2">
|
||||||
<div className="text-md capitalize text-primary-variant">
|
<div className="text-md text-primary-variant smart-capitalize">
|
||||||
{camera.replaceAll("_", " ")}
|
{camera.replaceAll("_", " ")}
|
||||||
</div>
|
</div>
|
||||||
{preferredLiveMode == "jsmpeg" && isRestreamed && (
|
{preferredLiveMode == "jsmpeg" && isRestreamed && (
|
||||||
|
|||||||
@ -83,7 +83,7 @@ export default function CameraInfoDialog({
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="capitalize">
|
<DialogTitle className="smart-capitalize">
|
||||||
{t("cameras.info.cameraProbeInfo", {
|
{t("cameras.info.cameraProbeInfo", {
|
||||||
camera: camera.name.replaceAll("_", " "),
|
camera: camera.name.replaceAll("_", " "),
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -288,7 +288,7 @@ export function ExportContent({
|
|||||||
id={opt}
|
id={opt}
|
||||||
value={opt}
|
value={opt}
|
||||||
/>
|
/>
|
||||||
<Label className="cursor-pointer capitalize" htmlFor={opt}>
|
<Label className="cursor-pointer smart-capitalize" htmlFor={opt}>
|
||||||
{isNaN(parseInt(opt))
|
{isNaN(parseInt(opt))
|
||||||
? opt == "timeline"
|
? opt == "timeline"
|
||||||
? t("export.time.fromTimeline")
|
? t("export.time.fromTimeline")
|
||||||
|
|||||||
@ -91,7 +91,7 @@ export default function FaceSelectionDialog({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SelectorItem
|
<SelectorItem
|
||||||
className="flex cursor-pointer gap-2 capitalize"
|
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||||
onClick={() => setNewFace(true)}
|
onClick={() => setNewFace(true)}
|
||||||
>
|
>
|
||||||
<LuPlus />
|
<LuPlus />
|
||||||
@ -100,7 +100,7 @@ export default function FaceSelectionDialog({
|
|||||||
{faceNames.map((faceName) => (
|
{faceNames.map((faceName) => (
|
||||||
<SelectorItem
|
<SelectorItem
|
||||||
key={faceName}
|
key={faceName}
|
||||||
className="flex cursor-pointer gap-2 capitalize"
|
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||||
onClick={() => onTrainAttempt(faceName)}
|
onClick={() => onTrainAttempt(faceName)}
|
||||||
>
|
>
|
||||||
<LuScanFace />
|
<LuScanFace />
|
||||||
|
|||||||
@ -26,7 +26,7 @@ export default function MobileCameraDrawer({
|
|||||||
<Drawer open={cameraDrawer} onOpenChange={setCameraDrawer}>
|
<Drawer open={cameraDrawer} onOpenChange={setCameraDrawer}>
|
||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className="rounded-lg capitalize"
|
className="rounded-lg smart-capitalize"
|
||||||
aria-label={t("menu.live.cameras.title")}
|
aria-label={t("menu.live.cameras.title")}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
@ -38,7 +38,7 @@ export default function MobileCameraDrawer({
|
|||||||
{allCameras.map((cam) => (
|
{allCameras.map((cam) => (
|
||||||
<div
|
<div
|
||||||
key={cam}
|
key={cam}
|
||||||
className={`mx-4 w-full py-2 text-center capitalize ${cam == selected ? "rounded-lg bg-secondary" : ""}`}
|
className={`mx-4 w-full py-2 text-center smart-capitalize ${cam == selected ? "rounded-lg bg-secondary" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelectCamera(cam);
|
onSelectCamera(cam);
|
||||||
setCameraDrawer(false);
|
setCameraDrawer(false);
|
||||||
|
|||||||
@ -324,7 +324,7 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
>
|
>
|
||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className="rounded-lg capitalize"
|
className="rounded-lg smart-capitalize"
|
||||||
aria-label={t("filters")}
|
aria-label={t("filters")}
|
||||||
variant={
|
variant={
|
||||||
filter?.labels || filter?.after || filter?.zones
|
filter?.labels || filter?.after || filter?.zones
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export default function MobileTimelineDrawer({
|
|||||||
<Drawer open={drawer} onOpenChange={setDrawer}>
|
<Drawer open={drawer} onOpenChange={setDrawer}>
|
||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className="rounded-lg capitalize"
|
className="rounded-lg smart-capitalize"
|
||||||
aria-label="Select timeline or events list"
|
aria-label="Select timeline or events list"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
@ -32,7 +32,7 @@ export default function MobileTimelineDrawer({
|
|||||||
</DrawerTrigger>
|
</DrawerTrigger>
|
||||||
<DrawerContent className="mx-1 flex max-h-[75dvh] flex-col items-center gap-2 overflow-hidden rounded-t-2xl px-4 pb-4">
|
<DrawerContent className="mx-1 flex max-h-[75dvh] flex-col items-center gap-2 overflow-hidden rounded-t-2xl px-4 pb-4">
|
||||||
<div
|
<div
|
||||||
className={`mx-4 w-full py-2 text-center capitalize ${selected == "timeline" ? "rounded-lg bg-secondary" : ""}`}
|
className={`mx-4 w-full py-2 text-center smart-capitalize ${selected == "timeline" ? "rounded-lg bg-secondary" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelect("timeline");
|
onSelect("timeline");
|
||||||
setDrawer(false);
|
setDrawer(false);
|
||||||
@ -41,7 +41,7 @@ export default function MobileTimelineDrawer({
|
|||||||
Timeline
|
Timeline
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`mx-4 w-full py-2 text-center capitalize ${selected == "events" ? "rounded-lg bg-secondary" : ""}`}
|
className={`mx-4 w-full py-2 text-center smart-capitalize ${selected == "events" ? "rounded-lg bg-secondary" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelect("events");
|
onSelect("events");
|
||||||
setDrawer(false);
|
setDrawer(false);
|
||||||
|
|||||||
@ -572,13 +572,13 @@ export default function ObjectLifecycle({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-3 text-lg">
|
<div className="mx-3 text-lg">
|
||||||
<div className="flex flex-row items-center capitalize text-primary">
|
<div className="flex flex-row items-center text-primary smart-capitalize">
|
||||||
{getLifecycleItemDescription(item)}
|
{getLifecycleItemDescription(item)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-primary-variant">
|
<div className="text-sm text-primary-variant">
|
||||||
{formatUnixTimestampToDateTime(item.timestamp, {
|
{formatUnixTimestampToDateTime(item.timestamp, {
|
||||||
timezone: config.ui.timezone,
|
timezone: config.ui.timezone,
|
||||||
strftime_fmt:
|
date_format:
|
||||||
config.ui.time_format == "24hour"
|
config.ui.time_format == "24hour"
|
||||||
? t("time.formattedTimestamp2.24hour", {
|
? t("time.formattedTimestamp2.24hour", {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
@ -616,7 +616,7 @@ export default function ObjectLifecycle({
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="cursor-pointer capitalize"
|
className="cursor-pointer smart-capitalize"
|
||||||
onClick={() => setSelectedZone(zone)}
|
onClick={() => setSelectedZone(zone)}
|
||||||
>
|
>
|
||||||
{zone.replaceAll("_", " ")}
|
{zone.replaceAll("_", " ")}
|
||||||
@ -722,7 +722,7 @@ export default function ObjectLifecycle({
|
|||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent className="capitalize">
|
<TooltipContent className="smart-capitalize">
|
||||||
{getLifecycleItemDescription(item)}
|
{getLifecycleItemDescription(item)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
|
|||||||
@ -100,7 +100,7 @@ export function ObjectPath({
|
|||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent side="top" className="capitalize">
|
<TooltipContent side="top" className="smart-capitalize">
|
||||||
{pos.lifecycle_item
|
{pos.lifecycle_item
|
||||||
? getLifecycleItemDescription(pos.lifecycle_item)
|
? getLifecycleItemDescription(pos.lifecycle_item)
|
||||||
: "Tracked point"}
|
: "Tracked point"}
|
||||||
|
|||||||
@ -223,7 +223,9 @@ export default function ObjectPathPlotter() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
<strong className="mr-1 capitalize">{event.label}</strong>
|
<strong className="mr-1 smart-capitalize">
|
||||||
|
{event.label}
|
||||||
|
</strong>
|
||||||
{formatUnixTimestampToDateTime(event.start_time, {
|
{formatUnixTimestampToDateTime(event.start_time, {
|
||||||
timezone: config?.ui.timezone,
|
timezone: config?.ui.timezone,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -96,8 +96,12 @@ export default function ReviewDetailDialog({
|
|||||||
const formattedDate = useFormattedTimestamp(
|
const formattedDate = useFormattedTimestamp(
|
||||||
review?.start_time ?? 0,
|
review?.start_time ?? 0,
|
||||||
config?.ui.time_format == "24hour"
|
config?.ui.time_format == "24hour"
|
||||||
? t("time.formattedTimestampWithYear.24hour", { ns: "common" })
|
? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
|
||||||
: t("time.formattedTimestampWithYear.12hour", { ns: "common" }),
|
ns: "common",
|
||||||
|
})
|
||||||
|
: t("time.formattedTimestampMonthDayYearHourMinute.12hour", {
|
||||||
|
ns: "common",
|
||||||
|
}),
|
||||||
config?.ui.timezone,
|
config?.ui.timezone,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -229,7 +233,7 @@ export default function ReviewDetailDialog({
|
|||||||
<div className="text-sm text-primary/40">
|
<div className="text-sm text-primary/40">
|
||||||
{t("details.camera")}
|
{t("details.camera")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm capitalize">
|
<div className="text-sm smart-capitalize">
|
||||||
{review.camera.replaceAll("_", " ")}
|
{review.camera.replaceAll("_", " ")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -245,12 +249,12 @@ export default function ReviewDetailDialog({
|
|||||||
<div className="text-sm text-primary/40">
|
<div className="text-sm text-primary/40">
|
||||||
{t("details.objects")}
|
{t("details.objects")}
|
||||||
</div>
|
</div>
|
||||||
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm capitalize">
|
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm smart-capitalize">
|
||||||
{events?.map((event) => {
|
{events?.map((event) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
className="flex flex-row items-center gap-2 capitalize"
|
className="flex flex-row items-center gap-2 smart-capitalize"
|
||||||
>
|
>
|
||||||
{getIconForLabel(
|
{getIconForLabel(
|
||||||
event.label,
|
event.label,
|
||||||
@ -286,12 +290,12 @@ export default function ReviewDetailDialog({
|
|||||||
<div className="text-sm text-primary/40">
|
<div className="text-sm text-primary/40">
|
||||||
{t("details.zones")}
|
{t("details.zones")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-start gap-2 text-sm capitalize">
|
<div className="flex flex-col items-start gap-2 text-sm smart-capitalize">
|
||||||
{review.data.zones.map((zone) => {
|
{review.data.zones.map((zone) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={zone}
|
key={zone}
|
||||||
className="flex flex-row items-center gap-2 capitalize"
|
className="flex flex-row items-center gap-2 smart-capitalize"
|
||||||
>
|
>
|
||||||
{zone.replaceAll("_", " ")}
|
{zone.replaceAll("_", " ")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -231,7 +231,7 @@ export default function SearchDetailDialog({
|
|||||||
{item == "object_lifecycle" && (
|
{item == "object_lifecycle" && (
|
||||||
<FaRotate className="size-4" />
|
<FaRotate className="size-4" />
|
||||||
)}
|
)}
|
||||||
<div className="capitalize">{t(`type.${item}`)}</div>
|
<div className="smart-capitalize">{t(`type.${item}`)}</div>
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
))}
|
))}
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
@ -320,8 +320,12 @@ function ObjectDetailsTab({
|
|||||||
const formattedDate = useFormattedTimestamp(
|
const formattedDate = useFormattedTimestamp(
|
||||||
search?.start_time ?? 0,
|
search?.start_time ?? 0,
|
||||||
config?.ui.time_format == "24hour"
|
config?.ui.time_format == "24hour"
|
||||||
? t("time.formattedTimestampWithYear.24hour", { ns: "common" })
|
? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
|
||||||
: t("time.formattedTimestampWithYear.12hour", { ns: "common" }),
|
ns: "common",
|
||||||
|
})
|
||||||
|
: t("time.formattedTimestampMonthDayYearHourMinute.12hour", {
|
||||||
|
ns: "common",
|
||||||
|
}),
|
||||||
config?.ui.timezone,
|
config?.ui.timezone,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -707,7 +711,7 @@ function ObjectDetailsTab({
|
|||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="text-sm text-primary/40">{t("details.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">
|
<div className="flex flex-row items-center gap-2 text-sm smart-capitalize">
|
||||||
{getIconForLabel(search.label, "size-4 text-primary")}
|
{getIconForLabel(search.label, "size-4 text-primary")}
|
||||||
{t(search.label, {
|
{t(search.label, {
|
||||||
ns: "objects",
|
ns: "objects",
|
||||||
@ -827,7 +831,7 @@ function ObjectDetailsTab({
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="text-sm text-primary/40">{t("details.camera")}</div>
|
<div className="text-sm text-primary/40">{t("details.camera")}</div>
|
||||||
<div className="text-sm capitalize">
|
<div className="text-sm smart-capitalize">
|
||||||
{search.camera.replaceAll("_", " ")}
|
{search.camera.replaceAll("_", " ")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -361,7 +361,7 @@ export default function LivePlayer({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</div>
|
</div>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent className="capitalize">
|
<TooltipContent className="smart-capitalize">
|
||||||
{[
|
{[
|
||||||
...new Set([
|
...new Set([
|
||||||
...(objects || []).map(({ label, sub_label }) =>
|
...(objects || []).map(({ label, sub_label }) =>
|
||||||
|
|||||||
@ -170,8 +170,8 @@ export default function PreviewThumbnailPlayer({
|
|||||||
const formattedDate = useFormattedTimestamp(
|
const formattedDate = useFormattedTimestamp(
|
||||||
review.start_time,
|
review.start_time,
|
||||||
config?.ui.time_format == "24hour"
|
config?.ui.time_format == "24hour"
|
||||||
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
|
? t("time.formattedTimestampMonthDayHourMinute.24hour", { ns: "common" })
|
||||||
: t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }),
|
: t("time.formattedTimestampMonthDayHourMinute.12hour", { ns: "common" }),
|
||||||
config?.ui?.timezone,
|
config?.ui?.timezone,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -262,7 +262,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</div>
|
</div>
|
||||||
<TooltipContent className="capitalize">
|
<TooltipContent className="smart-capitalize">
|
||||||
{[
|
{[
|
||||||
...new Set([
|
...new Set([
|
||||||
...(review.data.objects || []),
|
...(review.data.objects || []),
|
||||||
|
|||||||
@ -185,7 +185,7 @@ export function CameraStreamingDialog({
|
|||||||
return (
|
return (
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader className="mb-4">
|
<DialogHeader className="mb-4">
|
||||||
<DialogTitle className="capitalize">
|
<DialogTitle className="smart-capitalize">
|
||||||
{t("group.camera.setting.title", {
|
{t("group.camera.setting.title", {
|
||||||
cameraName: camera.replaceAll("_", " "),
|
cameraName: camera.replaceAll("_", " "),
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -222,7 +222,7 @@ export default function PolygonItem({
|
|||||||
saveToConfig(polygon);
|
saveToConfig(polygon);
|
||||||
addMessage(
|
addMessage(
|
||||||
"masks_zones",
|
"masks_zones",
|
||||||
"Restart required (masks/zones changed)",
|
t("masksAndZones.restart_required"),
|
||||||
undefined,
|
undefined,
|
||||||
"masks_zones",
|
"masks_zones",
|
||||||
);
|
);
|
||||||
|
|||||||
@ -933,7 +933,7 @@ export function ZoneObjectSelector({
|
|||||||
{allLabels.map((item) => (
|
{allLabels.map((item) => (
|
||||||
<div key={item} className="flex items-center justify-between">
|
<div key={item} className="flex items-center justify-between">
|
||||||
<Label
|
<Label
|
||||||
className="w-full cursor-pointer capitalize text-primary"
|
className="w-full cursor-pointer text-primary smart-capitalize"
|
||||||
htmlFor={item}
|
htmlFor={item}
|
||||||
>
|
>
|
||||||
{t(item, { ns: "objects" })}
|
{t(item, { ns: "objects" })}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
|
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
type MinimapSegmentProps = {
|
type MinimapSegmentProps = {
|
||||||
@ -34,6 +35,24 @@ export function MinimapBounds({
|
|||||||
dense,
|
dense,
|
||||||
}: MinimapSegmentProps) {
|
}: MinimapSegmentProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const { t } = useTranslation(["common"]);
|
||||||
|
const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
||||||
|
|
||||||
|
const formatKey = dense
|
||||||
|
? `time.formattedTimestampHourMinute.${timeFormat}`
|
||||||
|
: `time.formattedTimestampMonthDayHourMinute.${timeFormat}`;
|
||||||
|
|
||||||
|
const formattedStartTime = useFormattedTimestamp(
|
||||||
|
alignedMinimapStartTime,
|
||||||
|
t(formatKey),
|
||||||
|
config?.ui.timezone,
|
||||||
|
);
|
||||||
|
|
||||||
|
const formattedEndTime = useFormattedTimestamp(
|
||||||
|
alignedMinimapEndTime,
|
||||||
|
t(formatKey),
|
||||||
|
config?.ui.timezone,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -42,23 +61,13 @@ export function MinimapBounds({
|
|||||||
className="pointer-events-none absolute inset-0 -bottom-7 z-20 flex w-full select-none scroll-mt-8 items-center justify-center text-center text-[10px] font-medium text-primary"
|
className="pointer-events-none absolute inset-0 -bottom-7 z-20 flex w-full select-none scroll-mt-8 items-center justify-center text-center text-[10px] font-medium text-primary"
|
||||||
ref={firstMinimapSegmentRef}
|
ref={firstMinimapSegmentRef}
|
||||||
>
|
>
|
||||||
{formatUnixTimestampToDateTime(alignedMinimapStartTime, {
|
{formattedStartTime}
|
||||||
timezone: config?.ui.timezone,
|
|
||||||
strftime_fmt: !dense
|
|
||||||
? `%b %d, ${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`
|
|
||||||
: `${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`,
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLastSegmentInMinimap && (
|
{isLastSegmentInMinimap && (
|
||||||
<div className="pointer-events-none absolute inset-0 -top-3 z-20 flex w-full select-none items-center justify-center text-center text-[10px] font-medium text-primary">
|
<div className="pointer-events-none absolute inset-0 -top-3 z-20 flex w-full select-none items-center justify-center text-center text-[10px] font-medium text-primary">
|
||||||
{formatUnixTimestampToDateTime(alignedMinimapEndTime, {
|
{formattedEndTime}
|
||||||
timezone: config?.ui.timezone,
|
|
||||||
strftime_fmt: !dense
|
|
||||||
? `%b %d, ${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`
|
|
||||||
: `${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`,
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -92,27 +101,28 @@ export function Timestamp({
|
|||||||
timestampSpread,
|
timestampSpread,
|
||||||
segmentKey,
|
segmentKey,
|
||||||
}: TimestampSegmentProps) {
|
}: TimestampSegmentProps) {
|
||||||
|
const { t } = useTranslation(["common"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
const formattedTimestamp = useMemo(() => {
|
const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
||||||
if (
|
const format = t(`time.formattedTimestampHourMinute.${timeFormat}`);
|
||||||
!(
|
|
||||||
timestamp.getMinutes() % timestampSpread === 0 &&
|
|
||||||
timestamp.getSeconds() === 0
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatUnixTimestampToDateTime(timestamp.getTime() / 1000, {
|
const formattedTimestamp = useFormattedTimestamp(
|
||||||
timezone: config?.ui.timezone,
|
timestamp.getTime() / 1000,
|
||||||
strftime_fmt: config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
|
format,
|
||||||
});
|
config?.ui.timezone,
|
||||||
}, [config, timestamp, timestampSpread]);
|
);
|
||||||
|
|
||||||
|
const shouldDisplay = useMemo(() => {
|
||||||
|
return (
|
||||||
|
timestamp.getMinutes() % timestampSpread === 0 &&
|
||||||
|
timestamp.getSeconds() === 0
|
||||||
|
);
|
||||||
|
}, [timestamp, timestampSpread]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute left-[15px] z-10 h-[8px]">
|
<div className="absolute left-[15px] z-10 h-[8px]">
|
||||||
{!isFirstSegmentInMinimap && !isLastSegmentInMinimap && (
|
{!isFirstSegmentInMinimap && !isLastSegmentInMinimap && shouldDisplay && (
|
||||||
<div
|
<div
|
||||||
key={`${segmentKey}_timestamp`}
|
key={`${segmentKey}_timestamp`}
|
||||||
className="pointer-events-none select-none text-[8px] text-neutral_variant dark:text-neutral"
|
className="pointer-events-none select-none text-[8px] text-neutral_variant dark:text-neutral"
|
||||||
|
|||||||
@ -1,69 +1,18 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { DayPicker } from "react-day-picker";
|
import { DayPicker } from "react-day-picker";
|
||||||
import { Locale, enUS } from "date-fns/locale";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import i18n from "@/utils/i18n";
|
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||||
|
|
||||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||||
|
|
||||||
// Map of locale codes to dynamic import functions
|
|
||||||
const localeMap: Record<string, () => Promise<Locale>> = {
|
|
||||||
"zh-CN": () => import("date-fns/locale/zh-CN").then((module) => module.zhCN),
|
|
||||||
es: () => import("date-fns/locale/es").then((module) => module.es),
|
|
||||||
hi: () => import("date-fns/locale/hi").then((module) => module.hi),
|
|
||||||
fr: () => import("date-fns/locale/fr").then((module) => module.fr),
|
|
||||||
ar: () => import("date-fns/locale/ar").then((module) => module.ar),
|
|
||||||
pt: () => import("date-fns/locale/pt").then((module) => module.pt),
|
|
||||||
ru: () => import("date-fns/locale/ru").then((module) => module.ru),
|
|
||||||
de: () => import("date-fns/locale/de").then((module) => module.de),
|
|
||||||
ja: () => import("date-fns/locale/ja").then((module) => module.ja),
|
|
||||||
tr: () => import("date-fns/locale/tr").then((module) => module.tr),
|
|
||||||
it: () => import("date-fns/locale/it").then((module) => module.it),
|
|
||||||
nl: () => import("date-fns/locale/nl").then((module) => module.nl),
|
|
||||||
sv: () => import("date-fns/locale/sv").then((module) => module.sv),
|
|
||||||
cs: () => import("date-fns/locale/cs").then((module) => module.cs),
|
|
||||||
nb: () => import("date-fns/locale/nb").then((module) => module.nb),
|
|
||||||
ko: () => import("date-fns/locale/ko").then((module) => module.ko),
|
|
||||||
vi: () => import("date-fns/locale/vi").then((module) => module.vi),
|
|
||||||
fa: () => import("date-fns/locale/fa-IR").then((module) => module.faIR),
|
|
||||||
pl: () => import("date-fns/locale/pl").then((module) => module.pl),
|
|
||||||
uk: () => import("date-fns/locale/uk").then((module) => module.uk),
|
|
||||||
he: () => import("date-fns/locale/he").then((module) => module.he),
|
|
||||||
el: () => import("date-fns/locale/el").then((module) => module.el),
|
|
||||||
ro: () => import("date-fns/locale/ro").then((module) => module.ro),
|
|
||||||
hu: () => import("date-fns/locale/hu").then((module) => module.hu),
|
|
||||||
fi: () => import("date-fns/locale/fi").then((module) => module.fi),
|
|
||||||
da: () => import("date-fns/locale/da").then((module) => module.da),
|
|
||||||
sk: () => import("date-fns/locale/sk").then((module) => module.sk),
|
|
||||||
};
|
|
||||||
|
|
||||||
function Calendar({
|
function Calendar({
|
||||||
className,
|
className,
|
||||||
classNames,
|
classNames,
|
||||||
showOutsideDays = true,
|
showOutsideDays = true,
|
||||||
...props
|
...props
|
||||||
}: CalendarProps) {
|
}: CalendarProps) {
|
||||||
const [locale, setLocale] = useState<Locale>(enUS);
|
const locale = useDateLocale();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadLocale = async () => {
|
|
||||||
if (i18n.language === "en") {
|
|
||||||
setLocale(enUS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const localeLoader = localeMap[i18n.language];
|
|
||||||
if (localeLoader) {
|
|
||||||
const loadedLocale = await localeLoader();
|
|
||||||
setLocale(loadedLocale);
|
|
||||||
} else {
|
|
||||||
setLocale(enUS);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadLocale();
|
|
||||||
}, [i18n.language]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DayPicker
|
<DayPicker
|
||||||
|
|||||||
64
web/src/hooks/use-date-locale.ts
Normal file
64
web/src/hooks/use-date-locale.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { enUS, Locale } from "date-fns/locale";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
// Map of locale codes to dynamic import functions
|
||||||
|
const localeMap: Record<string, () => Promise<Locale>> = {
|
||||||
|
"zh-CN": () => import("date-fns/locale/zh-CN").then((module) => module.zhCN),
|
||||||
|
es: () => import("date-fns/locale/es").then((module) => module.es),
|
||||||
|
hi: () => import("date-fns/locale/hi").then((module) => module.hi),
|
||||||
|
fr: () => import("date-fns/locale/fr").then((module) => module.fr),
|
||||||
|
ar: () => import("date-fns/locale/ar").then((module) => module.ar),
|
||||||
|
pt: () => import("date-fns/locale/pt").then((module) => module.pt),
|
||||||
|
ru: () => import("date-fns/locale/ru").then((module) => module.ru),
|
||||||
|
de: () => import("date-fns/locale/de").then((module) => module.de),
|
||||||
|
ja: () => import("date-fns/locale/ja").then((module) => module.ja),
|
||||||
|
tr: () => import("date-fns/locale/tr").then((module) => module.tr),
|
||||||
|
it: () => import("date-fns/locale/it").then((module) => module.it),
|
||||||
|
nl: () => import("date-fns/locale/nl").then((module) => module.nl),
|
||||||
|
sv: () => import("date-fns/locale/sv").then((module) => module.sv),
|
||||||
|
cs: () => import("date-fns/locale/cs").then((module) => module.cs),
|
||||||
|
nb: () => import("date-fns/locale/nb").then((module) => module.nb),
|
||||||
|
ko: () => import("date-fns/locale/ko").then((module) => module.ko),
|
||||||
|
vi: () => import("date-fns/locale/vi").then((module) => module.vi),
|
||||||
|
fa: () => import("date-fns/locale/fa-IR").then((module) => module.faIR),
|
||||||
|
pl: () => import("date-fns/locale/pl").then((module) => module.pl),
|
||||||
|
uk: () => import("date-fns/locale/uk").then((module) => module.uk),
|
||||||
|
he: () => import("date-fns/locale/he").then((module) => module.he),
|
||||||
|
el: () => import("date-fns/locale/el").then((module) => module.el),
|
||||||
|
ro: () => import("date-fns/locale/ro").then((module) => module.ro),
|
||||||
|
hu: () => import("date-fns/locale/hu").then((module) => module.hu),
|
||||||
|
fi: () => import("date-fns/locale/fi").then((module) => module.fi),
|
||||||
|
da: () => import("date-fns/locale/da").then((module) => module.da),
|
||||||
|
sk: () => import("date-fns/locale/sk").then((module) => module.sk),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDateLocale(): Locale {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const [locale, setLocale] = useState<Locale>(enUS);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLocale = async () => {
|
||||||
|
if (i18n.language === "en") {
|
||||||
|
setLocale(enUS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localeLoader = localeMap[i18n.language];
|
||||||
|
if (localeLoader) {
|
||||||
|
try {
|
||||||
|
const loadedLocale = await localeLoader();
|
||||||
|
setLocale(loadedLocale);
|
||||||
|
} catch (error) {
|
||||||
|
setLocale(enUS);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setLocale(enUS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLocale();
|
||||||
|
}, [i18n.language]);
|
||||||
|
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
@ -1,33 +1,42 @@
|
|||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||||
|
|
||||||
export function useFormattedTimestamp(
|
export function useFormattedTimestamp(
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
format: string,
|
format: string,
|
||||||
timezone?: string,
|
timezone?: string,
|
||||||
) {
|
) {
|
||||||
|
const locale = useDateLocale();
|
||||||
|
|
||||||
const formattedTimestamp = useMemo(() => {
|
const formattedTimestamp = useMemo(() => {
|
||||||
return formatUnixTimestampToDateTime(timestamp, {
|
return formatUnixTimestampToDateTime(timestamp, {
|
||||||
timezone,
|
timezone,
|
||||||
strftime_fmt: format,
|
date_format: format,
|
||||||
|
locale,
|
||||||
});
|
});
|
||||||
}, [format, timestamp, timezone]);
|
}, [format, timestamp, timezone, locale]);
|
||||||
|
|
||||||
return formattedTimestamp;
|
return formattedTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFormattedRange(start: number, end: number, format: string) {
|
export function useFormattedRange(start: number, end: number, format: string) {
|
||||||
|
const locale = useDateLocale();
|
||||||
|
|
||||||
const formattedStart = useMemo(() => {
|
const formattedStart = useMemo(() => {
|
||||||
return formatUnixTimestampToDateTime(start, {
|
return formatUnixTimestampToDateTime(start, {
|
||||||
strftime_fmt: format,
|
date_format: format,
|
||||||
|
locale,
|
||||||
});
|
});
|
||||||
}, [format, start]);
|
}, [format, start, locale]);
|
||||||
|
|
||||||
const formattedEnd = useMemo(() => {
|
const formattedEnd = useMemo(() => {
|
||||||
return formatUnixTimestampToDateTime(end, {
|
return formatUnixTimestampToDateTime(end, {
|
||||||
strftime_fmt: format,
|
date_format: format,
|
||||||
|
locale,
|
||||||
});
|
});
|
||||||
}, [format, end]);
|
}, [format, end, locale]);
|
||||||
|
|
||||||
return `${formattedStart} - ${formattedEnd}`;
|
return `${formattedStart} - ${formattedEnd}`;
|
||||||
}
|
}
|
||||||
@ -44,7 +53,7 @@ export function useTimezone(config: FrigateConfig | undefined) {
|
|||||||
}, [config]);
|
}, [config]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function use24HourTime(config: FrigateConfig | undefined) {
|
export function use24HourTime(config: FrigateConfig | undefined) {
|
||||||
const localeUses24HourTime = useMemo(
|
const localeUses24HourTime = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new Intl.DateTimeFormat(undefined, {
|
new Intl.DateTimeFormat(undefined, {
|
||||||
@ -60,8 +69,8 @@ function use24HourTime(config: FrigateConfig | undefined) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.ui.time_format != "browser") {
|
if (config.ui.time_format !== "browser") {
|
||||||
return config.ui.time_format == "24hour";
|
return config.ui.time_format === "24hour";
|
||||||
}
|
}
|
||||||
|
|
||||||
return localeUses24HourTime;
|
return localeUses24HourTime;
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { useTimelineUtils } from "./use-timeline-utils";
|
|||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
|
import { useDateLocale } from "./use-date-locale";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type DraggableElementProps = {
|
type DraggableElementProps = {
|
||||||
contentRef: React.RefObject<HTMLElement>;
|
contentRef: React.RefObject<HTMLElement>;
|
||||||
@ -162,17 +164,28 @@ function useDraggableElement({
|
|||||||
[segmentDuration, timelineStartAligned, segmentHeight],
|
[segmentDuration, timelineStartAligned, segmentHeight],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { t } = useTranslation(["common"]);
|
||||||
|
const locale = useDateLocale();
|
||||||
|
|
||||||
|
const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
||||||
|
const format = useMemo(() => {
|
||||||
|
const formatKey = `time.${
|
||||||
|
segmentDuration < 60 && !dense
|
||||||
|
? "formattedTimestampHourMinuteSecond"
|
||||||
|
: "formattedTimestampHourMinute"
|
||||||
|
}.${timeFormat}`;
|
||||||
|
return t(formatKey);
|
||||||
|
}, [t, timeFormat, segmentDuration, dense]);
|
||||||
|
|
||||||
const getFormattedTimestamp = useCallback(
|
const getFormattedTimestamp = useCallback(
|
||||||
(segmentStartTime: number) => {
|
(segmentStartTime: number) => {
|
||||||
return formatUnixTimestampToDateTime(segmentStartTime, {
|
return formatUnixTimestampToDateTime(segmentStartTime, {
|
||||||
timezone: config?.ui.timezone,
|
timezone: config?.ui.timezone,
|
||||||
strftime_fmt:
|
date_format: format,
|
||||||
config?.ui.time_format == "24hour"
|
locale,
|
||||||
? `%H:%M${segmentDuration < 60 && !dense ? ":%S" : ""}`
|
|
||||||
: `%I:%M${segmentDuration < 60 && !dense ? ":%S" : ""} %p`,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[config, dense, segmentDuration],
|
[config?.ui.timezone, format, locale],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateDraggableElementPosition = useCallback(
|
const updateDraggableElementPosition = useCallback(
|
||||||
|
|||||||
@ -151,7 +151,7 @@ function Exports() {
|
|||||||
<DialogContent
|
<DialogContent
|
||||||
className={cn("max-w-[80%]", isMobile && "landscape:max-w-[60%]")}
|
className={cn("max-w-[80%]", isMobile && "landscape:max-w-[60%]")}
|
||||||
>
|
>
|
||||||
<DialogTitle className="capitalize">
|
<DialogTitle className="smart-capitalize">
|
||||||
{selected?.name?.replaceAll("_", " ")}
|
{selected?.name?.replaceAll("_", " ")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<video
|
<video
|
||||||
|
|||||||
@ -395,7 +395,7 @@ function LibrarySelector({
|
|||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button className="flex justify-between capitalize">
|
<Button className="flex justify-between smart-capitalize">
|
||||||
{pageToggle || t("selectFace")}
|
{pageToggle || t("selectFace")}
|
||||||
<span className="ml-2 text-primary-variant">
|
<span className="ml-2 text-primary-variant">
|
||||||
({(pageToggle && faceData?.[pageToggle]?.length) || 0})
|
({(pageToggle && faceData?.[pageToggle]?.length) || 0})
|
||||||
@ -432,7 +432,7 @@ function LibrarySelector({
|
|||||||
className="group flex items-center justify-between"
|
className="group flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex-grow cursor-pointer capitalize"
|
className="flex-grow cursor-pointer smart-capitalize"
|
||||||
onClick={() => setPageToggle(face)}
|
onClick={() => setPageToggle(face)}
|
||||||
>
|
>
|
||||||
{face}
|
{face}
|
||||||
@ -531,8 +531,12 @@ function TrainingGrid({
|
|||||||
const formattedDate = useFormattedTimestamp(
|
const formattedDate = useFormattedTimestamp(
|
||||||
selectedEvent?.start_time ?? 0,
|
selectedEvent?.start_time ?? 0,
|
||||||
config?.ui.time_format == "24hour"
|
config?.ui.time_format == "24hour"
|
||||||
? t("time.formattedTimestampWithYear.24hour", { ns: "common" })
|
? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
|
||||||
: t("time.formattedTimestampWithYear.12hour", { ns: "common" }),
|
ns: "common",
|
||||||
|
})
|
||||||
|
: t("time.formattedTimestampMonthDayYearHourMinute.12hour", {
|
||||||
|
ns: "common",
|
||||||
|
}),
|
||||||
config?.ui.timezone,
|
config?.ui.timezone,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -558,7 +562,7 @@ function TrainingGrid({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="text-sm text-primary/40">{t("details.person")}</div>
|
<div className="text-sm text-primary/40">{t("details.person")}</div>
|
||||||
<div className="text-sm capitalize">
|
<div className="text-sm smart-capitalize">
|
||||||
{selectedEvent?.sub_label ?? "Unknown"}
|
{selectedEvent?.sub_label ?? "Unknown"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -567,7 +571,7 @@ function TrainingGrid({
|
|||||||
<div className="text-sm text-primary/40">
|
<div className="text-sm text-primary/40">
|
||||||
{t("details.confidence")}
|
{t("details.confidence")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm capitalize">
|
<div className="text-sm smart-capitalize">
|
||||||
{Math.round(selectedEvent?.data?.sub_label_score || 0) * 100}%
|
{Math.round(selectedEvent?.data?.sub_label_score || 0) * 100}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -690,7 +694,7 @@ function FaceAttemptGroup({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row justify-between">
|
<div className="flex flex-row justify-between">
|
||||||
<div className="select-none capitalize">
|
<div className="select-none smart-capitalize">
|
||||||
Person
|
Person
|
||||||
{event?.sub_label
|
{event?.sub_label
|
||||||
? `: ${event.sub_label} (${Math.round((event.data.sub_label_score || 0) * 100)}%)`
|
? `: ${event.sub_label} (${Math.round((event.data.sub_label_score || 0) * 100)}%)`
|
||||||
@ -869,7 +873,7 @@ function FaceAttempt({
|
|||||||
<div className="select-none p-2">
|
<div className="select-none p-2">
|
||||||
<div className="flex w-full flex-row items-center justify-between gap-2">
|
<div className="flex w-full flex-row items-center justify-between gap-2">
|
||||||
<div className="flex flex-col items-start text-xs text-primary-variant">
|
<div className="flex flex-col items-start text-xs text-primary-variant">
|
||||||
<div className="capitalize">{data.name}</div>
|
<div className="smart-capitalize">{data.name}</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"",
|
"",
|
||||||
@ -953,7 +957,7 @@ function FaceImage({ name, image, onDelete }: FaceImageProps) {
|
|||||||
<div className="rounded-b-lg bg-card p-2">
|
<div className="rounded-b-lg bg-card p-2">
|
||||||
<div className="flex w-full flex-row items-center justify-between gap-2">
|
<div className="flex w-full flex-row items-center justify-between gap-2">
|
||||||
<div className="flex flex-col items-start text-xs text-primary-variant">
|
<div className="flex flex-col items-start text-xs text-primary-variant">
|
||||||
<div className="capitalize">{name}</div>
|
<div className="smart-capitalize">{name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-start justify-end gap-5 md:gap-4">
|
<div className="flex flex-row items-start justify-end gap-5 md:gap-4">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -493,7 +493,7 @@ function Logs() {
|
|||||||
data-nav-item={item}
|
data-nav-item={item}
|
||||||
aria-label={`Select ${item}`}
|
aria-label={`Select ${item}`}
|
||||||
>
|
>
|
||||||
<div className="capitalize">{item}</div>
|
<div className="smart-capitalize">{item}</div>
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
))}
|
))}
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
@ -536,7 +536,7 @@ function Logs() {
|
|||||||
<div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
|
<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="col-span-3 lg:col-span-2">
|
||||||
<div className="flex w-full flex-row items-center">
|
<div className="flex w-full flex-row items-center">
|
||||||
<div className="ml-1 min-w-16 capitalize lg:min-w-20">
|
<div className="ml-1 min-w-16 smart-capitalize lg:min-w-20">
|
||||||
{t("logs.type.label")}
|
{t("logs.type.label")}
|
||||||
</div>
|
</div>
|
||||||
<div className="mr-3">{t("logs.type.timestamp")}</div>
|
<div className="mr-3">{t("logs.type.timestamp")}</div>
|
||||||
|
|||||||
@ -219,7 +219,7 @@ export default function Settings() {
|
|||||||
item: t("menu." + item),
|
item: t("menu." + item),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className="capitalize">{t("menu." + item)}</div>
|
<div className="smart-capitalize">{t("menu." + item)}</div>
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
))}
|
))}
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
@ -336,7 +336,7 @@ function CameraSelectButton({
|
|||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-2 bg-selected capitalize hover:bg-selected"
|
className="flex items-center gap-2 bg-selected smart-capitalize hover:bg-selected"
|
||||||
aria-label="Select a camera"
|
aria-label="Select a camera"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -93,7 +93,7 @@ function System() {
|
|||||||
{item == "storage" && <LuHardDrive className="size-4" />}
|
{item == "storage" && <LuHardDrive className="size-4" />}
|
||||||
{item == "cameras" && <FaVideo className="size-4" />}
|
{item == "cameras" && <FaVideo className="size-4" />}
|
||||||
{isDesktop && (
|
{isDesktop && (
|
||||||
<div className="capitalize">{t(item + ".title")}</div>
|
<div className="smart-capitalize">{t(item + ".title")}</div>
|
||||||
)}
|
)}
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import strftime from "strftime";
|
|
||||||
import { fromUnixTime, intervalToDuration, formatDuration } from "date-fns";
|
import { fromUnixTime, intervalToDuration, formatDuration } from "date-fns";
|
||||||
|
import { Locale } from "date-fns/locale";
|
||||||
|
import { formatInTimeZone } from "date-fns-tz";
|
||||||
export const longToDate = (long: number): Date => new Date(long * 1000);
|
export const longToDate = (long: number): Date => new Date(long * 1000);
|
||||||
export const epochToLong = (date: number): number => date / 1000;
|
export const epochToLong = (date: number): number => date / 1000;
|
||||||
export const dateToLong = (date: Date): number => epochToLong(date.getTime());
|
export const dateToLong = (date: Date): number => epochToLong(date.getTime());
|
||||||
@ -108,11 +109,19 @@ const getResolvedTimeZone = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DateTimeStyle = {
|
||||||
|
timezone?: string;
|
||||||
|
time_format?: "browser" | "12hour" | "24hour";
|
||||||
|
date_style?: "full" | "long" | "medium" | "short";
|
||||||
|
time_style?: "full" | "long" | "medium" | "short";
|
||||||
|
date_format?: string;
|
||||||
|
locale?: string | Locale;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* Formats a Unix timestamp into a human-readable date/time string.
|
* Formats a Unix timestamp into a human-readable date/time string.
|
||||||
*
|
*
|
||||||
* The format of the output string is determined by a configuration object passed as an argument, which
|
* The format of the output string is determined by a configuration object passed as an argument, which
|
||||||
* may specify a time zone, 12- or 24-hour time, and various stylistic options for the date and time.
|
* may specify a time zone, 12- or 24-hour time, various stylistic options for the date and time, and a locale.
|
||||||
* If these options are not specified, the function will use system defaults or sensible fallbacks.
|
* If these options are not specified, the function will use system defaults or sensible fallbacks.
|
||||||
*
|
*
|
||||||
* The function is robust to environments where the Intl API is not fully supported, and includes a
|
* The function is robust to environments where the Intl API is not fully supported, and includes a
|
||||||
@ -126,53 +135,71 @@ const getResolvedTimeZone = () => {
|
|||||||
*/
|
*/
|
||||||
export const formatUnixTimestampToDateTime = (
|
export const formatUnixTimestampToDateTime = (
|
||||||
unixTimestamp: number,
|
unixTimestamp: number,
|
||||||
config: {
|
config: DateTimeStyle = {},
|
||||||
timezone?: string;
|
|
||||||
time_format?: "browser" | "12hour" | "24hour";
|
|
||||||
date_style?: "full" | "long" | "medium" | "short";
|
|
||||||
time_style?: "full" | "long" | "medium" | "short";
|
|
||||||
strftime_fmt?: string;
|
|
||||||
},
|
|
||||||
): string => {
|
): string => {
|
||||||
const { timezone, time_format, date_style, time_style, strftime_fmt } =
|
const { timezone, time_format, date_style, time_style, date_format, locale } =
|
||||||
config;
|
config;
|
||||||
const locale = window.navigator?.language || "en-US";
|
|
||||||
|
// Determine the locale to use
|
||||||
|
let localeCode: string;
|
||||||
|
let dateFnsLocale: Locale | undefined;
|
||||||
|
if (typeof locale === "string") {
|
||||||
|
localeCode = locale;
|
||||||
|
} else if (locale && "code" in locale) {
|
||||||
|
localeCode = (locale as Locale).code || "en-US";
|
||||||
|
dateFnsLocale = locale as Locale;
|
||||||
|
} else {
|
||||||
|
localeCode = window.navigator?.language || "en-US";
|
||||||
|
}
|
||||||
|
|
||||||
if (isNaN(unixTimestamp)) {
|
if (isNaN(unixTimestamp)) {
|
||||||
return "Invalid time";
|
return "Invalid time";
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const date = new Date(unixTimestamp * 1000);
|
const date = new Date(unixTimestamp * 1000);
|
||||||
const resolvedTimeZone = getResolvedTimeZone();
|
|
||||||
|
|
||||||
// use strftime_fmt if defined in config
|
if (date_format) {
|
||||||
if (strftime_fmt) {
|
const resolvedTimeZone = timezone || getResolvedTimeZone();
|
||||||
const offset = getUTCOffset(date, timezone || resolvedTimeZone);
|
let formatted = formatInTimeZone(date, resolvedTimeZone, date_format, {
|
||||||
const strftime_locale = strftime.timezone(offset);
|
locale: dateFnsLocale,
|
||||||
return strftime_locale(strftime_fmt, date);
|
});
|
||||||
|
// Uppercase AM/PM for 12-hour formats
|
||||||
|
if (date_format.includes("a") || date_format.includes("aaa")) {
|
||||||
|
formatted = formatted.replace(/am|pm/gi, (match) =>
|
||||||
|
match.toUpperCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DateTime format options
|
// DateTime format options
|
||||||
const options: Intl.DateTimeFormatOptions = {
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
dateStyle: date_style,
|
dateStyle: date_style,
|
||||||
timeStyle: time_style,
|
timeStyle: time_style,
|
||||||
hour12: time_format !== "browser" ? time_format == "12hour" : undefined,
|
hour12: time_format !== "browser" ? time_format === "12hour" : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only set timeZone option when resolvedTimeZone does not match UTC±HH:MM format, or when timezone is set in config
|
// Only set timeZone option when resolvedTimeZone does not match UTC±HH:MM format, or when timezone is set in config
|
||||||
|
const resolvedTimeZone = getResolvedTimeZone();
|
||||||
const isUTCOffsetFormat = /^UTC[+-]\d{2}:\d{2}$/.test(resolvedTimeZone);
|
const isUTCOffsetFormat = /^UTC[+-]\d{2}:\d{2}$/.test(resolvedTimeZone);
|
||||||
if (timezone || !isUTCOffsetFormat) {
|
if (timezone || !isUTCOffsetFormat) {
|
||||||
options.timeZone = timezone || resolvedTimeZone;
|
options.timeZone = timezone || resolvedTimeZone;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatter = new Intl.DateTimeFormat(locale, options);
|
const formatter = new Intl.DateTimeFormat(localeCode, options);
|
||||||
const formattedDateTime = formatter.format(date);
|
let formattedDateTime = formatter.format(date);
|
||||||
|
|
||||||
// Regex to check for existence of time. This is needed because dateStyle/timeStyle is not always supported.
|
if (options.hour12) {
|
||||||
|
formattedDateTime = formattedDateTime.replace(/am|pm/gi, (match) =>
|
||||||
|
match.toUpperCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regex to check for existence of time
|
||||||
const containsTime = /\d{1,2}:\d{1,2}/.test(formattedDateTime);
|
const containsTime = /\d{1,2}:\d{1,2}/.test(formattedDateTime);
|
||||||
|
|
||||||
// fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
|
// fallback if the browser does not support dateStyle/timeStyle
|
||||||
// This works even tough the timezone is undefined, it will use the runtime's default time zone
|
|
||||||
if (!containsTime) {
|
if (!containsTime) {
|
||||||
const dateOptions = {
|
const dateOptions = {
|
||||||
...formatMap[date_style ?? ""]?.date,
|
...formatMap[date_style ?? ""]?.date,
|
||||||
@ -185,10 +212,17 @@ export const formatUnixTimestampToDateTime = (
|
|||||||
hour12: options.hour12,
|
hour12: options.hour12,
|
||||||
};
|
};
|
||||||
|
|
||||||
return `${date.toLocaleDateString(
|
let fallbackFormatted = `${date.toLocaleDateString(
|
||||||
locale,
|
localeCode,
|
||||||
dateOptions,
|
dateOptions,
|
||||||
)} ${date.toLocaleTimeString(locale, timeOptions)}`;
|
)} ${date.toLocaleTimeString(localeCode, timeOptions)}`;
|
||||||
|
// Uppercase AM/PM in fallback
|
||||||
|
if (options.hour12) {
|
||||||
|
fallbackFormatted = fallbackFormatted.replace(/am|pm/gi, (match) =>
|
||||||
|
match.toUpperCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fallbackFormatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
return formattedDateTime;
|
return formattedDateTime;
|
||||||
|
|||||||
@ -80,7 +80,7 @@ i18n
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
// For single keys, just capitalize and format
|
// For single keys, just smart-capitalize and format
|
||||||
return key
|
return key
|
||||||
.split("_")
|
.split("_")
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@ -151,7 +151,7 @@ function ThumbnailRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-background_alt p-2 md:px-4">
|
<div className="rounded-lg bg-background_alt p-2 md:px-4">
|
||||||
<div className="flex flex-row items-center text-lg capitalize">
|
<div className="flex flex-row items-center text-lg smart-capitalize">
|
||||||
{t(objectType, { ns: "objects" })}
|
{t(objectType, { ns: "objects" })}
|
||||||
{searchResults && (
|
{searchResults && (
|
||||||
<span className="ml-3 text-sm text-secondary-foreground">
|
<span className="ml-3 text-sm text-secondary-foreground">
|
||||||
@ -190,7 +190,7 @@ function ThumbnailRow({
|
|||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent className="capitalize">
|
<TooltipContent className="smart-capitalize">
|
||||||
<ExploreMoreLink objectType={objectType} />
|
<ExploreMoreLink objectType={objectType} />
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
|
|||||||
@ -597,7 +597,7 @@ export default function SearchView({
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Chip
|
<Chip
|
||||||
className={`flex select-none items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize text-white`}
|
className={`flex select-none items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs text-white smart-capitalize`}
|
||||||
>
|
>
|
||||||
{value.search_source == "thumbnail" ? (
|
{value.search_source == "thumbnail" ? (
|
||||||
<LuImage className="size-3" />
|
<LuImage className="size-3" />
|
||||||
|
|||||||
@ -431,7 +431,7 @@ export default function CameraSettingsView({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className="font-normal capitalize">
|
<FormLabel className="font-normal smart-capitalize">
|
||||||
{zone.name.replaceAll("_", " ")}
|
{zone.name.replaceAll("_", " ")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -536,7 +536,7 @@ export default function CameraSettingsView({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className="font-normal capitalize">
|
<FormLabel className="font-normal smart-capitalize">
|
||||||
{zone.name.replaceAll("_", " ")}
|
{zone.name.replaceAll("_", " ")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@ -165,7 +165,7 @@ export default function ClassificationSettingsView({
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
addMessage(
|
addMessage(
|
||||||
"search_settings_restart",
|
"search_settings_restart",
|
||||||
`Restart required (Classification settings changed)`,
|
t("classification.restart_required"),
|
||||||
undefined,
|
undefined,
|
||||||
"search_settings",
|
"search_settings",
|
||||||
);
|
);
|
||||||
|
|||||||
@ -158,7 +158,7 @@ export default function FrigatePlusSettingsView({
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
addMessage(
|
addMessage(
|
||||||
"plus_restart",
|
"plus_restart",
|
||||||
"Restart required (Frigate+ model changed)",
|
t("frigatePlus.restart_required"),
|
||||||
undefined,
|
undefined,
|
||||||
"plus_restart",
|
"plus_restart",
|
||||||
);
|
);
|
||||||
|
|||||||
@ -194,11 +194,11 @@ export default function MasksAndZonesView({
|
|||||||
setUnsavedChanges(false);
|
setUnsavedChanges(false);
|
||||||
addMessage(
|
addMessage(
|
||||||
"masks_zones",
|
"masks_zones",
|
||||||
"Restart required (masks/zones changed)",
|
t("masksAndZones.restart_required"),
|
||||||
undefined,
|
undefined,
|
||||||
"masks_zones",
|
"masks_zones",
|
||||||
);
|
);
|
||||||
}, [editingPolygons, setUnsavedChanges, addMessage]);
|
}, [t, editingPolygons, setUnsavedChanges, addMessage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@ -44,6 +44,7 @@ import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
|||||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||||
|
|
||||||
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
|
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
|
||||||
|
|
||||||
@ -645,6 +646,8 @@ export function CameraNotificationSwitch({
|
|||||||
sendNotificationSuspend(0);
|
sendNotificationSuspend(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const locale = useDateLocale();
|
||||||
|
|
||||||
const formatSuspendedUntil = (timestamp: string) => {
|
const formatSuspendedUntil = (timestamp: string) => {
|
||||||
// Some languages require a change in word order
|
// Some languages require a change in word order
|
||||||
if (timestamp === "0") return t("time.untilForRestart", { ns: "common" });
|
if (timestamp === "0") return t("time.untilForRestart", { ns: "common" });
|
||||||
@ -653,10 +656,15 @@ export function CameraNotificationSwitch({
|
|||||||
time_style: "medium",
|
time_style: "medium",
|
||||||
date_style: "medium",
|
date_style: "medium",
|
||||||
timezone: config?.ui.timezone,
|
timezone: config?.ui.timezone,
|
||||||
strftime_fmt:
|
date_format:
|
||||||
config?.ui.time_format == "24hour"
|
config?.ui.time_format == "24hour"
|
||||||
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
|
? t("time.formattedTimestampMonthDayHourMinute.24hour", {
|
||||||
: t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }),
|
ns: "common",
|
||||||
|
})
|
||||||
|
: t("time.formattedTimestampMonthDayHourMinute.12hour", {
|
||||||
|
ns: "common",
|
||||||
|
}),
|
||||||
|
locale: locale,
|
||||||
});
|
});
|
||||||
return t("time.untilForTime", { ns: "common", time });
|
return t("time.untilForTime", { ns: "common", time });
|
||||||
};
|
};
|
||||||
@ -672,7 +680,7 @@ export function CameraNotificationSwitch({
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Label
|
<Label
|
||||||
className="text-md cursor-pointer capitalize text-primary"
|
className="text-md cursor-pointer text-primary smart-capitalize"
|
||||||
htmlFor="camera"
|
htmlFor="camera"
|
||||||
>
|
>
|
||||||
{camera.replaceAll("_", " ")}
|
{camera.replaceAll("_", " ")}
|
||||||
|
|||||||
@ -197,7 +197,7 @@ export default function ObjectSettingsView({
|
|||||||
<div className="mb-2 flex flex-col">
|
<div className="mb-2 flex flex-col">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label
|
<Label
|
||||||
className="mb-0 cursor-pointer capitalize text-primary"
|
className="mb-0 cursor-pointer text-primary smart-capitalize"
|
||||||
htmlFor={param}
|
htmlFor={param}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@ -239,7 +239,7 @@ export default function ObjectSettingsView({
|
|||||||
<div className="mb-2 flex flex-col">
|
<div className="mb-2 flex flex-col">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label
|
<Label
|
||||||
className="mb-0 cursor-pointer capitalize text-primary"
|
className="mb-0 cursor-pointer text-primary smart-capitalize"
|
||||||
htmlFor="debugdraw"
|
htmlFor="debugdraw"
|
||||||
>
|
>
|
||||||
{t("debug.objectShapeFilterDrawing.title")}
|
{t("debug.objectShapeFilterDrawing.title")}
|
||||||
|
|||||||
@ -259,7 +259,7 @@ export default function CameraMetrics({
|
|||||||
)}
|
)}
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<div className="flex flex-row items-center justify-between">
|
<div className="flex flex-row items-center justify-between">
|
||||||
<div className="text-sm font-medium capitalize text-muted-foreground">
|
<div className="text-sm font-medium text-muted-foreground smart-capitalize">
|
||||||
{camera.name.replaceAll("_", " ")}
|
{camera.name.replaceAll("_", " ")}
|
||||||
</div>
|
</div>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -105,7 +105,7 @@ export default function EnrichmentMetrics({
|
|||||||
<>
|
<>
|
||||||
{embeddingInferenceTimeSeries.map((series) => (
|
{embeddingInferenceTimeSeries.map((series) => (
|
||||||
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
||||||
<div className="mb-5 capitalize">{series.name}</div>
|
<div className="mb-5 smart-capitalize">{series.name}</div>
|
||||||
{series.name.endsWith("Speed") ? (
|
{series.name.endsWith("Speed") ? (
|
||||||
<ThresholdBarGraph
|
<ThresholdBarGraph
|
||||||
key={series.name}
|
key={series.name}
|
||||||
|
|||||||
@ -10,9 +10,8 @@ import {
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { CiCircleAlert } from "react-icons/ci";
|
import { CiCircleAlert } from "react-icons/ci";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { useTimezone } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp, useTimezone } from "@/hooks/use-date-utils";
|
||||||
import { RecordingsSummary } from "@/types/review";
|
import { RecordingsSummary } from "@/types/review";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type CameraStorage = {
|
type CameraStorage = {
|
||||||
@ -70,6 +69,19 @@ export default function StorageMetrics({
|
|||||||
: null;
|
: null;
|
||||||
}, [recordingsSummary]);
|
}, [recordingsSummary]);
|
||||||
|
|
||||||
|
const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
||||||
|
const format = useMemo(() => {
|
||||||
|
return t(`time.formattedTimestampMonthDayYearHourMinute.${timeFormat}`, {
|
||||||
|
ns: "common",
|
||||||
|
});
|
||||||
|
}, [t, timeFormat]);
|
||||||
|
|
||||||
|
const formattedEarliestDate = useFormattedTimestamp(
|
||||||
|
earliestDate || 0,
|
||||||
|
format,
|
||||||
|
timezone,
|
||||||
|
);
|
||||||
|
|
||||||
if (!cameraStorage || !stats || !totalStorage || !config) {
|
if (!cameraStorage || !stats || !totalStorage || !config) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -114,13 +126,7 @@ export default function StorageMetrics({
|
|||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{t("storage.recordings.earliestRecording")}
|
{t("storage.recordings.earliestRecording")}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
{formatUnixTimestampToDateTime(earliestDate, {
|
{formattedEarliestDate}
|
||||||
timezone: timezone,
|
|
||||||
strftime_fmt:
|
|
||||||
config.ui.time_format === "24hour"
|
|
||||||
? "%d %b %Y, %H:%M"
|
|
||||||
: "%B %d, %Y, %I:%M %p",
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
|
||||||
|
const plugin = require("tailwindcss/plugin");
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
content: [
|
content: [
|
||||||
@ -166,5 +169,16 @@ module.exports = {
|
|||||||
require("tailwindcss-animate"),
|
require("tailwindcss-animate"),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
require("tailwind-scrollbar")({ nocompatible: true }),
|
require("tailwind-scrollbar")({ nocompatible: true }),
|
||||||
|
plugin(function ({ addUtilities }) {
|
||||||
|
addUtilities({
|
||||||
|
".smart-capitalize": {
|
||||||
|
':root[lang="ru"] &, :root[lang="ar"] &, :root[lang="he"] &, :root[lang="zh"] &, :root[lang="ja"] &, :root[lang="ko"] &, :root[lang="hi"] &, :root[lang="th"] &':
|
||||||
|
{
|
||||||
|
textTransform: "none",
|
||||||
|
},
|
||||||
|
textTransform: "capitalize",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user