diff --git a/webapp/.gitignore b/webapp/.gitignore index ee05149..1c0c6d6 100644 --- a/webapp/.gitignore +++ b/webapp/.gitignore @@ -23,4 +23,7 @@ dist-ssr *.sln *.sw? -METABASE_LOGIN.json \ No newline at end of file +METABASE_LOGIN.json + +# Translations +public/locales \ No newline at end of file diff --git a/webapp/.prettierrc b/webapp/.prettierrc index 2444c37..78e079f 100644 --- a/webapp/.prettierrc +++ b/webapp/.prettierrc @@ -37,6 +37,8 @@ "^@pages/(.*)$", "^@ui", "^@ui/(.*)$", + "^@shared", + "^@shared/(.*)$", "[../]", "[./]" ], diff --git a/webapp/README.md b/webapp/README.md index 6cbd57d..5034324 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -25,3 +25,4 @@ npm run dev - `npm run preview` : Démarre le serveur à partir du build pour la production (dans le dist) - `npm run check:arch` : Vérifier si les imports du projet respectent le principe de l'architecture FSD. - `npm run arch:tree` : Génère un fichier text contenant l'arborescence du projet. +- `npm run translations:update`: Upload les fichiers de traductions vers public/locales, depuis lequel sont servis les fichiers de traduction diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 0a3f4f3..c49e028 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -21,10 +21,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "coordo": "github:dataforgoodfr/Coordonnees#master", + "i18next": "^25.8.0", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "lucide-react": "^0.562.0", "react": "^19.2.0", "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.0", + "react-i18next": "^16.5.4", "react-resizable-panels": "^3.0.6", "recharts": "^2.15.4" }, @@ -3804,6 +3808,15 @@ "maplibre-gl": "^5.16.0" } }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5037,6 +5050,64 @@ "node": ">= 0.4" } }, + "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==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.0.tgz", + "integrity": "sha512-urrg4HMFFMQZ2bbKRK7IZ8/CTE7D8H4JRlAwqA2ZwDRFfdd0K/4cdbNNLgfn9mo+I/h9wJu61qJzH7jCFAhUZQ==", + "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" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6104,6 +6175,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -6516,6 +6607,33 @@ "react": "^19.2.3" } }, + "node_modules/react-i18next": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz", + "integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -7263,6 +7381,12 @@ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -7387,7 +7511,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7531,6 +7655,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -7628,6 +7761,31 @@ } } }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/webapp/package.json b/webapp/package.json index 49c0cef..4133068 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -14,7 +14,8 @@ "format": "prettier . --write", "format:check": "prettier . --check", "lint": "eslint ./src", - "lint:fix": "eslint ./src --fix" + "lint:fix": "eslint ./src --fix", + "translations:update": "./scripts/update-translations.sh" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", @@ -30,10 +31,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "coordo": "github:dataforgoodfr/Coordonnees#master", + "i18next": "^25.8.0", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "lucide-react": "^0.562.0", "react": "^19.2.0", "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.0", + "react-i18next": "^16.5.4", "react-resizable-panels": "^3.0.6", "recharts": "^2.15.4" }, diff --git a/webapp/scripts/update-translations.sh b/webapp/scripts/update-translations.sh new file mode 100755 index 0000000..c58f3af --- /dev/null +++ b/webapp/scripts/update-translations.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +# Copy src/shared/i18n/translations/{files} -> public/locales/{files} + +cp -R ./src/shared/i18n/translations/ public/locales/ diff --git a/webapp/src/components/Header.tsx b/webapp/src/components/Header.tsx index b187111..b2c512f 100644 --- a/webapp/src/components/Header.tsx +++ b/webapp/src/components/Header.tsx @@ -5,6 +5,9 @@ import logo from "@assets/logo_all4trees.png"; import { Button } from "@ui/button"; +import { useTranslation } from "@shared/i18n"; + +import { LanguageSelecor } from "./LanguageSelector"; import { ModeToggle } from "./ModeToggle"; interface HeaderProps { @@ -14,6 +17,8 @@ interface HeaderProps { } export function Header({ onLogout, onLogin, isLogin }: HeaderProps) { + const { t } = useTranslation("translations"); + return (
@@ -41,6 +46,8 @@ export function Header({ onLogout, onLogin, isLogin }: HeaderProps) { )} +

{t("helloWorld")}

+
diff --git a/webapp/src/components/LanguageSelector.tsx b/webapp/src/components/LanguageSelector.tsx new file mode 100644 index 0000000..9ce3205 --- /dev/null +++ b/webapp/src/components/LanguageSelector.tsx @@ -0,0 +1,55 @@ +import { LanguagesIcon } from "lucide-react"; +import type { FC } from "react"; + +import { Button } from "@ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@ui/dropdown-menu"; + +import { LANGUAGES, i18nInstance } from "@shared/i18n"; + +const LANGUAGES_CONFIGS = [ + { + identifier: LANGUAGES.FRENCH, + fullString: "Français", + flag: "🇫🇷", + }, + { + identifier: LANGUAGES.ENGLISH, + fullString: "English", + flag: "🇬🇧", + }, +] as const; + +export const LanguageSelecor: FC = () => { + return ( + + + + + + + {LANGUAGES_CONFIGS.map((config) => ( + i18nInstance.changeLanguage(config.identifier)} + > + {`${config.flag} - ${config.fullString}`} + + ))} + + + ); +}; diff --git a/webapp/src/shared/i18n/config.ts b/webapp/src/shared/i18n/config.ts new file mode 100644 index 0000000..164e262 --- /dev/null +++ b/webapp/src/shared/i18n/config.ts @@ -0,0 +1,72 @@ +import i18n from "i18next"; +import languageDetector from "i18next-browser-languagedetector"; +import httpBackend from "i18next-http-backend"; +import { initReactI18next } from "react-i18next"; + +export const LANGUAGES = { + FRENCH: "fr", + ENGLISH: "en", +} as const; + +const NAMESPACES = ["translations"]; + +i18n + /** Passes i18n down to react-i18next */ + .use(initReactI18next) + /** + * Language detection plugin used to detect user language in the browser. + * By default, it uses localStorage with i18nextLang key. + * https://www.i18next.com/overview/plugins-and-utils#language-detector + * https://github.com/i18next/i18next-browser-languageDetector + * https://github.com/i18next/i18next-browser-languageDetector?tab=readme-ov-file#detector-options + */ + .use(languageDetector) + /** + * Plugin to serve translations via http request on the CDN public files. + * Options are set in init -> options.backend. + * https://www.i18next.com/overview/plugins-and-utils#backends + * https://github.com/i18next/i18next-http-backend + */ + .use(httpBackend) + .init({ + /** Avoid loading en-US or en-GB when loading en */ + load: "languageOnly", + + /** + * https://github.com/i18next/i18next-http-backend?tab=readme-ov-file#backend-options + */ + backend: { + loadPath: "/locales/{{lng}}/{{ns}}.json", + /** Allow cross domain requests => used for XmlHttpRequest */ + crossDomain: false, + /** Allow credentials on cross domain requests => used for XmlHttpRequest */ + withCredentials: false, + /** OverrideMimeType sets request.overrideMimeType("application/json") => used for XmlHttpRequest */ + overrideMimeType: false, + /** Used for fetch, can also be a function (payload) => ({ method: 'GET' }) */ + requestOptions: { + mode: "cors", + credentials: "same-origin", + cache: "default", + method: "GET", + }, + }, + + fallbackLng: LANGUAGES.FRENCH, + supportedLngs: Object.values(LANGUAGES), + + /** Name of the JSON files - Single namespace to start with */ + ns: NAMESPACES, + + interpolation: { + /** react already safes from xss => https://www.i18next.com/translation-function/interpolation#unescape */ + escapeValue: false, + }, + }); + +i18n.on("languageChanged", (lng) => { + /** @todo Update Intl Locale */ + console.log("Language changed to ", lng); +}); + +export default i18n; diff --git a/webapp/src/shared/i18n/i18next.d.ts b/webapp/src/shared/i18n/i18next.d.ts new file mode 100644 index 0000000..25dbd68 --- /dev/null +++ b/webapp/src/shared/i18n/i18next.d.ts @@ -0,0 +1,20 @@ +// import the original type declarations +import "i18next"; + +// import all namespaces (for the default language, only) +import translations from "./translations/fr/translations.json"; + +// import namespace2 from "./translations/fr/namespace2.json" + +declare module "i18next" { + // Extend CustomTypeOptions + interface CustomTypeOptions { + // custom namespace type, if you changed it + defaultNS: "translations"; + // custom resources type + resources: { + translations: typeof translations; + // namespace2: typeof namespace2; + }; + } +} diff --git a/webapp/src/shared/i18n/index.ts b/webapp/src/shared/i18n/index.ts new file mode 100644 index 0000000..8cdc2be --- /dev/null +++ b/webapp/src/shared/i18n/index.ts @@ -0,0 +1,2 @@ +export { useTranslation } from "react-i18next"; +export { default as i18nInstance, LANGUAGES } from "./config"; diff --git a/webapp/src/shared/i18n/translations/en/translations.json b/webapp/src/shared/i18n/translations/en/translations.json new file mode 100644 index 0000000..a1929d6 --- /dev/null +++ b/webapp/src/shared/i18n/translations/en/translations.json @@ -0,0 +1,3 @@ +{ + "helloWorld": "Hello world !" +} diff --git a/webapp/src/shared/i18n/translations/fr/translations.json b/webapp/src/shared/i18n/translations/fr/translations.json new file mode 100644 index 0000000..17ccfe1 --- /dev/null +++ b/webapp/src/shared/i18n/translations/fr/translations.json @@ -0,0 +1,3 @@ +{ + "helloWorld": "Bonjour le monde !" +} diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json index b5d6e68..3fde866 100644 --- a/webapp/tsconfig.json +++ b/webapp/tsconfig.json @@ -21,7 +21,8 @@ "@widgets": ["./src/widgets/index.ts"], "@widgets/*": ["./src/widgets/*"], "@ui/*": ["./src/components/ui/*"], - "@assets/*": ["./src/assets/*"] + "@assets/*": ["./src/assets/*"], + "@shared/*": ["./src/shared/*"] } } } diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts index 8af3926..a08b521 100644 --- a/webapp/vite.config.ts +++ b/webapp/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ "@assets": path.resolve(__dirname, "./src/assets"), "@pages": path.resolve(__dirname, "./src/pages"), "@ui": path.resolve(__dirname, "./src/components/ui"), + "@shared": path.resolve(__dirname, "./src/shared"), }, }, plugins: [react(), tailwindcss()],