diff --git a/.vscode/settings.json b/.vscode/settings.json
index 0263499bb..28d5f189e 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -27,7 +27,7 @@
"Umami"
],
"i18n-ally.dirStructure": "auto",
- "i18n-ally.enabledFrameworks": ["next-international"],
+ "i18n-ally.enabledFrameworks": ["next-intl"],
"i18n-ally.localesPaths": ["./packages/translation/src/lang/"],
"i18n-ally.enabledParsers": ["ts"],
"i18n-ally.extract.keyMaxLength": 0,
diff --git a/apps/nextjs/next.config.mjs b/apps/nextjs/next.config.mjs
index 7d53a70db..2467471d8 100644
--- a/apps/nextjs/next.config.mjs
+++ b/apps/nextjs/next.config.mjs
@@ -2,11 +2,15 @@
import "@homarr/auth/env.mjs";
import MillionLint from "@million/lint";
+import createNextIntlPlugin from "next-intl/plugin";
import "./src/env.mjs";
+// Package path does not work... so we need to use relative path
+const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
+
/** @type {import("next").NextConfig} */
-const config = {
+const nextConfig = {
output: "standalone",
reactStrictMode: true,
/** We already do linting and typechecking as separate tasks in CI */
@@ -34,4 +38,4 @@ const config = {
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
-export default config;
+export default withNextIntl(nextConfig);
diff --git a/apps/nextjs/src/app/[locale]/_client-providers/next-international.tsx b/apps/nextjs/src/app/[locale]/_client-providers/next-international.tsx
deleted file mode 100644
index 8297a73bb..000000000
--- a/apps/nextjs/src/app/[locale]/_client-providers/next-international.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import type { PropsWithChildren } from "react";
-
-import { defaultLocale } from "@homarr/translation";
-import { I18nProviderClient } from "@homarr/translation/client";
-
-export const NextInternationalProvider = ({ children, locale }: PropsWithChildren<{ locale: string }>) => {
- return (
-
- {children}
-
- );
-};
diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx
index 22888ea0b..765eae8d7 100644
--- a/apps/nextjs/src/app/[locale]/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/layout.tsx
@@ -7,18 +7,20 @@ import "@homarr/ui/styles.css";
import "~/styles/scroll-area.scss";
import { cookies } from "next/headers";
+import { notFound } from "next/navigation";
+import { NextIntlClientProvider } from "next-intl";
import { env } from "@homarr/auth/env.mjs";
import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
-import { getScopedI18n } from "@homarr/translation/server";
+import { isLocaleSupported } from "@homarr/translation";
+import { getI18nMessages, getScopedI18n } from "@homarr/translation/server";
import { Analytics } from "~/components/layout/analytics";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { JotaiProvider } from "./_client-providers/jotai";
import { CustomMantineProvider } from "./_client-providers/mantine";
-import { NextInternationalProvider } from "./_client-providers/next-international";
import { AuthProvider } from "./_client-providers/session";
import { TRPCReactProvider } from "./_client-providers/trpc";
import { composeWrappers } from "./compose";
@@ -59,10 +61,15 @@ export const viewport: Viewport = {
};
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
+ if (!isLocaleSupported(props.params.locale)) {
+ notFound();
+ }
+
const session = await auth();
const colorScheme = getColorScheme();
const tCommon = await getScopedI18n("common");
const direction = tCommon("direction");
+ const i18nMessages = await getI18nMessages();
const StackedProvider = composeWrappers([
(innerProps) => {
@@ -70,7 +77,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
},
(innerProps) => ,
(innerProps) => ,
- (innerProps) => ,
+ (innerProps) => ,
(innerProps) => ,
(innerProps) => ,
]);
@@ -78,7 +85,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
{
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
- params: createCustomErrorParams("passwordsDoNotMatch"),
+ params: createCustomErrorParams({
+ key: "passwordsDoNotMatch",
+ params: {},
+ }),
}),
{
initialValues: {
diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_reserved-group-alert.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_reserved-group-alert.tsx
index bd6920792..a777ce73d 100644
--- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_reserved-group-alert.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_reserved-group-alert.tsx
@@ -10,8 +10,8 @@ export const ReservedGroupAlert = async () => {
return (
}>
- {t("group.reservedNotice.message", {
- checkoutDocs: (
+ {t.rich("group.reservedNotice.message", {
+ checkoutDocs: () => (
{
onDropdownClose: () => combobox.resetSelectedOption(),
});
const currentLocale = useCurrentLocale();
- const changeLocale = useChangeLocale();
+ const { changeLocale, isPending } = useChangeLocale();
const handleOnOptionSubmit = React.useCallback(
(value: string) => {
@@ -39,6 +39,7 @@ export const LanguageCombobox = () => {
component="button"
type="button"
pointer
+ leftSection={isPending ? : null}
rightSection={}
rightSectionPointerEvents="none"
onClick={handleOnClick}
@@ -72,11 +73,11 @@ const OptionItem = ({
return (
-
+
- {localeAttributes[localeKey].name}
+ {localeConfigurations[localeKey].name}
- ({localeAttributes[localeKey].translatedName})
+ ({localeConfigurations[localeKey].translatedName})
diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts
index e29902783..45c9ad902 100644
--- a/packages/common/src/types.ts
+++ b/packages/common/src/types.ts
@@ -5,3 +5,7 @@ export type AtLeastOneOf = [T, ...T[]];
export type Modify>> = {
[P in keyof (Omit & R)]: (Omit & R)[P];
};
+
+export type RemoveReadonly = {
+ -readonly [P in keyof T]: T[P] extends Record ? RemoveReadonly : T[P];
+};
diff --git a/packages/modals-collection/src/invites/invite-copy-modal.tsx b/packages/modals-collection/src/invites/invite-copy-modal.tsx
index 2fa3890a3..08136af34 100644
--- a/packages/modals-collection/src/invites/invite-copy-modal.tsx
+++ b/packages/modals-collection/src/invites/invite-copy-modal.tsx
@@ -12,8 +12,11 @@ export const InviteCopyModal = createModal
- {t("action.copy.description")}
- {/* TODO: When next-international v2 is released the descriptions bold element can be implemented, see https://github.com/QuiiBz/next-international/pull/361 for progress */}
+
+ {t.rich("action.copy.description", {
+ b: (children) => {children},
+ })}
+
{t("action.copy.link")}
{t("field.id.label")}:
diff --git a/packages/spotlight/src/modes/command/children/language.tsx b/packages/spotlight/src/modes/command/children/language.tsx
index 848139b68..f861746dd 100644
--- a/packages/spotlight/src/modes/command/children/language.tsx
+++ b/packages/spotlight/src/modes/command/children/language.tsx
@@ -1,7 +1,7 @@
import { Group, Stack, Text } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
-import { localeAttributes, supportedLanguages } from "@homarr/translation";
+import { localeConfigurations, supportedLanguages } from "@homarr/translation";
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../../lib/children";
@@ -11,34 +11,34 @@ export const languageChildrenOptions = createChildrenOptions ({ localeKey, attributes: localeAttributes[localeKey] }))
+ .map((localeKey) => ({ localeKey, configuration: localeConfigurations[localeKey] }))
.filter(
- ({ attributes }) =>
- attributes.name.toLowerCase().includes(normalizedQuery) ||
- attributes.translatedName.toLowerCase().includes(normalizedQuery),
+ ({ configuration }) =>
+ configuration.name.toLowerCase().includes(normalizedQuery) ||
+ configuration.translatedName.toLowerCase().includes(normalizedQuery),
)
.sort(
(languageA, languageB) =>
Math.min(
- languageA.attributes.name.toLowerCase().indexOf(normalizedQuery),
- languageA.attributes.translatedName.toLowerCase().indexOf(normalizedQuery),
+ languageA.configuration.name.toLowerCase().indexOf(normalizedQuery),
+ languageA.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
) -
Math.min(
- languageB.attributes.name.toLowerCase().indexOf(normalizedQuery),
- languageB.attributes.translatedName.toLowerCase().indexOf(normalizedQuery),
+ languageB.configuration.name.toLowerCase().indexOf(normalizedQuery),
+ languageB.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
),
)
- .map(({ localeKey, attributes }) => ({
+ .map(({ localeKey, configuration }) => ({
key: localeKey,
Component() {
return (
-
+
- {attributes.name}
+ {configuration.name}
- ({attributes.translatedName})
+ ({configuration.translatedName})
@@ -47,7 +47,7 @@ export const languageChildrenOptions = createChildrenOptions changeLocale(localeKey) };
},
diff --git a/packages/translation/package.json b/packages/translation/package.json
index 0f4f728d3..4b5ef75ee 100644
--- a/packages/translation/package.json
+++ b/packages/translation/package.json
@@ -6,9 +6,10 @@
"type": "module",
"exports": {
".": "./index.ts",
- "./client": "./src/client.ts",
+ "./client": "./src/client/index.ts",
"./server": "./src/server.ts",
- "./middleware": "./src/middleware.ts"
+ "./middleware": "./src/middleware.ts",
+ "./request": "./src/request.ts"
},
"typesVersions": {
"*": {
@@ -25,9 +26,12 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
+ "@homarr/common": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"mantine-react-table": "2.0.0-beta.7",
- "next-international": "^1.2.4"
+ "next": "^14.2.16",
+ "next-intl": "3.23.2",
+ "react": "^18.3.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
diff --git a/packages/translation/src/client.ts b/packages/translation/src/client.ts
deleted file mode 100644
index 8cbb49e73..000000000
--- a/packages/translation/src/client.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-"use client";
-
-import { createI18nClient } from "next-international/client";
-
-import { languageMapping } from "./lang";
-import enTranslation from "./lang/en";
-
-export const { useI18n, useScopedI18n, useCurrentLocale, useChangeLocale, I18nProviderClient } = createI18nClient(
- languageMapping(),
- {
- fallbackLocale: enTranslation,
- },
-);
diff --git a/packages/translation/src/client/index.ts b/packages/translation/src/client/index.ts
new file mode 100644
index 000000000..16d73330b
--- /dev/null
+++ b/packages/translation/src/client/index.ts
@@ -0,0 +1,19 @@
+"use client";
+
+import { useMessages, useTranslations } from "next-intl";
+
+import type { TranslationObject } from "../type";
+
+export { useChangeLocale } from "./use-change-locale";
+export { useCurrentLocale } from "./use-current-locale";
+
+export const { useI18n, useScopedI18n } = {
+ useI18n: useTranslations,
+ useScopedI18n: useTranslations,
+};
+
+export const { useI18nMessages } = {
+ useI18nMessages: () => useMessages() as TranslationObject,
+};
+
+export { useTranslations };
diff --git a/packages/translation/src/client/use-change-locale.ts b/packages/translation/src/client/use-change-locale.ts
new file mode 100644
index 000000000..7d5ce06e1
--- /dev/null
+++ b/packages/translation/src/client/use-change-locale.ts
@@ -0,0 +1,25 @@
+import { useTransition } from "react";
+import { usePathname, useRouter } from "next/navigation";
+
+import type { SupportedLanguage } from "../config";
+import { useCurrentLocale } from "./use-current-locale";
+
+export const useChangeLocale = () => {
+ const currentLocale = useCurrentLocale();
+ const router = useRouter();
+ const pathname = usePathname();
+ const [isPending, startTransition] = useTransition();
+
+ return {
+ changeLocale: (newLocale: SupportedLanguage) => {
+ if (newLocale === currentLocale) {
+ return;
+ }
+
+ startTransition(() => {
+ router.replace(`/${newLocale}/${pathname}`);
+ });
+ },
+ isPending,
+ };
+};
diff --git a/packages/translation/src/client/use-current-locale.ts b/packages/translation/src/client/use-current-locale.ts
new file mode 100644
index 000000000..f05eff7e5
--- /dev/null
+++ b/packages/translation/src/client/use-current-locale.ts
@@ -0,0 +1,5 @@
+import { useLocale } from "next-intl";
+
+import type { SupportedLanguage } from "../config";
+
+export const useCurrentLocale = () => useLocale() as SupportedLanguage;
diff --git a/packages/translation/src/config.ts b/packages/translation/src/config.ts
new file mode 100644
index 000000000..6a88e640d
--- /dev/null
+++ b/packages/translation/src/config.ts
@@ -0,0 +1,26 @@
+import { objectKeys } from "@homarr/common";
+
+export const localeConfigurations = {
+ de: {
+ name: "Deutsch",
+ translatedName: "German",
+ flagIcon: "de",
+ },
+ en: {
+ name: "English",
+ translatedName: "English",
+ flagIcon: "us",
+ },
+} satisfies Record<
+ string,
+ {
+ name: string;
+ translatedName: string;
+ flagIcon: string;
+ }
+>;
+
+export const supportedLanguages = objectKeys(localeConfigurations);
+export type SupportedLanguage = (typeof supportedLanguages)[number];
+
+export const defaultLocale = "en" satisfies SupportedLanguage;
diff --git a/packages/translation/src/index.ts b/packages/translation/src/index.ts
index 89f14b401..fff1e95aa 100644
--- a/packages/translation/src/index.ts
+++ b/packages/translation/src/index.ts
@@ -1,14 +1,11 @@
+import type { SupportedLanguage } from "./config";
+import { supportedLanguages } from "./config";
import type { stringOrTranslation, TranslationFunction } from "./type";
export * from "./type";
-export * from "./locale-attributes";
-
-export const supportedLanguages = ["en", "de"] as const;
-export type SupportedLanguage = (typeof supportedLanguages)[number];
-
-export const defaultLocale = "en";
-export { languageMapping } from "./lang";
-export type { TranslationKeys } from "./lang";
+export * from "./config";
+export { createLanguageMapping } from "./mapping";
+export type { TranslationKeys } from "./mapping";
export const translateIfNecessary = (t: TranslationFunction, value: stringOrTranslation | undefined) => {
if (typeof value === "function") {
@@ -16,3 +13,7 @@ export const translateIfNecessary = (t: TranslationFunction, value: stringOrTran
}
return value;
};
+
+export const isLocaleSupported = (locale: string): locale is SupportedLanguage => {
+ return supportedLanguages.includes(locale as SupportedLanguage);
+};
diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts
index 670be6712..836b6bd99 100644
--- a/packages/translation/src/lang/en.ts
+++ b/packages/translation/src/lang/en.ts
@@ -668,7 +668,7 @@ export default {
},
},
},
- mantineReactTable: MRT_Localization_EN,
+ mantineReactTable: MRT_Localization_EN as Readonly>,
},
section: {
dynamic: {
@@ -1167,11 +1167,11 @@ export default {
},
integration: {
noData: "No integration found",
- description: "Click {here} to create a new integration",
+ description: "Click to create a new integration",
},
app: {
noData: "No app found",
- description: "Click {here} to create a new app",
+ description: "Click to create a new app",
},
error: {
action: {
@@ -1800,7 +1800,7 @@ export default {
copy: {
title: "Copy invite",
description:
- "Your invitation has been generated. After this modal closes, you'll not be able to copy this link anymore. If you do no longer wish to invite said person, you can delete this invitation any time.",
+ "Your invitation has been generated. After this modal closes, you'll not be able to copy this link anymore. If you do no longer wish to invite said person, you can delete this invitation any time.",
link: "Invitation link",
button: "Copy & close",
},
diff --git a/packages/translation/src/locale-attributes.ts b/packages/translation/src/locale-attributes.ts
deleted file mode 100644
index 69967c003..000000000
--- a/packages/translation/src/locale-attributes.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { SupportedLanguage } from ".";
-
-export const localeAttributes: Record<
- SupportedLanguage,
- {
- name: string;
- translatedName: string;
- flagIcon: string;
- }
-> = {
- de: {
- name: "Deutsch",
- translatedName: "German",
- flagIcon: "de",
- },
- en: {
- name: "English",
- translatedName: "English",
- flagIcon: "us",
- },
-};
diff --git a/packages/translation/src/lang.ts b/packages/translation/src/mapping.ts
similarity index 88%
rename from packages/translation/src/lang.ts
rename to packages/translation/src/mapping.ts
index 5874d7918..b65d7e7bb 100644
--- a/packages/translation/src/lang.ts
+++ b/packages/translation/src/mapping.ts
@@ -1,9 +1,9 @@
-import { supportedLanguages } from ".";
+import { supportedLanguages } from "./config";
const _enTranslations = () => import("./lang/en");
type EnTranslation = typeof _enTranslations;
-export const languageMapping = () => {
+export const createLanguageMapping = () => {
const mapping: Record = {};
for (const language of supportedLanguages) {
diff --git a/packages/translation/src/middleware.ts b/packages/translation/src/middleware.ts
index 77d2a138d..636c1c5ca 100644
--- a/packages/translation/src/middleware.ts
+++ b/packages/translation/src/middleware.ts
@@ -1,9 +1,10 @@
-import { createI18nMiddleware } from "next-international/middleware";
+import createMiddleware from "next-intl/middleware";
-import { defaultLocale, supportedLanguages } from ".";
+import { routing } from "./routing";
-export const I18nMiddleware = createI18nMiddleware({
- locales: supportedLanguages,
- defaultLocale,
- urlMappingStrategy: "rewrite",
-});
+export const I18nMiddleware = createMiddleware(routing);
+
+export const config = {
+ // Match only internationalized pathnames
+ matcher: ["/", "/(de|en)/:path*"],
+};
diff --git a/packages/translation/src/request.ts b/packages/translation/src/request.ts
new file mode 100644
index 000000000..4436d7443
--- /dev/null
+++ b/packages/translation/src/request.ts
@@ -0,0 +1,34 @@
+import deepmerge from "deepmerge";
+import { getRequestConfig } from "next-intl/server";
+
+import { isLocaleSupported } from ".";
+import type { SupportedLanguage } from "./config";
+import { createLanguageMapping } from "./mapping";
+import { routing } from "./routing";
+
+// This file is referenced in the `next.config.js` file. See https://next-intl-docs.vercel.app/docs/usage/configuration
+export default getRequestConfig(async ({ requestLocale }) => {
+ let currentLocale = await requestLocale;
+
+ if (!currentLocale || !isLocaleSupported(currentLocale)) {
+ currentLocale = routing.defaultLocale;
+ }
+ const typedLocale = currentLocale as SupportedLanguage;
+
+ const languageMap = createLanguageMapping();
+ const currentMessages = (await languageMap[typedLocale]()).default;
+
+ // Fallback to default locale if the current locales messages if not all messages are present
+ if (currentLocale !== routing.defaultLocale) {
+ const fallbackMessages = (await languageMap[routing.defaultLocale]()).default;
+ return {
+ locale: currentLocale,
+ messages: deepmerge(fallbackMessages, currentMessages),
+ };
+ }
+
+ return {
+ locale: currentLocale,
+ messages: currentMessages,
+ };
+});
diff --git a/packages/translation/src/routing.ts b/packages/translation/src/routing.ts
new file mode 100644
index 000000000..a7e171bc5
--- /dev/null
+++ b/packages/translation/src/routing.ts
@@ -0,0 +1,11 @@
+import { defineRouting } from "next-intl/routing";
+
+import { defaultLocale, supportedLanguages } from "./config";
+
+export const routing = defineRouting({
+ locales: supportedLanguages,
+ defaultLocale,
+ localePrefix: {
+ mode: "never", // Rewrite the URL with locale parameter but without shown in url
+ },
+});
diff --git a/packages/translation/src/server.ts b/packages/translation/src/server.ts
index c7b32793a..bfab90719 100644
--- a/packages/translation/src/server.ts
+++ b/packages/translation/src/server.ts
@@ -1,8 +1,8 @@
-import { createI18nServer } from "next-international/server";
+import { getTranslations } from "next-intl/server";
-import { languageMapping } from "./lang";
-import enTranslation from "./lang/en";
+export const { getI18n, getScopedI18n } = {
+ getI18n: getTranslations,
+ getScopedI18n: getTranslations,
+};
-export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer(languageMapping(), {
- fallbackLocale: enTranslation,
-});
+export { getMessages as getI18nMessages } from "next-intl/server";
diff --git a/packages/translation/src/type.ts b/packages/translation/src/type.ts
index e2baf031f..6057069f0 100644
--- a/packages/translation/src/type.ts
+++ b/packages/translation/src/type.ts
@@ -1,9 +1,19 @@
+import type { NamespaceKeys, NestedKeyOf } from "next-intl";
+
+import type { RemoveReadonly } from "@homarr/common/types";
+
import type { useI18n, useScopedI18n } from "./client";
import type enTranslation from "./lang/en";
-export type TranslationFunction = ReturnType;
-export type ScopedTranslationFunction[0]> = ReturnType<
- typeof useScopedI18n
->;
+export type TranslationFunction = ReturnType>;
+export type ScopedTranslationFunction<
+ NestedKey extends NamespaceKeys> = never,
+> = ReturnType>;
export type TranslationObject = typeof enTranslation;
export type stringOrTranslation = string | ((t: TranslationFunction) => string);
+
+declare global {
+ // Use type safe message keys with `next-intl`
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
+ interface IntlMessages extends RemoveReadonly {}
+}
diff --git a/packages/ui/src/components/beta-badge.tsx b/packages/ui/src/components/beta-badge.tsx
index 4c7381196..8d9d36615 100644
--- a/packages/ui/src/components/beta-badge.tsx
+++ b/packages/ui/src/components/beta-badge.tsx
@@ -1,14 +1,16 @@
+"use client";
+
import type { BadgeProps } from "@mantine/core";
import { Badge } from "@mantine/core";
-import { useI18n } from "@homarr/translation/client";
+import { useTranslations } from "@homarr/translation/client";
interface BetaBadgeProps {
size: BadgeProps["size"];
}
export const BetaBadge = ({ size }: BetaBadgeProps) => {
- const t = useI18n();
+ const t = useTranslations();
return (
{t("common.beta")}
diff --git a/packages/ui/src/hooks/use-translated-mantine-react-table.ts b/packages/ui/src/hooks/use-translated-mantine-react-table.ts
index 1be41a63b..275f48b75 100644
--- a/packages/ui/src/hooks/use-translated-mantine-react-table.ts
+++ b/packages/ui/src/hooks/use-translated-mantine-react-table.ts
@@ -1,22 +1,14 @@
import type { MRT_RowData, MRT_TableOptions } from "mantine-react-table";
import { useMantineReactTable } from "mantine-react-table";
-import { MRT_Localization_EN } from "mantine-react-table/locales/en/index.cjs";
-import { objectKeys } from "@homarr/common";
-import { useScopedI18n } from "@homarr/translation/client";
+import { useI18nMessages } from "@homarr/translation/client";
export const useTranslatedMantineReactTable = (
tableOptions: Omit, "localization">,
) => {
- const t = useScopedI18n("common.mantineReactTable");
+ const messages = useI18nMessages();
return useMantineReactTable({
...tableOptions,
- localization: objectKeys(MRT_Localization_EN).reduce(
- (acc, key) => {
- acc[key] = t(key);
- return acc;
- },
- {} as typeof MRT_Localization_EN,
- ),
+ localization: messages.common.mantineReactTable,
});
};
diff --git a/packages/validation/src/form/i18n.ts b/packages/validation/src/form/i18n.ts
index c057fad57..db53d1e95 100644
--- a/packages/validation/src/form/i18n.ts
+++ b/packages/validation/src/form/i18n.ts
@@ -1,15 +1,9 @@
-import type { ParamsObject } from "international-types";
import type { ErrorMapCtx, z, ZodTooBigIssue, ZodTooSmallIssue } from "zod";
import { ZodIssueCode } from "zod";
-import type { TranslationObject } from "@homarr/translation";
+import type { TranslationFunction, TranslationObject } from "@homarr/translation";
-export const zodErrorMap = <
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- TFunction extends (key: string, ...params: any[]) => string,
->(
- t: TFunction,
-) => {
+export const zodErrorMap = (t: TFunction) => {
return (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
const error = handleZodError(issue, ctx);
if ("message" in error && error.message) {
@@ -139,7 +133,7 @@ type CustomErrorKey = keyof TranslationObject["common"]["zod"]["errors"]["custom
export interface CustomErrorParams {
i18n: {
key: TKey;
- params: ParamsObject;
+ params: Record;
};
}
diff --git a/packages/validation/src/user.ts b/packages/validation/src/user.ts
index 566e43363..65bac9500 100644
--- a/packages/validation/src/user.ts
+++ b/packages/validation/src/user.ts
@@ -30,7 +30,10 @@ const passwordSchema = z
return passwordRequirements.every((requirement) => requirement.check(value));
},
{
- params: createCustomErrorParams("passwordRequirements"),
+ params: createCustomErrorParams({
+ key: "passwordRequirements",
+ params: {},
+ }),
},
);
@@ -38,7 +41,10 @@ const confirmPasswordRefine = [
(data: { password: string; confirmPassword: string }) => data.password === data.confirmPassword,
{
path: ["confirmPassword"],
- params: createCustomErrorParams("passwordsDoNotMatch"),
+ params: createCustomErrorParams({
+ key: "passwordsDoNotMatch",
+ params: {},
+ }),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] satisfies [(args: any) => boolean, unknown];
diff --git a/packages/widgets/src/_inputs/widget-app-input.tsx b/packages/widgets/src/_inputs/widget-app-input.tsx
index ee22b08da..fd3cc0599 100644
--- a/packages/widgets/src/_inputs/widget-app-input.tsx
+++ b/packages/widgets/src/_inputs/widget-app-input.tsx
@@ -43,8 +43,8 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
inputWrapperOrder={["label", "input", "description", "error"]}
description={
- {t("widget.common.app.description", {
- here: (
+ {t.rich("widget.common.app.description", {
+ here: () => (
{t("common.here")}
diff --git a/packages/widgets/src/test/translation.spec.ts b/packages/widgets/src/test/translation.spec.ts
index 5415fefcc..967c5de85 100644
--- a/packages/widgets/src/test/translation.spec.ts
+++ b/packages/widgets/src/test/translation.spec.ts
@@ -1,12 +1,12 @@
import { describe, expect, it } from "vitest";
import { objectEntries } from "@homarr/common";
-import { languageMapping } from "@homarr/translation";
+import { createLanguageMapping } from "@homarr/translation";
import { widgetImports } from "..";
describe("Widget properties with description should have matching translations", async () => {
- const enTranslation = await languageMapping().en();
+ const enTranslation = await createLanguageMapping().en();
objectEntries(widgetImports).forEach(([key, value]) => {
Object.entries(value.definition.options).forEach(
([optionKey, optionValue]: [string, { withDescription?: boolean }]) => {
@@ -25,7 +25,7 @@ describe("Widget properties with description should have matching translations",
});
describe("Widget properties should have matching name translations", async () => {
- const enTranslation = await languageMapping().en();
+ const enTranslation = await createLanguageMapping().en();
objectEntries(widgetImports).forEach(([key, value]) => {
Object.keys(value.definition.options).forEach((optionKey) => {
it(`should have matching translations for ${optionKey} option name of ${key} widget`, () => {
diff --git a/packages/widgets/src/widget-integration-select.tsx b/packages/widgets/src/widget-integration-select.tsx
index 3aa27fcd6..215b1ccf2 100644
--- a/packages/widgets/src/widget-integration-select.tsx
+++ b/packages/widgets/src/widget-integration-select.tsx
@@ -92,8 +92,8 @@ export const WidgetIntegrationSelect = ({
inputWrapperOrder={["label", "input", "description", "error"]}
description={
- {t("widget.common.integration.description", {
- here: (
+ {t.rich("widget.common.integration.description", {
+ here: () => (
{t("common.here")}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4ffccc7d0..730b29981 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1389,15 +1389,24 @@ importers:
packages/translation:
dependencies:
+ '@homarr/common':
+ specifier: workspace:^0.1.0
+ version: link:../common
dayjs:
specifier: ^1.11.13
version: 1.11.13
mantine-react-table:
specifier: 2.0.0-beta.7
version: 2.0.0-beta.7(@mantine/core@7.13.4(@mantine/hooks@7.13.4(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/dates@7.13.4(@mantine/core@7.13.4(@mantine/hooks@7.13.4(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.13.4(react@18.3.1))(dayjs@1.11.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.13.4(react@18.3.1))(@tabler/icons-react@3.20.0(react@18.3.1))(clsx@2.1.1)(dayjs@1.11.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- next-international:
- specifier: ^1.2.4
- version: 1.2.4
+ next:
+ specifier: ^14.2.16
+ version: 14.2.16(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.4)
+ next-intl:
+ specifier: 3.23.2
+ version: 3.23.2(next@14.2.16(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.4))(react@18.3.1)
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
devDependencies:
'@homarr/eslint-config':
specifier: workspace:^0.2.0
@@ -2654,6 +2663,21 @@ packages:
'@floating-ui/utils@0.2.8':
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
+ '@formatjs/ecma402-abstract@2.2.0':
+ resolution: {integrity: sha512-IpM+ev1E4QLtstniOE29W1rqH9eTdx5hQdNL8pzrflMj/gogfaoONZqL83LUeQScHAvyMbpqP5C9MzNf+fFwhQ==}
+
+ '@formatjs/fast-memoize@2.2.1':
+ resolution: {integrity: sha512-XS2RcOSyWxmUB7BUjj3mlPH0exsUzlf6QfhhijgI941WaJhVxXQ6mEWkdUFIdnKi3TuTYxRdelsgv3mjieIGIA==}
+
+ '@formatjs/icu-messageformat-parser@2.8.0':
+ resolution: {integrity: sha512-r2un3fmF9oJv3mOkH+wwQZ037VpqmdfahbcCZ9Lh+p6Sx+sNsonI7Zcr6jNMm1s+Si7ejQORS4Ezlh05mMPAXA==}
+
+ '@formatjs/icu-skeleton-parser@1.8.4':
+ resolution: {integrity: sha512-LMQ1+Wk1QSzU4zpd5aSu7+w5oeYhupRwZnMQckLPRYhSjf2/8JWQ882BauY9NyHxs5igpuQIXZDgfkaH3PoATg==}
+
+ '@formatjs/intl-localematcher@0.5.5':
+ resolution: {integrity: sha512-t5tOGMgZ/i5+ALl2/offNqAQq/lfUnKLEw0mXQI4N4bqpedhrSE+fyKLpwnd22sK0dif6AV+ufQcTsKShB9J1g==}
+
'@hapi/bourne@3.0.0':
resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==}
@@ -5472,8 +5496,8 @@ packages:
resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==}
engines: {node: '>= 0.4'}
- international-types@0.8.1:
- resolution: {integrity: sha512-tajBCAHo4I0LIFlmQ9ZWfjMWVyRffzuvfbXCd6ssFt5u1Zw15DN0UBpVTItXdNa1ls+cpQt3Yw8+TxsfGF8JcA==}
+ intl-messageformat@10.7.1:
+ resolution: {integrity: sha512-xQuJW2WcyzNJZWUu5xTVPOmNSA1Sowuu/NKFdUid5Fxx/Yl6/s4DefTU/y7zy+irZLDmFGmTLtnM8FqpN05wlA==}
invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
@@ -6128,8 +6152,11 @@ packages:
nodemailer:
optional: true
- next-international@1.2.4:
- resolution: {integrity: sha512-JQvp+h2iSgA/t8hu5S/Lwow1ZErJutQRdpnplxjv4VTlCiND8T95fYih8BjkHcVhQbtM+Wu9Mb1CM32wD9hlWQ==}
+ next-intl@3.23.2:
+ resolution: {integrity: sha512-SCYEG2i0kYz+OupN6+qH9T+GDRfLCmJuT835uI9ac7AOlYCUbBizj28cti+oGhDkIjueZrweVw7iEiTkqCpKpQ==}
+ peerDependencies:
+ next: ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
next@14.2.16:
resolution: {integrity: sha512-LcO7WnFu6lYSvCzZoo1dB+IO0xXz5uEv52HF1IUN0IqVTUIZGHuuR10I5efiLadGt+4oZqTcNZyVVEem/TM5nA==}
@@ -7008,9 +7035,6 @@ packages:
serialize-javascript@6.0.2:
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
- server-only@0.0.1:
- resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
-
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
@@ -7717,6 +7741,11 @@ packages:
peerDependencies:
react: '>=16.13'
+ use-intl@3.23.2:
+ resolution: {integrity: sha512-lrKb5M6zr9YoHK+OuUsRApPPNEMHX8ntx0PDGZ0fxlMmj6W2u/3y++UB4uE/o0C8Jyn7oiHCjShYjgPjDaB1cg==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+
use-isomorphic-layout-effect@1.1.2:
resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==}
peerDependencies:
@@ -8767,6 +8796,31 @@ snapshots:
'@floating-ui/utils@0.2.8': {}
+ '@formatjs/ecma402-abstract@2.2.0':
+ dependencies:
+ '@formatjs/fast-memoize': 2.2.1
+ '@formatjs/intl-localematcher': 0.5.5
+ tslib: 2.7.0
+
+ '@formatjs/fast-memoize@2.2.1':
+ dependencies:
+ tslib: 2.7.0
+
+ '@formatjs/icu-messageformat-parser@2.8.0':
+ dependencies:
+ '@formatjs/ecma402-abstract': 2.2.0
+ '@formatjs/icu-skeleton-parser': 1.8.4
+ tslib: 2.7.0
+
+ '@formatjs/icu-skeleton-parser@1.8.4':
+ dependencies:
+ '@formatjs/ecma402-abstract': 2.2.0
+ tslib: 2.7.0
+
+ '@formatjs/intl-localematcher@0.5.5':
+ dependencies:
+ tslib: 2.7.0
+
'@hapi/bourne@3.0.0': {}
'@homarr/gridstack@1.0.3': {}
@@ -12169,7 +12223,12 @@ snapshots:
hasown: 2.0.2
side-channel: 1.0.6
- international-types@0.8.1: {}
+ intl-messageformat@10.7.1:
+ dependencies:
+ '@formatjs/ecma402-abstract': 2.2.0
+ '@formatjs/fast-memoize': 2.2.1
+ '@formatjs/icu-messageformat-parser': 2.8.0
+ tslib: 2.7.0
invariant@2.2.4:
dependencies:
@@ -12797,11 +12856,13 @@ snapshots:
next: 14.2.16(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.4)
react: 18.3.1
- next-international@1.2.4:
+ next-intl@3.23.2(next@14.2.16(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.4))(react@18.3.1):
dependencies:
- client-only: 0.0.1
- international-types: 0.8.1
- server-only: 0.0.1
+ '@formatjs/intl-localematcher': 0.5.5
+ negotiator: 0.6.3
+ next: 14.2.16(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.4)
+ react: 18.3.1
+ use-intl: 3.23.2(react@18.3.1)
next@14.2.16(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.4):
dependencies:
@@ -13816,8 +13877,6 @@ snapshots:
dependencies:
randombytes: 2.1.0
- server-only@0.0.1: {}
-
set-blocking@2.0.0: {}
set-function-length@1.2.2:
@@ -14632,6 +14691,12 @@ snapshots:
dequal: 2.0.3
react: 18.3.1
+ use-intl@3.23.2(react@18.3.1):
+ dependencies:
+ '@formatjs/fast-memoize': 2.2.1
+ intl-messageformat: 10.7.1
+ react: 18.3.1
+
use-isomorphic-layout-effect@1.1.2(@types/react@18.3.12)(react@18.3.1):
dependencies:
react: 18.3.1