Add i18n support #5
@ -1 +1,2 @@
|
|||||||
src/lib/api.d.ts
|
src/lib/api.d.ts
|
||||||
|
src/i18n/locales/*/*.ts
|
||||||
15
frontend/.swcrc
Normal file
15
frontend/.swcrc
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://swc.rs/schema.json",
|
||||||
|
"jsc": {
|
||||||
|
"experimental": {
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"@lingui/swc-plugin",
|
||||||
|
{
|
||||||
|
// Additional Configuration
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,63 @@
|
|||||||
|
|
||||||
## Developing
|
## Developing
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
|
||||||
|
This project uses [Lingui](https://lingui.js.org/) for internalization. The translations are stored in the `src/i18n/locales` folder.
|
||||||
|
|
||||||
|
**Translating in JSX: `<Trans>` Component**
|
||||||
|
|
||||||
|
Use the `<Trans>` component when translating strings directly in JSX markup:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
|
function Greeting() {
|
||||||
|
return (
|
||||||
|
<h1>
|
||||||
|
<Trans>Hello, world!</Trans>
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Translating in JavaScript: `i18n._(t\`...`)`**
|
||||||
|
|
||||||
|
For translations used **outside of JSX**, such as in variables, logs, or logic, use `i18n._(t\`...`)`:
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { i18n } from "@lingui/core";
|
||||||
|
|
||||||
|
console.log(i18n._(t`Operation completed successfully.`));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extracting & Compiling Translations
|
||||||
|
|
||||||
|
#### Extract Strings
|
||||||
|
|
||||||
|
To extract translatable strings into `.po` files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# To extract all translations
|
||||||
|
npm run extract
|
||||||
|
|
||||||
|
# To extract translations and clean up unused ones
|
||||||
|
npm run extract-clean
|
||||||
|
```
|
||||||
|
|
||||||
|
This updates the `messages.po` files inside `src/i18n/locales/{locale}/`.
|
||||||
|
|
||||||
|
#### Compile Translations
|
||||||
|
|
||||||
|
After updating the `.po` files with your translations, compile them into `.ts` files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run compile
|
||||||
|
```
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node v20
|
- Node v20
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import tseslint from "typescript-eslint";
|
|||||||
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ["dist"] },
|
{ ignores: ["dist", "src/i18n/locales/**/*.ts"] },
|
||||||
|
ansv7779 marked this conversation as resolved
|
|||||||
{
|
{
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
|
|||||||
12
frontend/lingui.config.js
Normal file
12
frontend/lingui.config.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from "@lingui/cli";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
sourceLocale: "en",
|
||||||
|
locales: ["sv", "en"],
|
||||||
|
catalogs: [
|
||||||
|
{
|
||||||
|
path: "<rootDir>/src/i18n/locales/{locale}/messages",
|
||||||
|
include: ["src"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
3626
frontend/package-lock.json
generated
3626
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -5,13 +5,17 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "lingui compile --typescript && tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier . --write",
|
"format": "prettier . --write",
|
||||||
"update-api": "openapi-typescript http://localhost:8080/v3/api-docs --output src/lib/api.d.ts",
|
"update-api": "openapi-typescript http://localhost:8080/v3/api-docs --output src/lib/api.d.ts",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"extract": "lingui extract",
|
||||||
|
"extract-clean": "lingui extract --clean",
|
||||||
|
"compile": "lingui compile --typescript"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@lingui/react": "^5.3.0",
|
||||||
"openapi-fetch": "^0.13.5",
|
"openapi-fetch": "^0.13.5",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@ -19,6 +23,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.21.0",
|
"@eslint/js": "^9.21.0",
|
||||||
|
"@lingui/cli": "^5.3.0",
|
||||||
|
"@lingui/swc-plugin": "^5.5.1",
|
||||||
|
"@lingui/vite-plugin": "^5.3.0",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||||
|
|||||||
@ -3,16 +3,23 @@ import suLogoLandscape from "./assets/SU_logo_optimized.svg";
|
|||||||
import { ProfileContext } from "./hooks/profile.ts";
|
import { ProfileContext } from "./hooks/profile.ts";
|
||||||
import Studentportalen from "./Studentportalen.tsx";
|
import Studentportalen from "./Studentportalen.tsx";
|
||||||
import { useViewTransitioningFetch } from "./hooks/fetch";
|
import { useViewTransitioningFetch } from "./hooks/fetch";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { dynamicActivate } from "./i18n/dynamicActivate.ts";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { i18n } from "@lingui/core";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { data: profile, error } = useViewTransitioningFetch("/profile");
|
const { data: profile, error } = useViewTransitioningFetch("/profile");
|
||||||
|
|
||||||
if (!profile) {
|
useEffect(() => {
|
||||||
return splashScreen("Loading...");
|
if (profile?.language) {
|
||||||
}
|
dynamicActivate(profile.language);
|
||||||
if (error) {
|
|
||||||
return splashScreen("Application failed to start");
|
|
||||||
}
|
}
|
||||||
|
}, [profile?.language]);
|
||||||
|
|
||||||
|
if (!profile) return splashScreen(i18n._(t`Loading...`));
|
||||||
|
if (error) return splashScreen(i18n._(t`Application failed to start`));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProfileContext value={profile}>
|
<ProfileContext value={profile}>
|
||||||
@ -23,12 +30,16 @@ function App() {
|
|||||||
|
|
||||||
function splashScreen(extraContent: string) {
|
function splashScreen(extraContent: string) {
|
||||||
return (
|
return (
|
||||||
<div id={"app"}>
|
<div id="app">
|
||||||
<img src={suLogoLandscape} alt={"Stockholm University"} />
|
<img src={suLogoLandscape} alt={i18n._(t`Stockholm University`)} />
|
||||||
<div>
|
<div>
|
||||||
<h1>Student portal</h1>
|
<h1>
|
||||||
<h2>Department of Computer and Systems Sciences</h2>
|
<Trans>Student portal</Trans>
|
||||||
{extraContent}
|
</h1>
|
||||||
|
<h2>
|
||||||
|
<Trans>Department of Computer and Systems Sciences</Trans>
|
||||||
|
</h2>
|
||||||
|
<p>{extraContent}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
12
frontend/src/i18n/dynamicActivate.ts
Normal file
12
frontend/src/i18n/dynamicActivate.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { i18n } from "@lingui/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We do a dynamic import of just the catalog that we need.
|
||||||
|
* Source: https://lingui.dev/guides/dynamic-loading-catalogs
|
||||||
|
* @param locale any locale string
|
||||||
|
*/
|
||||||
|
export async function dynamicActivate(locale: string) {
|
||||||
|
const { messages } = await import(`./locales/${locale}/messages.po`);
|
||||||
|
i18n.load(locale, messages);
|
||||||
|
i18n.activate(locale);
|
||||||
|
}
|
||||||
15
frontend/src/i18n/getDefaultLocale.ts
Normal file
15
frontend/src/i18n/getDefaultLocale.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
const supportedLocales = ["sv", "en"];
|
||||||
|
|
||||||
|
export function getDefaultLocale(): string {
|
||||||
|
const userPreferredLangs = navigator.languages.map(
|
||||||
|
(lang) => lang.split("-")[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const lang of userPreferredLangs) {
|
||||||
|
if (supportedLocales.includes(lang)) {
|
||||||
|
return lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "en"; // fallback to English
|
||||||
|
}
|
||||||
55
frontend/src/i18n/locales/en/messages.po
Normal file
55
frontend/src/i18n/locales/en/messages.po
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"POT-Creation-Date: 2025-04-07 19:53+0200\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=utf-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"X-Generator: @lingui/cli\n"
|
||||||
|
"Language: en\n"
|
||||||
|
"Project-Id-Version: \n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"PO-Revision-Date: \n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#: src/App.tsx:22
|
||||||
|
msgid "Application failed to start"
|
||||||
|
msgstr "Application failed to start"
|
||||||
|
|
||||||
|
#: src/App.tsx:40
|
||||||
|
msgid "Department of Computer and Systems Sciences"
|
||||||
|
msgstr "Department of Computer and Systems Sciences"
|
||||||
|
|
||||||
|
#: src/studentportalen/Home.tsx:10
|
||||||
|
msgid "Here you can see the latest and greatest"
|
||||||
|
msgstr "Here you can see the latest and greatest"
|
||||||
|
|
||||||
|
#: src/studentportalen/Menu.tsx:12
|
||||||
|
msgid "Home"
|
||||||
|
msgstr "Home"
|
||||||
|
|
||||||
|
#: src/studentportalen/Home.tsx:7
|
||||||
|
msgid "Home screen"
|
||||||
|
msgstr "Home screen"
|
||||||
|
|
||||||
|
#: src/App.tsx:21
|
||||||
|
msgid "Loading..."
|
||||||
|
msgstr "Loading..."
|
||||||
|
|
||||||
|
#: src/studentportalen/Menu.tsx:24
|
||||||
|
msgid "Log out"
|
||||||
|
msgstr "Log out"
|
||||||
|
|
||||||
|
#: src/studentportalen/Menu.tsx:21
|
||||||
|
msgid "Profile"
|
||||||
|
msgstr "Profile"
|
||||||
|
|
||||||
|
#: src/App.tsx:34
|
||||||
|
#: src/studentportalen/Header.tsx:12
|
||||||
|
msgid "Stockholm University"
|
||||||
|
msgstr "Stockholm University"
|
||||||
|
|
||||||
|
#: src/App.tsx:37
|
||||||
|
msgid "Student portal"
|
||||||
|
msgstr "Student portal"
|
||||||
1
frontend/src/i18n/locales/en/messages.ts
Normal file
1
frontend/src/i18n/locales/en/messages.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"3C3CY2\":[\"Application failed to start\"],\"xYQvrP\":[\"Department of Computer and Systems Sciences\"],\"z13C6e\":[\"Here you can see the latest and greatest\"],\"i0qMbr\":[\"Home\"],\"p7H6+B\":[\"Home screen\"],\"Z3FXyt\":[\"Loading...\"],\"FgAxTj\":[\"Log out\"],\"vERlcd\":[\"Profile\"],\"he8WSY\":[\"Stockholm University\"],\"07DTL2\":[\"Student portal\"]}")as Messages;
|
||||||
55
frontend/src/i18n/locales/sv/messages.po
Normal file
55
frontend/src/i18n/locales/sv/messages.po
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"POT-Creation-Date: 2025-04-07 19:53+0200\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=utf-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"X-Generator: @lingui/cli\n"
|
||||||
|
"Language: sv\n"
|
||||||
|
"Project-Id-Version: \n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"PO-Revision-Date: \n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#: src/App.tsx:22
|
||||||
|
msgid "Application failed to start"
|
||||||
|
msgstr "Applikationen startade inte"
|
||||||
|
|
||||||
|
#: src/App.tsx:40
|
||||||
|
msgid "Department of Computer and Systems Sciences"
|
||||||
|
msgstr "Institutionen för data- och systemvetenskap"
|
||||||
|
|
||||||
|
#: src/studentportalen/Home.tsx:10
|
||||||
|
msgid "Here you can see the latest and greatest"
|
||||||
|
msgstr "Här kan du hitta det senaste och bästa"
|
||||||
|
|
||||||
|
#: src/studentportalen/Menu.tsx:12
|
||||||
|
msgid "Home"
|
||||||
|
msgstr "Hem"
|
||||||
|
|
||||||
|
#: src/studentportalen/Home.tsx:7
|
||||||
|
msgid "Home screen"
|
||||||
|
msgstr "Hemskärm"
|
||||||
|
|
||||||
|
#: src/App.tsx:21
|
||||||
|
msgid "Loading..."
|
||||||
|
msgstr "Laddar..."
|
||||||
|
|
||||||
|
#: src/studentportalen/Menu.tsx:24
|
||||||
|
msgid "Log out"
|
||||||
|
msgstr "Logga ut"
|
||||||
|
|
||||||
|
#: src/studentportalen/Menu.tsx:21
|
||||||
|
msgid "Profile"
|
||||||
|
msgstr "Profil"
|
||||||
|
|
||||||
|
#: src/App.tsx:34
|
||||||
|
#: src/studentportalen/Header.tsx:12
|
||||||
|
msgid "Stockholm University"
|
||||||
|
msgstr "Stockholms universitet"
|
||||||
|
|
||||||
|
#: src/App.tsx:37
|
||||||
|
msgid "Student portal"
|
||||||
|
msgstr "Studentportal"
|
||||||
1
frontend/src/i18n/locales/sv/messages.ts
Normal file
1
frontend/src/i18n/locales/sv/messages.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"3C3CY2\":[\"Applikationen startade inte\"],\"xYQvrP\":[\"Institutionen för data- och systemvetenskap\"],\"z13C6e\":[\"Här kan du hitta det senaste och bästa\"],\"i0qMbr\":[\"Hem\"],\"p7H6+B\":[\"Hemskärm\"],\"Z3FXyt\":[\"Laddar...\"],\"FgAxTj\":[\"Logga ut\"],\"vERlcd\":[\"Profil\"],\"he8WSY\":[\"Stockholms universitet\"],\"07DTL2\":[\"Studentportal\"]}")as Messages;
|
||||||
@ -2,9 +2,19 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
|
import { I18nProvider } from "@lingui/react";
|
||||||
|
import { i18n } from "@lingui/core";
|
||||||
|
import { dynamicActivate } from "./i18n/dynamicActivate.ts";
|
||||||
|
import { getDefaultLocale } from "./i18n/getDefaultLocale.ts";
|
||||||
|
|
||||||
|
// Activate the default locale from browser settings before a profile is loaded
|
||||||
|
const locale = getDefaultLocale();
|
||||||
|
await dynamicActivate(locale);
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<I18nProvider i18n={i18n}>
|
||||||
<App />
|
<App />
|
||||||
|
</I18nProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,13 +1,16 @@
|
|||||||
import "./header.css";
|
import "./header.css";
|
||||||
import suLogo from "../assets/SU_logo_optimized.svg";
|
import suLogo from "../assets/SU_logo_optimized.svg";
|
||||||
import { useProfile } from "../hooks/profile.ts";
|
import { useProfile } from "../hooks/profile.ts";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { i18n } from "@lingui/core";
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const profile = useProfile();
|
const profile = useProfile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header>
|
<header>
|
||||||
<img src={suLogo} alt="Stockholm University" />
|
<img src={suLogo} alt={i18n._(t`Stockholm University`)} />
|
||||||
|
|
||||||
<span>{profile.name}</span>
|
<span>{profile.name}</span>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1>Home screen</h1>
|
<h1>
|
||||||
<p>Here you can see the latest and greatest</p>
|
<Trans>Home screen</Trans>
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
<Trans>Here you can see the latest and greatest</Trans>
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,28 @@
|
|||||||
import { NavLink } from "react-router";
|
import { NavLink } from "react-router";
|
||||||
import "./menu.css";
|
import "./menu.css";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
export default function Menu() {
|
export default function Menu() {
|
||||||
const [subMenuVisible, setSubMenuVisible] = useState(false);
|
const [subMenuVisible, setSubMenuVisible] = useState(false);
|
||||||
return (
|
return (
|
||||||
<menu className={"main"}>
|
<menu className={"main"}>
|
||||||
<li>
|
<li>
|
||||||
<NavLink to={"/"}>Home</NavLink>
|
<NavLink to={"/"}>
|
||||||
|
<Trans>Home</Trans>
|
||||||
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button onClick={() => setSubMenuVisible((current) => !current)}>
|
<button onClick={() => setSubMenuVisible((current) => !current)}>
|
||||||
<span>| | |</span>
|
<span>| | |</span>
|
||||||
</button>
|
</button>
|
||||||
<menu className={"sub " + (subMenuVisible ? "visible" : "")}>
|
<menu className={"sub " + (subMenuVisible ? "visible" : "")}>
|
||||||
<li>Profile</li>
|
<li>
|
||||||
<li>Log out</li>
|
<Trans>Profile</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Log out</Trans>
|
||||||
|
</li>
|
||||||
</menu>
|
</menu>
|
||||||
</li>
|
</li>
|
||||||
</menu>
|
</menu>
|
||||||
|
|||||||
@ -1,7 +1,15 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react-swc";
|
import react from "@vitejs/plugin-react-swc";
|
||||||
|
import { lingui } from "@lingui/vite-plugin";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
build: {
|
||||||
|
target: "esnext",
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
react({
|
||||||
|
plugins: [["@lingui/swc-plugin", {}]],
|
||||||
|
}),
|
||||||
|
lingui(),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user
Do we need this since the generated
.tsfiles have/*eslint-disable*/at the start?Otherwise you get the warning
Unused eslint-disable directive (no problems were reported)when runnningnpm run lint