Compare commits

...

4 Commits

Author SHA1 Message Date
Josh Hawkins
cee5d6e5ee
Fix i18n plural for selected review count (#17864)
* Fix i18n plural for selected review count

* use selected count in explore
2025-04-22 22:01:46 -05:00
Josh Hawkins
78c1694451
Add missing i18n keys (#17861) 2025-04-22 19:46:05 -06:00
Nicolas Mowen
f9b2db4405
Implement smart capitalization based on locale (#17860) 2025-04-22 16:21:09 -06:00
Josh Hawkins
b6e0e5698a
Proper i18n date/time handling (#17858)
* install date-fns-tz

* add date locale hook

* refactor formatUnixTimestampToDateTime

Use date-fns style instead of using strftime. This requires changing the i18n keys to the way date-fns represents dates (eg: "MMM d, h:mm:ss aaa"  instead of "%b %-d, %H:%M"

* refactor calendar to use new hook

* fix useFormattedTimestamp to use new formatUnixTimestampToDateTime date_format

* change i18n keys to new format

* fix timeline

* fix review

* fix explore

* fix metrics

* fix notifications

* fix face library

* clean up
2025-04-22 15:50:21 -06:00
68 changed files with 479 additions and 255 deletions

10
web/package-lock.json generated
View File

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

View File

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

View File

@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 || []),

View File

@ -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"),

View File

@ -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,
); );

View File

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

View File

@ -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("_", " ")}

View File

@ -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]);
}} }}

View File

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

View File

@ -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("_", " ")}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(() => {

View File

@ -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(() => {

View File

@ -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();

View File

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

View File

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

View File

@ -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("_", " "),
})} })}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
})} })}

View File

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

View File

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

View File

@ -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 }) =>

View File

@ -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 || []),

View File

@ -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("_", " "),
})} })}

View File

@ -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",
); );

View File

@ -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" })}

View File

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

View File

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

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",
); );

View File

@ -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",
); );

View File

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

View File

@ -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("_", " ")}

View File

@ -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")}

View File

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

View File

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

View File

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

View File

@ -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",
},
});
}),
], ],
}; };