mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-26 13:47:03 +02:00
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
This commit is contained in:
parent
645c84bc1a
commit
b6e0e5698a
10
web/package-lock.json
generated
10
web/package-lock.json
generated
@ -38,6 +38,7 @@
|
||||
"cmdk": "^1.0.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"embla-carousel-react": "^8.2.0",
|
||||
"framer-motion": "^11.5.4",
|
||||
"hls.js": "^1.5.20",
|
||||
@ -4399,6 +4400,15 @@
|
||||
"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": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
|
@ -44,6 +44,7 @@
|
||||
"cmdk": "^1.0.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"embla-carousel-react": "^8.2.0",
|
||||
"framer-motion": "^11.5.4",
|
||||
"hls.js": "^1.5.20",
|
||||
|
@ -41,22 +41,34 @@
|
||||
"second_one": "{{time}} second",
|
||||
"second_other": "{{time}} seconds",
|
||||
"formattedTimestamp": {
|
||||
"12hour": "%b %-d, %I:%M:%S %p",
|
||||
"24hour": "%b %-d, %H:%M:%S"
|
||||
"12hour": "MMM d, h:mm:ss aaa",
|
||||
"24hour": "MMM d, HH:mm:ss"
|
||||
},
|
||||
"formattedTimestamp2": {
|
||||
"12hour": "%m/%d %I:%M:%S%P",
|
||||
"24hour": "%d %b %H:%M:%S"
|
||||
"12hour": "MM/dd h:mm:ssa",
|
||||
"24hour": "d MMM HH:mm:ss"
|
||||
},
|
||||
"formattedTimestampExcludeSeconds": {
|
||||
"12hour": "%b %-d, %I:%M %p",
|
||||
"24hour": "%b %-d, %H:%M"
|
||||
"formattedTimestampHourMinute": {
|
||||
"12hour": "h:mm aaa",
|
||||
"24hour": "HH:mm"
|
||||
},
|
||||
"formattedTimestampWithYear": {
|
||||
"12hour": "%b %-d %Y, %I:%M %p",
|
||||
"24hour": "%b %-d %Y, %H:%M"
|
||||
"formattedTimestampHourMinuteSecond": {
|
||||
"12hour": "h:mm:ss aaa",
|
||||
"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": {
|
||||
"speed": {
|
||||
|
@ -4,6 +4,10 @@ import { FaDownload } from "react-icons/fa";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 = {
|
||||
source: string;
|
||||
@ -19,10 +23,17 @@ export function DownloadVideoButton({
|
||||
className,
|
||||
}: DownloadVideoButtonProps) {
|
||||
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, {
|
||||
strftime_fmt: "%D-%T",
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
date_format: format,
|
||||
locale,
|
||||
});
|
||||
const filename = `${camera}_${formattedDate}.mp4`;
|
||||
|
||||
|
@ -52,7 +52,9 @@ export default function ReviewCard({
|
||||
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
|
||||
const formattedDate = useFormattedTimestamp(
|
||||
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,
|
||||
);
|
||||
const isSelected = useMemo(
|
||||
|
@ -32,8 +32,8 @@ export default function SearchThumbnailFooter({
|
||||
const formattedDate = useFormattedTimestamp(
|
||||
searchResult.start_time,
|
||||
config?.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
|
||||
: t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }),
|
||||
? t("time.formattedTimestampMonthDayHourMinute.24hour", { ns: "common" })
|
||||
: t("time.formattedTimestampMonthDayHourMinute.12hour", { ns: "common" }),
|
||||
config?.ui.timezone,
|
||||
);
|
||||
|
||||
|
@ -32,7 +32,7 @@ export default function CalendarFilterButton({
|
||||
const [open, setOpen] = useState(false);
|
||||
const selectedDate = useFormattedTimestamp(
|
||||
day == undefined ? 0 : day?.getTime() / 1000 + 1,
|
||||
t("time.formattedTimestampOnlyMonthAndDay", { ns: "common" }),
|
||||
t("time.formattedTimestampMonthDay", { ns: "common" }),
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
@ -103,7 +103,7 @@ export function CalendarRangeFilterButton({
|
||||
const selectedDate = useFormattedRange(
|
||||
range?.from == undefined ? 0 : range.from.getTime() / 1000 + 1,
|
||||
range?.to == undefined ? 0 : range.to.getTime() / 1000 - 1,
|
||||
t("time.formattedTimestampOnlyMonthAndDay", { ns: "common" }),
|
||||
t("time.formattedTimestampMonthDay", { ns: "common" }),
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { useTheme } from "@/context/theme-provider";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
@ -24,7 +25,7 @@ export function CameraLineGraph({
|
||||
updateTimes,
|
||||
data,
|
||||
}: CameraLineGraphProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { t } = useTranslation(["views/system", "common"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
@ -43,18 +44,27 @@ export function CameraLineGraph({
|
||||
|
||||
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(
|
||||
(val: unknown) => {
|
||||
return formatUnixTimestampToDateTime(
|
||||
updateTimes[Math.round(val as number)],
|
||||
{
|
||||
timezone: config?.ui.timezone,
|
||||
strftime_fmt:
|
||||
config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
|
||||
date_format: format,
|
||||
locale,
|
||||
},
|
||||
);
|
||||
},
|
||||
[config, updateTimes],
|
||||
[config?.ui.timezone, format, locale, updateTimes],
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
@ -170,18 +180,28 @@ export function EventsPerSecondsLineGraph({
|
||||
[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(
|
||||
(val: unknown) => {
|
||||
return formatUnixTimestampToDateTime(
|
||||
updateTimes[Math.round(val as number) - 1],
|
||||
{
|
||||
timezone: config?.ui.timezone,
|
||||
strftime_fmt:
|
||||
config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
|
||||
date_format: format,
|
||||
locale,
|
||||
},
|
||||
);
|
||||
},
|
||||
[config, updateTimes],
|
||||
[config?.ui.timezone, format, locale, updateTimes],
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { useTheme } from "@/context/theme-provider";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Threshold } from "@/types/graph";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import Chart from "react-apexcharts";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
|
||||
type ThresholdBarGraphProps = {
|
||||
@ -45,6 +47,16 @@ export function ThresholdBarGraph({
|
||||
|
||||
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(
|
||||
(val: unknown) => {
|
||||
const dateIndex = Math.round(val as number);
|
||||
@ -53,17 +65,16 @@ export function ThresholdBarGraph({
|
||||
if (dateIndex < 0) {
|
||||
timeOffset = 5 * Math.abs(dateIndex);
|
||||
}
|
||||
|
||||
return formatUnixTimestampToDateTime(
|
||||
updateTimes[Math.max(1, dateIndex) - 1] - timeOffset,
|
||||
{
|
||||
timezone: config?.ui.timezone,
|
||||
strftime_fmt:
|
||||
config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
|
||||
date_format: format,
|
||||
locale,
|
||||
},
|
||||
);
|
||||
},
|
||||
[config, updateTimes],
|
||||
[config?.ui.timezone, format, locale, updateTimes],
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
|
@ -45,6 +45,7 @@ import {
|
||||
useNotificationSuspend,
|
||||
} from "@/api/ws";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
|
||||
type LiveContextMenuProps = {
|
||||
className?: string;
|
||||
@ -235,18 +236,25 @@ export default function LiveContextMenu({
|
||||
}
|
||||
};
|
||||
|
||||
const locale = useDateLocale();
|
||||
|
||||
const formatSuspendedUntil = (timestamp: string) => {
|
||||
// Some languages require a change in word order
|
||||
if (timestamp === "0") return t("time.untilForRestart", { ns: "common" });
|
||||
|
||||
const time = formatUnixTimestampToDateTime(Number.parseInt(timestamp), {
|
||||
const time = formatUnixTimestampToDateTime(parseInt(timestamp), {
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
timezone: config?.ui.timezone,
|
||||
strftime_fmt:
|
||||
date_format:
|
||||
config?.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
|
||||
: t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }),
|
||||
? t("time.formattedTimestampMonthDayHourMinute.24hour", {
|
||||
ns: "common",
|
||||
})
|
||||
: t("time.formattedTimestampMonthDayHourMinute.12hour", {
|
||||
ns: "common",
|
||||
}),
|
||||
locale: locale,
|
||||
});
|
||||
return t("time.untilForTime", { ns: "common", time });
|
||||
};
|
||||
|
@ -578,7 +578,7 @@ export default function ObjectLifecycle({
|
||||
<div className="text-sm text-primary-variant">
|
||||
{formatUnixTimestampToDateTime(item.timestamp, {
|
||||
timezone: config.ui.timezone,
|
||||
strftime_fmt:
|
||||
date_format:
|
||||
config.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestamp2.24hour", {
|
||||
ns: "common",
|
||||
|
@ -96,8 +96,12 @@ export default function ReviewDetailDialog({
|
||||
const formattedDate = useFormattedTimestamp(
|
||||
review?.start_time ?? 0,
|
||||
config?.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestampWithYear.24hour", { ns: "common" })
|
||||
: t("time.formattedTimestampWithYear.12hour", { ns: "common" }),
|
||||
? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
|
||||
ns: "common",
|
||||
})
|
||||
: t("time.formattedTimestampMonthDayYearHourMinute.12hour", {
|
||||
ns: "common",
|
||||
}),
|
||||
config?.ui.timezone,
|
||||
);
|
||||
|
||||
|
@ -320,8 +320,12 @@ function ObjectDetailsTab({
|
||||
const formattedDate = useFormattedTimestamp(
|
||||
search?.start_time ?? 0,
|
||||
config?.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestampWithYear.24hour", { ns: "common" })
|
||||
: t("time.formattedTimestampWithYear.12hour", { ns: "common" }),
|
||||
? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
|
||||
ns: "common",
|
||||
})
|
||||
: t("time.formattedTimestampMonthDayYearHourMinute.12hour", {
|
||||
ns: "common",
|
||||
}),
|
||||
config?.ui.timezone,
|
||||
);
|
||||
|
||||
|
@ -170,8 +170,8 @@ export default function PreviewThumbnailPlayer({
|
||||
const formattedDate = useFormattedTimestamp(
|
||||
review.start_time,
|
||||
config?.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
|
||||
: t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }),
|
||||
? t("time.formattedTimestampMonthDayHourMinute.24hour", { ns: "common" })
|
||||
: t("time.formattedTimestampMonthDayHourMinute.12hour", { ns: "common" }),
|
||||
config?.ui?.timezone,
|
||||
);
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
|
||||
type MinimapSegmentProps = {
|
||||
@ -34,6 +35,24 @@ export function MinimapBounds({
|
||||
dense,
|
||||
}: MinimapSegmentProps) {
|
||||
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 (
|
||||
<>
|
||||
@ -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"
|
||||
ref={firstMinimapSegmentRef}
|
||||
>
|
||||
{formatUnixTimestampToDateTime(alignedMinimapStartTime, {
|
||||
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"}`,
|
||||
})}
|
||||
{formattedStartTime}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
{formatUnixTimestampToDateTime(alignedMinimapEndTime, {
|
||||
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"}`,
|
||||
})}
|
||||
{formattedEndTime}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@ -92,27 +101,28 @@ export function Timestamp({
|
||||
timestampSpread,
|
||||
segmentKey,
|
||||
}: TimestampSegmentProps) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const formattedTimestamp = useMemo(() => {
|
||||
if (
|
||||
!(
|
||||
timestamp.getMinutes() % timestampSpread === 0 &&
|
||||
timestamp.getSeconds() === 0
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
||||
const format = t(`time.formattedTimestampHourMinute.${timeFormat}`);
|
||||
|
||||
return formatUnixTimestampToDateTime(timestamp.getTime() / 1000, {
|
||||
timezone: config?.ui.timezone,
|
||||
strftime_fmt: config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
|
||||
});
|
||||
}, [config, timestamp, timestampSpread]);
|
||||
const formattedTimestamp = useFormattedTimestamp(
|
||||
timestamp.getTime() / 1000,
|
||||
format,
|
||||
config?.ui.timezone,
|
||||
);
|
||||
|
||||
const shouldDisplay = useMemo(() => {
|
||||
return (
|
||||
timestamp.getMinutes() % timestampSpread === 0 &&
|
||||
timestamp.getSeconds() === 0
|
||||
);
|
||||
}, [timestamp, timestampSpread]);
|
||||
|
||||
return (
|
||||
<div className="absolute left-[15px] z-10 h-[8px]">
|
||||
{!isFirstSegmentInMinimap && !isLastSegmentInMinimap && (
|
||||
{!isFirstSegmentInMinimap && !isLastSegmentInMinimap && shouldDisplay && (
|
||||
<div
|
||||
key={`${segmentKey}_timestamp`}
|
||||
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 { DayPicker } from "react-day-picker";
|
||||
import { Locale, enUS } from "date-fns/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
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>;
|
||||
|
||||
// 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({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
const [locale, setLocale] = useState<Locale>(enUS);
|
||||
|
||||
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]);
|
||||
const locale = useDateLocale();
|
||||
|
||||
return (
|
||||
<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 { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { useMemo } from "react";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
|
||||
export function useFormattedTimestamp(
|
||||
timestamp: number,
|
||||
format: string,
|
||||
timezone?: string,
|
||||
) {
|
||||
const locale = useDateLocale();
|
||||
|
||||
const formattedTimestamp = useMemo(() => {
|
||||
return formatUnixTimestampToDateTime(timestamp, {
|
||||
timezone,
|
||||
strftime_fmt: format,
|
||||
date_format: format,
|
||||
locale,
|
||||
});
|
||||
}, [format, timestamp, timezone]);
|
||||
}, [format, timestamp, timezone, locale]);
|
||||
|
||||
return formattedTimestamp;
|
||||
}
|
||||
|
||||
export function useFormattedRange(start: number, end: number, format: string) {
|
||||
const locale = useDateLocale();
|
||||
|
||||
const formattedStart = useMemo(() => {
|
||||
return formatUnixTimestampToDateTime(start, {
|
||||
strftime_fmt: format,
|
||||
date_format: format,
|
||||
locale,
|
||||
});
|
||||
}, [format, start]);
|
||||
}, [format, start, locale]);
|
||||
|
||||
const formattedEnd = useMemo(() => {
|
||||
return formatUnixTimestampToDateTime(end, {
|
||||
strftime_fmt: format,
|
||||
date_format: format,
|
||||
locale,
|
||||
});
|
||||
}, [format, end]);
|
||||
}, [format, end, locale]);
|
||||
|
||||
return `${formattedStart} - ${formattedEnd}`;
|
||||
}
|
||||
@ -44,7 +53,7 @@ export function useTimezone(config: FrigateConfig | undefined) {
|
||||
}, [config]);
|
||||
}
|
||||
|
||||
function use24HourTime(config: FrigateConfig | undefined) {
|
||||
export function use24HourTime(config: FrigateConfig | undefined) {
|
||||
const localeUses24HourTime = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
@ -60,8 +69,8 @@ function use24HourTime(config: FrigateConfig | undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config.ui.time_format != "browser") {
|
||||
return config.ui.time_format == "24hour";
|
||||
if (config.ui.time_format !== "browser") {
|
||||
return config.ui.time_format === "24hour";
|
||||
}
|
||||
|
||||
return localeUses24HourTime;
|
||||
|
@ -3,6 +3,8 @@ import { useTimelineUtils } from "./use-timeline-utils";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { useDateLocale } from "./use-date-locale";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type DraggableElementProps = {
|
||||
contentRef: React.RefObject<HTMLElement>;
|
||||
@ -162,17 +164,28 @@ function useDraggableElement({
|
||||
[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(
|
||||
(segmentStartTime: number) => {
|
||||
return formatUnixTimestampToDateTime(segmentStartTime, {
|
||||
timezone: config?.ui.timezone,
|
||||
strftime_fmt:
|
||||
config?.ui.time_format == "24hour"
|
||||
? `%H:%M${segmentDuration < 60 && !dense ? ":%S" : ""}`
|
||||
: `%I:%M${segmentDuration < 60 && !dense ? ":%S" : ""} %p`,
|
||||
date_format: format,
|
||||
locale,
|
||||
});
|
||||
},
|
||||
[config, dense, segmentDuration],
|
||||
[config?.ui.timezone, format, locale],
|
||||
);
|
||||
|
||||
const updateDraggableElementPosition = useCallback(
|
||||
|
@ -531,8 +531,12 @@ function TrainingGrid({
|
||||
const formattedDate = useFormattedTimestamp(
|
||||
selectedEvent?.start_time ?? 0,
|
||||
config?.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestampWithYear.24hour", { ns: "common" })
|
||||
: t("time.formattedTimestampWithYear.12hour", { ns: "common" }),
|
||||
? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
|
||||
ns: "common",
|
||||
})
|
||||
: t("time.formattedTimestampMonthDayYearHourMinute.12hour", {
|
||||
ns: "common",
|
||||
}),
|
||||
config?.ui.timezone,
|
||||
);
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import strftime from "strftime";
|
||||
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 epochToLong = (date: number): number => date / 1000;
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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 = (
|
||||
unixTimestamp: number,
|
||||
config: {
|
||||
timezone?: string;
|
||||
time_format?: "browser" | "12hour" | "24hour";
|
||||
date_style?: "full" | "long" | "medium" | "short";
|
||||
time_style?: "full" | "long" | "medium" | "short";
|
||||
strftime_fmt?: string;
|
||||
},
|
||||
config: DateTimeStyle = {},
|
||||
): string => {
|
||||
const { timezone, time_format, date_style, time_style, strftime_fmt } =
|
||||
const { timezone, time_format, date_style, time_style, date_format, locale } =
|
||||
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)) {
|
||||
return "Invalid time";
|
||||
}
|
||||
|
||||
try {
|
||||
const date = new Date(unixTimestamp * 1000);
|
||||
const resolvedTimeZone = getResolvedTimeZone();
|
||||
|
||||
// use strftime_fmt if defined in config
|
||||
if (strftime_fmt) {
|
||||
const offset = getUTCOffset(date, timezone || resolvedTimeZone);
|
||||
const strftime_locale = strftime.timezone(offset);
|
||||
return strftime_locale(strftime_fmt, date);
|
||||
if (date_format) {
|
||||
const resolvedTimeZone = timezone || getResolvedTimeZone();
|
||||
let formatted = formatInTimeZone(date, resolvedTimeZone, date_format, {
|
||||
locale: dateFnsLocale,
|
||||
});
|
||||
// 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
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
dateStyle: date_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
|
||||
const resolvedTimeZone = getResolvedTimeZone();
|
||||
const isUTCOffsetFormat = /^UTC[+-]\d{2}:\d{2}$/.test(resolvedTimeZone);
|
||||
if (timezone || !isUTCOffsetFormat) {
|
||||
options.timeZone = timezone || resolvedTimeZone;
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(locale, options);
|
||||
const formattedDateTime = formatter.format(date);
|
||||
const formatter = new Intl.DateTimeFormat(localeCode, options);
|
||||
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);
|
||||
|
||||
// fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
|
||||
// This works even tough the timezone is undefined, it will use the runtime's default time zone
|
||||
// fallback if the browser does not support dateStyle/timeStyle
|
||||
if (!containsTime) {
|
||||
const dateOptions = {
|
||||
...formatMap[date_style ?? ""]?.date,
|
||||
@ -185,10 +212,17 @@ export const formatUnixTimestampToDateTime = (
|
||||
hour12: options.hour12,
|
||||
};
|
||||
|
||||
return `${date.toLocaleDateString(
|
||||
locale,
|
||||
let fallbackFormatted = `${date.toLocaleDateString(
|
||||
localeCode,
|
||||
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;
|
||||
|
@ -44,6 +44,7 @@ import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
|
||||
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
|
||||
|
||||
@ -645,6 +646,8 @@ export function CameraNotificationSwitch({
|
||||
sendNotificationSuspend(0);
|
||||
};
|
||||
|
||||
const locale = useDateLocale();
|
||||
|
||||
const formatSuspendedUntil = (timestamp: string) => {
|
||||
// Some languages require a change in word order
|
||||
if (timestamp === "0") return t("time.untilForRestart", { ns: "common" });
|
||||
@ -653,10 +656,15 @@ export function CameraNotificationSwitch({
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
timezone: config?.ui.timezone,
|
||||
strftime_fmt:
|
||||
date_format:
|
||||
config?.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
|
||||
: t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }),
|
||||
? t("time.formattedTimestampMonthDayHourMinute.24hour", {
|
||||
ns: "common",
|
||||
})
|
||||
: t("time.formattedTimestampMonthDayHourMinute.12hour", {
|
||||
ns: "common",
|
||||
}),
|
||||
locale: locale,
|
||||
});
|
||||
return t("time.untilForTime", { ns: "common", time });
|
||||
};
|
||||
|
@ -10,9 +10,8 @@ import {
|
||||
import useSWR from "swr";
|
||||
import { CiCircleAlert } from "react-icons/ci";
|
||||
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 { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type CameraStorage = {
|
||||
@ -70,6 +69,19 @@ export default function StorageMetrics({
|
||||
: null;
|
||||
}, [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) {
|
||||
return;
|
||||
}
|
||||
@ -114,13 +126,7 @@ export default function StorageMetrics({
|
||||
<span className="font-medium">
|
||||
{t("storage.recordings.earliestRecording")}
|
||||
</span>{" "}
|
||||
{formatUnixTimestampToDateTime(earliestDate, {
|
||||
timezone: timezone,
|
||||
strftime_fmt:
|
||||
config.ui.time_format === "24hour"
|
||||
? "%d %b %Y, %H:%M"
|
||||
: "%B %d, %Y, %I:%M %p",
|
||||
})}
|
||||
{formattedEarliestDate}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
Loading…
Reference in New Issue
Block a user