Add react i18next to console
Signed-off-by: Jonathan <contact@grafikart.fr>
This commit is contained in:
@@ -1,165 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
const defaultValue = {
|
||||
lang: "en" as "en" | "fr",
|
||||
translations: {} as Record<string, string>,
|
||||
translate: (s: string) => s,
|
||||
};
|
||||
|
||||
type Context = typeof defaultValue;
|
||||
|
||||
const TranslatorContext = createContext(defaultValue);
|
||||
|
||||
type Props = {
|
||||
lang: "en" | "fr";
|
||||
loader: (lang: string) => Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export function TranslatorProvider({
|
||||
lang,
|
||||
loader,
|
||||
children,
|
||||
}: PropsWithChildren<Props>) {
|
||||
const [translations, setTranslations] = useState(
|
||||
{} as Record<string, string>
|
||||
);
|
||||
const translate = useCallback<Context["translate"]>(
|
||||
(s) => {
|
||||
return translations[s] ? translations[s] : s;
|
||||
},
|
||||
[translations]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loader(lang).then(setTranslations);
|
||||
}, [lang]);
|
||||
|
||||
return (
|
||||
<TranslatorContext.Provider value={{ lang, translations, translate }}>
|
||||
{children}
|
||||
</TranslatorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const SECONDS = 1000;
|
||||
const MINUTES = SECONDS * 60;
|
||||
const HOURS = MINUTES * 60;
|
||||
const DAYS = HOURS * 24;
|
||||
const WEEKS = DAYS * 7;
|
||||
const MONTHS = DAYS * 30;
|
||||
const YEARS = DAYS * 365;
|
||||
|
||||
const relativeFormat = [
|
||||
{ limit: YEARS, unit: "years" },
|
||||
{ limit: MONTHS, unit: "months" },
|
||||
{ limit: WEEKS, unit: "weeks" },
|
||||
{ limit: DAYS, unit: "days" },
|
||||
{ limit: HOURS, unit: "hours" },
|
||||
{ limit: MINUTES, unit: "minutes" },
|
||||
{ limit: SECONDS, unit: "seconds" },
|
||||
] as const;
|
||||
|
||||
export function useTranslate() {
|
||||
const { translate, lang } = useContext(TranslatorContext);
|
||||
const dateFormat = (
|
||||
date: Date | string | null | undefined,
|
||||
options: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
weekday: "short",
|
||||
}
|
||||
) => {
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
if (typeof date === "string") {
|
||||
return new Intl.DateTimeFormat(lang, options).format(parseDate(date));
|
||||
}
|
||||
return new Intl.DateTimeFormat(lang, options).format(date);
|
||||
};
|
||||
|
||||
const relativeDateFormat = (
|
||||
date: Date | string | null | undefined,
|
||||
options: Intl.RelativeTimeFormatOptions = {
|
||||
style: "long",
|
||||
}
|
||||
) => {
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
const distanceInSeconds =
|
||||
(date instanceof Date ? date.getTime() : parseDate(date).getTime()) -
|
||||
Date.now();
|
||||
|
||||
const formatter = new Intl.RelativeTimeFormat(lang, options);
|
||||
for (const { limit, unit } of relativeFormat) {
|
||||
if (Math.abs(distanceInSeconds) > limit) {
|
||||
return formatter.format(Math.round(distanceInSeconds / limit), unit);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
return {
|
||||
lang,
|
||||
__: translate,
|
||||
dateFormat: dateFormat,
|
||||
relativeDateFormat,
|
||||
dateTimeFormat: (
|
||||
date: Date | string | null | undefined,
|
||||
options: Intl.DateTimeFormatOptions = {
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
minute: "2-digit",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}
|
||||
) => {
|
||||
return dateFormat(date, options);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseDate(date: Date | string): Date {
|
||||
if (typeof date === "string") {
|
||||
if (date.includes("T")) {
|
||||
return new Date(date);
|
||||
}
|
||||
const parts = date.split("-");
|
||||
return new Date(
|
||||
parseInt(parts[0], 10),
|
||||
parts[1] ? parseInt(parts[1], 10) - 1 : 0,
|
||||
parts[2] ? parseInt(parts[2], 10) : 1
|
||||
);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
82
packages/i18n/date.ts
Normal file
82
packages/i18n/date.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { parseDate } from "@probo/helpers";
|
||||
|
||||
const relativeFormat = [
|
||||
{ limit: 1000 * 60 * 60 * 24 * 365, unit: "years" },
|
||||
{ limit: 1000 * 60 * 60 * 24 * 30, unit: "months" },
|
||||
{ limit: 1000 * 60 * 60 * 24 * 7, unit: "weeks" },
|
||||
{ limit: 1000 * 60 * 60 * 24, unit: "days" },
|
||||
{ limit: 1000 * 60 * 60, unit: "hours" },
|
||||
{ limit: 1000 * 60, unit: "minutes" },
|
||||
{ limit: 1000, unit: "seconds" },
|
||||
] as const;
|
||||
|
||||
export function relativeDateFormat(
|
||||
language: string,
|
||||
date: Date | string | null | undefined,
|
||||
options: Intl.RelativeTimeFormatOptions = { style: "long" },
|
||||
): string {
|
||||
if (!date) return "";
|
||||
|
||||
const dateValue = typeof date === "string" ? parseDate(date) : date;
|
||||
const distanceInMilliseconds = dateValue.getTime() - Date.now();
|
||||
const formatter = new Intl.RelativeTimeFormat(language, options);
|
||||
|
||||
for (const { limit, unit } of relativeFormat) {
|
||||
if (Math.abs(distanceInMilliseconds) >= limit) {
|
||||
return formatter.format(Math.round(distanceInMilliseconds / limit), unit);
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function dateFormat(
|
||||
language: string,
|
||||
date: Date | string | null | undefined,
|
||||
options: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
weekday: "short",
|
||||
},
|
||||
): string {
|
||||
if (!date) return "";
|
||||
|
||||
const dateValue = typeof date === "string" ? parseDate(date) : date;
|
||||
return new Intl.DateTimeFormat(language, options).format(dateValue);
|
||||
}
|
||||
|
||||
export function dateTimeFormat(
|
||||
language: string,
|
||||
date: Date | string | null | undefined,
|
||||
options: Intl.DateTimeFormatOptions = {
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
minute: "2-digit",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
},
|
||||
): string {
|
||||
return dateFormat(language, date, options);
|
||||
}
|
||||
96
packages/i18n/duration.ts
Normal file
96
packages/i18n/duration.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
type Translator = (key: string, options?: { count?: number }) => string;
|
||||
|
||||
const DURATION_UNITS = [
|
||||
{ value: "seconds", seconds: 1, snap: 0 },
|
||||
{ value: "minutes", seconds: 60, snap: 5 },
|
||||
{ value: "hours", seconds: 3_600, snap: 5 * 60 },
|
||||
{ value: "days", seconds: 86_400, snap: 2 * 3_600 },
|
||||
{ value: "weeks", seconds: 604_800, snap: 12 * 3_600 },
|
||||
{ value: "months", seconds: 2_592_000, snap: 2 * 24 * 3_600 },
|
||||
{ value: "years", seconds: 31_536_000, snap: 21 * 24 * 3_600 },
|
||||
] as const;
|
||||
|
||||
export function humanizeSeconds(
|
||||
seconds: number | null,
|
||||
t: Translator,
|
||||
): string {
|
||||
if (seconds === null || seconds <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let remaining = seconds;
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const { value, seconds: durationInSeconds, snap } of [...DURATION_UNITS].reverse()) {
|
||||
if (remaining >= durationInSeconds - snap) {
|
||||
let count = Math.floor(remaining / durationInSeconds);
|
||||
const leftover = remaining - count * durationInSeconds;
|
||||
|
||||
if (leftover >= durationInSeconds - snap) {
|
||||
count++;
|
||||
remaining = 0;
|
||||
} else if (leftover <= snap) {
|
||||
remaining = 0;
|
||||
} else {
|
||||
remaining = leftover;
|
||||
}
|
||||
|
||||
parts.push(`${count} ${t(`duration.${value}`, { count })}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(", ") : t("duration.session");
|
||||
}
|
||||
|
||||
export function formatDuration(
|
||||
duration?: string | null,
|
||||
t?: Translator,
|
||||
): string | null {
|
||||
if (!duration || !t) return null;
|
||||
|
||||
const timeMatch = duration.match(/PT(\d+)([MH])/);
|
||||
if (timeMatch) {
|
||||
const amount = parseInt(timeMatch[1], 10) || 0;
|
||||
const unit = timeMatch[2];
|
||||
if (unit === "M") return t("duration.min", { count: amount });
|
||||
if (unit === "H") return t("duration.hour", { count: amount });
|
||||
}
|
||||
|
||||
const dateMatch = duration.match(/P(\d+)([DW])/);
|
||||
if (dateMatch) {
|
||||
const amount = parseInt(dateMatch[1], 10) || 0;
|
||||
const unit = dateMatch[2];
|
||||
if (unit === "W") {
|
||||
return `${amount} ${amount === 1 ? t("Week") : t("Weeks")}`;
|
||||
}
|
||||
if (unit === "D") {
|
||||
if (amount % 7 === 0 && amount > 0) {
|
||||
const weeks = amount / 7;
|
||||
return `${weeks} ${weeks === 1 ? t("Week") : t("Weeks")}`;
|
||||
}
|
||||
return `${amount} ${amount === 1 ? t("Day") : t("Days")}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
50
packages/i18n/index.test.ts
Normal file
50
packages/i18n/index.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { fileSize, humanizeSeconds } from "./index";
|
||||
|
||||
describe("fileSize", () => {
|
||||
it("formats byte counts with translated units", () => {
|
||||
const t = (key: string) => key.replace("size.", "");
|
||||
|
||||
expect(fileSize(4911, t)).toBe("4.8 KB");
|
||||
expect(fileSize(20, t)).toBe("20 B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("humanizeSeconds", () => {
|
||||
const t = (key: string, options?: { count?: number }) => {
|
||||
const unit = key.replace("duration.", "");
|
||||
if (unit === "session" || unit === "persistent") return unit;
|
||||
return options?.count === 1 ? unit.slice(0, -1) : unit;
|
||||
};
|
||||
|
||||
it("formats durations with translated plural units", () => {
|
||||
expect(humanizeSeconds(4570, t)).toBe("1 hour, 16 minutes, 10 seconds");
|
||||
expect(humanizeSeconds(120, t)).toBe("2 minutes");
|
||||
});
|
||||
|
||||
it("formats trackers without a max age as session or persistent", () => {
|
||||
expect(humanizeSeconds(null, t)).toBe("session");
|
||||
expect(humanizeSeconds(0, t, "LOCAL_STORAGE")).toBe("persistent");
|
||||
});
|
||||
});
|
||||
23
packages/i18n/index.ts
Normal file
23
packages/i18n/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
export * from "./date";
|
||||
export * from "./duration";
|
||||
export * from "./size";
|
||||
@@ -2,9 +2,10 @@
|
||||
"name": "@probo/i18n",
|
||||
"version": "1.0.0",
|
||||
"author": "",
|
||||
"main": "./TranslatorProvider.tsx",
|
||||
"main": "./index.ts",
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.17"
|
||||
"@types/react": "^19.2.17",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0"
|
||||
@@ -12,5 +13,10 @@
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
"scripts": {}
|
||||
"scripts": {
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@probo/helpers": "1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
35
packages/i18n/size.ts
Normal file
35
packages/i18n/size.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
type Translator = (key: string, options?: { count?: number }) => string;
|
||||
|
||||
export function fileSize(size: number, t: Translator): string {
|
||||
if (size < 0) return "";
|
||||
if (size === 0) return `0 ${t("size.B")}`;
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const unitIndex = Math.min(
|
||||
Math.floor(Math.log(size) / Math.log(1024)),
|
||||
units.length - 1,
|
||||
);
|
||||
const formattedSize = Math.round((size / 1024 ** unitIndex) * 100) / 100;
|
||||
|
||||
return `${formattedSize} ${t(`size.${units[unitIndex]}`)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user