Bladeren bron

Add i18n

tags/Baseline_30082024_FRONTEND_UAT
Wayne Lee 1 jaar geleden
bovenliggende
commit
19fe83b1f2
17 gewijzigde bestanden met toevoegingen van 386 en 54 verwijderingen
  1. +9
    -18
      README.md
  2. +127
    -1
      package-lock.json
  3. +5
    -1
      package.json
  4. +1
    -1
      src/app/dashboard/page.tsx
  5. +5
    -3
      src/app/layout.tsx
  6. +5
    -13
      src/app/login/page.tsx
  7. +16
    -10
      src/components/LoginPage/LoginForm.tsx
  8. +20
    -0
      src/components/LoginPage/LoginPage.tsx
  9. +1
    -0
      src/components/LoginPage/index.ts
  10. +20
    -4
      src/config/authConfig.ts
  11. +43
    -0
      src/i18n/I18nClientProvider.tsx
  12. +1
    -0
      src/i18n/en/login.json
  13. +3
    -0
      src/i18n/en/translation.json
  14. +92
    -0
      src/i18n/index.tsx
  15. +10
    -0
      src/i18n/zh/login.json
  16. +3
    -0
      src/i18n/zh/translation.json
  17. +25
    -3
      src/middleware.ts

+ 9
- 18
README.md Bestand weergeven

@@ -2,7 +2,9 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next

## Setting Up the Environment

It is recommended to use the same node and npm versions for development. An easy way to do so would be to use `nvm`. After installing `nvm`, run:
It is recommended to use the same node and npm versions for development. An easy way to do so would be to use `nvm` ([Linux/MacOS](https://github.com/nvm-sh/nvm), [Windows](https://github.com/coreybutler/nvm-windows)).

After installing `nvm`, run:

```bash
nvm use
@@ -21,21 +23,10 @@ npm run dev

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.

This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.

## Learn More

To learn more about Next.js, take a look at the following resources:

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!

## Deploy on Vercel

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
## References

Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
This project uses the following libraries:
- [NextJS](https://nextjs.org/docs)
- [Next-Auth](https://next-auth.js.org/getting-started/example)
- [Material UI](https://mui.com/material-ui/getting-started/)
- [i18next](https://www.i18next.com/overview/getting-started)

+ 127
- 1
package-lock.json Bestand weergeven

@@ -16,11 +16,15 @@
"@mui/icons-material": "^5.15.0",
"@mui/material": "^5.15.0",
"@mui/material-nextjs": "^5.15.0",
"@unly/universal-language-detector": "^2.0.3",
"i18next": "^23.7.11",
"i18next-resources-to-backend": "^1.2.0",
"next": "14.0.4",
"next-auth": "^4.24.5",
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.49.2"
"react-hook-form": "^7.49.2",
"react-i18next": "^13.5.0"
},
"devDependencies": {
"@types/node": "^20",
@@ -1068,6 +1072,43 @@
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
"dev": true
},
"node_modules/@unly/iso3166-1": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@unly/iso3166-1/-/iso3166-1-1.0.2.tgz",
"integrity": "sha512-aL/7cmcfjpwOaKFr9XHcWP/Z7lQjKLm5NMcjncT96aeSJxfblmPLnH/8lnX0GrWWFA2/CseCxnA73u5eiZcClA=="
},
"node_modules/@unly/universal-language-detector": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@unly/universal-language-detector/-/universal-language-detector-2.0.3.tgz",
"integrity": "sha512-UMmZIQS2XfJko8/GOrJc9ojO4UIF9HJM59NyQgcH+Ncrb0WBHL2L7EZ/7iZ/YcdoRnioonRECUQmmUELWt9z5Q==",
"dependencies": {
"@unly/iso3166-1": "1.0.2",
"@unly/utils": "1.0.3",
"accept-language-parser": "1.5.0",
"i18next": "19.0.2",
"i18next-browser-languagedetector": "4.0.1",
"lodash.get": "4.4.2",
"lodash.includes": "4.3.0"
}
},
"node_modules/@unly/universal-language-detector/node_modules/i18next": {
"version": "19.0.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-19.0.2.tgz",
"integrity": "sha512-fBa43Ann2udP1CQAz3IQpOZ1dGAkmi3mMfzisOhH17igneSRbvZ7P2RNbL+L1iRYKMufBmVwnC7G3gqcyviZ9g==",
"dependencies": {
"@babel/runtime": "^7.3.1"
}
},
"node_modules/@unly/utils": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@unly/utils/-/utils-1.0.3.tgz",
"integrity": "sha512-QTRknIDX56FvzGcIpBum5D/oRSlX3dkZ+l1op1jsFlYCTd925OGUb991V7zsFv3ePcqFfvfqfR5cNVv+w4JAOw=="
},
"node_modules/accept-language-parser": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/accept-language-parser/-/accept-language-parser-1.5.0.tgz",
"integrity": "sha512-QhyTbMLYo0BBGg1aWbeMG4ekWtds/31BrEU+DONOg/7ax23vxpL03Pb7/zBmha2v7vdD3AyzZVWBVGEZxKOXWw=="
},
"node_modules/acorn": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz",
@@ -3003,6 +3044,14 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/human-signals": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz",
@@ -3012,6 +3061,44 @@
"node": ">=14.18.0"
}
},
"node_modules/i18next": {
"version": "23.7.11",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-23.7.11.tgz",
"integrity": "sha512-A/vOkw8vY99YHU9A1Td3I1dcTiYaPnwBWzrpVzfXUXSYgogK3cmBcmop/0cnXPc6QpUWIyqaugKNxRUEZVk9Nw==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-4.0.1.tgz",
"integrity": "sha512-RxSoX6mB8cab0CTIQ+klCS764vYRj+Jk621cnFVsINvcdlb/cdi3vQFyrPwmnowB7ReUadjHovgZX+RPIzHVQQ==",
"dependencies": {
"@babel/runtime": "^7.5.5"
}
},
"node_modules/i18next-resources-to-backend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.0.tgz",
"integrity": "sha512-8f1l03s+QxDmCfpSXCh9V+AFcxAwIp0UaroWuyOx+hmmv8484GcELHs+lnu54FrNij8cDBEXvEwhzZoXsKcVpg==",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/ignore": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz",
@@ -3610,6 +3697,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ=="
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4422,6 +4519,27 @@
"react": "^16.8.0 || ^17 || ^18"
}
},
"node_modules/react-i18next": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-13.5.0.tgz",
"integrity": "sha512-CFJ5NDGJ2MUyBohEHxljOq/39NQ972rh1ajnadG9BjTk+UXbHLq4z5DKEbEQBDoIhUmmbuS/fIMJKo6VOax1HA==",
"dependencies": {
"@babel/runtime": "^7.22.5",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
@@ -5087,6 +5205,14 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/watchpack": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",


+ 5
- 1
package.json Bestand weergeven

@@ -17,11 +17,15 @@
"@mui/icons-material": "^5.15.0",
"@mui/material": "^5.15.0",
"@mui/material-nextjs": "^5.15.0",
"@unly/universal-language-detector": "^2.0.3",
"i18next": "^23.7.11",
"i18next-resources-to-backend": "^1.2.0",
"next": "14.0.4",
"next-auth": "^4.24.5",
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.49.2"
"react-hook-form": "^7.49.2",
"react-i18next": "^13.5.0"
},
"devDependencies": {
"@types/node": "^20",


+ 1
- 1
src/app/dashboard/page.tsx Bestand weergeven

@@ -1,4 +1,4 @@
const Dashboard: React.FC = () => {
const Dashboard: React.FC = async () => {
return "Dashboard (protected)";
};



+ 5
- 3
src/app/layout.tsx Bestand weergeven

@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { detectLanguage } from "@/i18n";
import ThemeRegistry from "@/theme/ThemeRegistry";

export const metadata: Metadata = {
@@ -7,13 +7,15 @@ export const metadata: Metadata = {
description: "Generated by create next app",
};

export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const lang = await detectLanguage();

return (
<html lang="en">
<html lang={lang}>
<body>
<ThemeRegistry>{children}</ThemeRegistry>
</body>


+ 5
- 13
src/app/login/page.tsx Bestand weergeven

@@ -1,9 +1,8 @@
import Grid from "@mui/material/Grid";
import Paper from "@mui/material/Paper";
import LoginForm from "./LoginForm";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/config/authConfig";
import { I18nProvider } from "@/i18n";
import LoginPage from "@/components/LoginPage/LoginPage";

const Login: React.FC = async () => {
const session = await getServerSession(authOptions);
@@ -12,16 +11,9 @@ const Login: React.FC = async () => {
}

return (
<Grid container height="100vh">
<Grid item sm>
Hero
</Grid>
<Grid item xs={12} sm={8} lg={5}>
<Paper square sx={{ height: "100%" }}>
<LoginForm />
</Paper>
</Grid>
</Grid>
<I18nProvider namespaces={["login"]}>
<LoginPage />
</I18nProvider>
);
};



src/app/login/LoginForm.tsx → src/components/LoginPage/LoginForm.tsx Bestand weergeven

@@ -5,10 +5,12 @@ import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import { TFunction } from "i18next";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";

type LoginFields = {
username: string;
@@ -16,17 +18,21 @@ type LoginFields = {
};

// Error codes in https://next-auth.js.org/configuration/pages#sign-in-page
const getHumanFriendlyErrorMessage = (serverError: string): string => {
const getHumanFriendlyErrorMessage = (
t: TFunction,
serverError: string,
): string => {
switch (serverError) {
case "CredentialsSignin":
return "Invalid username or password.";
return t("Invalid username or password.");
case "Default":
default:
return "Something went wrong. Please try again later.";
return t("Something went wrong. Please try again later.");
}
};

const LoginForm: React.FC = () => {
const { t } = useTranslation("login");
const {
register,
handleSubmit,
@@ -60,23 +66,23 @@ const LoginForm: React.FC = () => {
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<Typography variant="h1">Sign In</Typography>
<Typography variant="h1">{t("Sign In")}</Typography>
<TextField
label="Username"
{...register("username", { required: "Please enter a username" })}
label={t("Username")}
{...register("username", { required: t("Please enter a username") })}
error={Boolean(errors.username)}
helperText={errors.username?.message}
/>
<TextField
label="Password"
label={t("Password")}
type="password"
{...register("password", { required: "Please enter a password" })}
{...register("password", { required: t("Please enter a password") })}
error={Boolean(errors.password)}
helperText={errors.password?.message}
/>
{serverError && (
<FormHelperText error>
{getHumanFriendlyErrorMessage(serverError)}
{getHumanFriendlyErrorMessage(t, serverError)}
</FormHelperText>
)}
<Button
@@ -85,7 +91,7 @@ const LoginForm: React.FC = () => {
type="submit"
disabled={isSubmitting}
>
Login
{t("Login")}
</Button>
</Stack>
);

+ 20
- 0
src/components/LoginPage/LoginPage.tsx Bestand weergeven

@@ -0,0 +1,20 @@
import Grid from "@mui/material/Grid";
import Paper from "@mui/material/Paper";
import LoginForm from "./LoginForm";

const LoginPage = () => {
return (
<Grid container height="100vh">
<Grid item sm>
test
</Grid>
<Grid item xs={12} sm={8} lg={5}>
<Paper square sx={{ height: "100%" }}>
<LoginForm />
</Paper>
</Grid>
</Grid>
);
};

export default LoginPage;

+ 1
- 0
src/components/LoginPage/index.ts Bestand weergeven

@@ -0,0 +1 @@
export { default } from "./LoginPage";

+ 20
- 4
src/config/authConfig.ts Bestand weergeven

@@ -1,7 +1,12 @@
import { AuthOptions } from "next-auth";
import { AuthOptions, Session } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { LOGIN_API_PATH } from "./api";

interface SessionWithTokens extends Session {
accessToken?: string;
refreshToken?: string;
}

export const authOptions: AuthOptions = {
debug: process.env.NODE_ENV === "development",
providers: [
@@ -33,10 +38,21 @@ export const authOptions: AuthOptions = {
},
callbacks: {
jwt(params) {
return params.token;
// Add the data from user to the token
const { token, user } = params;
const newToken = { ...token, ...user };

return newToken;
},
session(params) {
return params.session;
session({ session, token }) {
const sessionWithToken: SessionWithTokens = {
...session,
// Add the data from the token to the session
accessToken: token.accessToken as string | undefined,
refreshToken: token.refreshToken as string | undefined,
};

return sessionWithToken;
},
},
};

+ 43
- 0
src/i18n/I18nClientProvider.tsx Bestand weergeven

@@ -0,0 +1,43 @@
"use client";

import { createInstance, i18n } from "i18next";
import React, { useMemo } from "react";
import { I18nextProvider } from "react-i18next";

interface Props {
children: React.ReactNode;
language: string;
resources: { [ns: string]: any };
namespaces: string[];
}

export const I18nContext = React.createContext(null);

const I18nProvider: React.FC<Props> = ({
children,
language,
resources,
namespaces,
}) => {
const i18n = useMemo<i18n>(() => {
const instance = createInstance();
instance.init({
resources: {
[language]: resources,
},
fallbackLng: language,
interpolation: {
escapeValue: false,
},
ns: namespaces,
});
return instance as i18n;
// No need to check dependencies since this
// should only be created once from the server
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
};

export default I18nProvider;

+ 1
- 0
src/i18n/en/login.json Bestand weergeven

@@ -0,0 +1 @@
{}

+ 3
- 0
src/i18n/en/translation.json Bestand weergeven

@@ -0,0 +1,3 @@
{
"test": "abc"
}

+ 92
- 0
src/i18n/index.tsx Bestand weergeven

@@ -0,0 +1,92 @@
import { cookies, headers } from "next/headers";
import { createInstance, i18n, LanguageDetectorAsyncModule } from "i18next";
import resourcesToBackend from "i18next-resources-to-backend";
import { getServerSession } from "next-auth";
import { authOptions } from "@/config/authConfig";
import I18nClientProvider from "./I18nClientProvider";
import universalLanguageDetect from "@unly/universal-language-detector";

const FALLBACK_LANG = "en";
const SUPPORTED_LANGUAGES = ["en", "zh"];

export const detectLanguage = async (): Promise<string> => {
// Logic to get language preference from cookies/headers/session
const cookiesList = cookies();
const cookiesObj = cookiesList
.getAll()
.reduce<{ [name: string]: string }>(
(acc, cookie) => ({ ...acc, [cookie.name]: cookie.value }),
{},
);
const headersList = headers();
const session = await getServerSession(authOptions);

const lang = universalLanguageDetect({
supportedLanguages: SUPPORTED_LANGUAGES,
fallbackLanguage: FALLBACK_LANG,
acceptLanguageHeader: headersList.get("accept-language") || undefined,
serverCookies: cookiesObj,
});

return lang;
};

const languageDetector: LanguageDetectorAsyncModule = {
type: "languageDetector",
detect: detectLanguage,
async: true,
};

const initI18next = async (namespaces: string[]): Promise<i18n> => {
const i18nInstance = createInstance();
await i18nInstance
.use(languageDetector)
.use(
resourcesToBackend((language: string, namespace: string) => {
return import(`./${language}/${namespace}.json`);
}),
)
.init({
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
ns: namespaces,
});
return i18nInstance as i18n;
};

export const getServerI18n = async (...namespaces: string[]) => {
return initI18next(namespaces);
};

interface Props {
children: React.ReactNode;
namespaces: string[];
}

// Provides the resources for the client
export const I18nProvider: React.FC<Props> = async ({
children,
namespaces,
}) => {
const i18n = await getServerI18n(...namespaces);
const language = i18n.language;
const resources = namespaces.reduce<{ [ns: string]: any }>(
(acc, ns) => ({
...acc,
[ns]: i18n.getResourceBundle(language, ns),
}),
{},
);

return (
<I18nClientProvider
language={language}
resources={resources}
namespaces={namespaces}
>
{children}
</I18nClientProvider>
);
};

+ 10
- 0
src/i18n/zh/login.json Bestand weergeven

@@ -0,0 +1,10 @@
{
"Invalid username or password.": "帳號或密碼錯誤。",
"Something went wrong. Please try again later.": "出了些問題。請稍後再試。",
"Username": "帳號",
"Password": "密碼",
"Please enter a username": "請輸入帳號",
"Please enter a password": "請輸入密碼",
"Login": "登入",
"Sign In": "登入"
}

+ 3
- 0
src/i18n/zh/translation.json Bestand weergeven

@@ -0,0 +1,3 @@
{
"test": "def"
}

+ 25
- 3
src/middleware.ts Bestand weergeven

@@ -1,8 +1,30 @@
import { withAuth } from "next-auth/middleware";
import { NextRequestWithAuth, withAuth } from "next-auth/middleware";
import { authOptions } from "@/config/authConfig";
import { NextFetchEvent, NextResponse } from "next/server";

export default withAuth({
const PUBLIC_ROUTES = ["/login"];
const LANG_QUERY_PARAM = "lang";

const authMiddleware = withAuth({
pages: authOptions.pages,
});

export const config = { matcher: ["/dashboard"] };
export default async function middleware(
req: NextRequestWithAuth,
event: NextFetchEvent,
) {
const langPref = req.nextUrl.searchParams.get(LANG_QUERY_PARAM);
if (langPref) {
// Redirect to same url without the lang query param + set cookies
const newUrl = new URL(req.nextUrl);
newUrl.searchParams.delete(LANG_QUERY_PARAM);
const response = NextResponse.redirect(newUrl);
response.cookies.set("i18next", langPref);
return response;
}

// Matcher for using the auth middleware
return PUBLIC_ROUTES.some((route) => req.nextUrl.pathname.startsWith(route))
? NextResponse.next() // Return normal response
: await authMiddleware(req, event); // Let auth middleware handle response
}

Laden…
Annuleren
Opslaan