diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8b6e4cc..5b985f5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,8 @@ "echarts": "^5.3.2", "echarts-for-react": "^3.0.2", "fomantic-ui-less": "^2.8.8", + "i18next-browser-languagedetector": "^6.1.4", + "i18next-http-backend": "^1.4.1", "immer": "^9.0.7", "luxon": "^1.28.0", "maplibre-gl": "^1.15.2", @@ -30,6 +32,7 @@ "react-dom": "^17.0.2", "react-helmet": "^6.1.0", "react-hook-form": "^6.15.8", + "react-i18next": "^11.18.1", "react-map-gl": "^6.1.17", "react-markdown": "^5.0.3", "react-redux": "^7.2.6", @@ -42,7 +45,8 @@ "sass": "^1.43.5", "semantic-ui-react": "^2.0.4", "ts-loader": "^9.2.6", - "typescript": "^4.7.4" + "typescript": "^4.7.4", + "yaml-loader": "^0.8.0" }, "devDependencies": { "@babel/core": "^7.16.0", @@ -4162,6 +4166,14 @@ "node": ">=10" } }, + "node_modules/cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "dependencies": { + "node-fetch": "2.6.7" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -6251,6 +6263,14 @@ "node": ">= 12" } }, + "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/html-to-react": { "version": "1.4.7", "resolved": "https://registry.npmjs.org/html-to-react/-/html-to-react-1.4.7.tgz", @@ -6385,6 +6405,45 @@ "node": ">=10.17.0" } }, + "node_modules/i18next": { + "version": "21.8.14", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.8.14.tgz", + "integrity": "sha512-4Yi+DtexvMm/Yw3Q9fllzY12SgLk+Mcmar+rCAccsOPul/2UmnBzoHbTGn/L48IPkFcmrNaH7xTLboBWIbH6pw==", + "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" + } + ], + "peer": true, + "dependencies": { + "@babel/runtime": "^7.17.2" + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-6.1.4.tgz", + "integrity": "sha512-wukWnFeU7rKIWT66VU5i8I+3Zc4wReGcuDK2+kuFhtoxBRGWGdvYI9UQmqNL/yQH1KogWwh+xGEaIPH8V/i2Zg==", + "dependencies": { + "@babel/runtime": "^7.14.6" + } + }, + "node_modules/i18next-http-backend": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-1.4.1.tgz", + "integrity": "sha512-s4Q9hK2jS29iyhniMP82z+yYY8riGTrWbnyvsSzi5TaF7Le4E7b5deTmtuaRuab9fdDcYXtcwdBgawZG+JCEjA==", + "dependencies": { + "cross-fetch": "3.1.5" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -7020,6 +7079,11 @@ "node": ">=0.10.0" } }, + "node_modules/javascript-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz", + "integrity": "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==" + }, "node_modules/jest-worker": { "version": "27.3.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.3.1.tgz", @@ -7867,6 +7931,25 @@ "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", "dev": true }, + "node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "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-forge": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", @@ -9251,6 +9334,27 @@ "react": "^16.8.0 || ^17" } }, + "node_modules/react-i18next": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.1.tgz", + "integrity": "sha512-S8cl4mvIOSA7OQCE5jNy2yhv705Vwi+7PinpqKIYcBmX/trJtHKqrf6CL67WJSA8crr2JU+oxE9jn9DQIrQezg==", + "dependencies": { + "@babel/runtime": "^7.14.5", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 19.0.0", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -10596,6 +10700,11 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/trough": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", @@ -10971,6 +11080,14 @@ "@math.gl/web-mercator": "^3.5.5" } }, + "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/vt-pbf": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", @@ -11010,6 +11127,11 @@ "minimalistic-assert": "^1.0.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==" + }, "node_modules/webpack": { "version": "5.64.4", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.64.4.tgz", @@ -11275,6 +11397,15 @@ "node": ">=0.8.0" } }, + "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==", + "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", @@ -11375,6 +11506,40 @@ "node": ">= 6" } }, + "node_modules/yaml-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/yaml-loader/-/yaml-loader-0.8.0.tgz", + "integrity": "sha512-LjeKnTzVBKWiQBeE2L9ssl6WprqaUIxCSNs5tle8PaDydgu3wVFXTbMfsvF2MSErpy9TDVa092n4q6adYwJaWg==", + "dependencies": { + "javascript-stringify": "^2.0.1", + "loader-utils": "^2.0.0", + "yaml": "^2.0.0" + }, + "engines": { + "node": ">= 12.13" + } + }, + "node_modules/yaml-loader/node_modules/loader-utils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz", + "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/yaml-loader/node_modules/yaml": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.1.1.tgz", + "integrity": "sha512-o96x3OPo8GjWeSLF+wOAbrPfhFOGY0W00GNaxCDv+9hkcDJEnev1yh8S7pgHF0ik6zc8sQLuL8hjHjJULZp8bw==", + "engines": { + "node": ">= 14" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -14399,6 +14564,14 @@ "yaml": "^1.10.0" } }, + "cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "requires": { + "node-fetch": "2.6.7" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -16002,6 +16175,14 @@ } } }, + "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==", + "requires": { + "void-elements": "3.1.0" + } + }, "html-to-react": { "version": "1.4.7", "resolved": "https://registry.npmjs.org/html-to-react/-/html-to-react-1.4.7.tgz", @@ -16100,6 +16281,31 @@ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true }, + "i18next": { + "version": "21.8.14", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.8.14.tgz", + "integrity": "sha512-4Yi+DtexvMm/Yw3Q9fllzY12SgLk+Mcmar+rCAccsOPul/2UmnBzoHbTGn/L48IPkFcmrNaH7xTLboBWIbH6pw==", + "peer": true, + "requires": { + "@babel/runtime": "^7.17.2" + } + }, + "i18next-browser-languagedetector": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-6.1.4.tgz", + "integrity": "sha512-wukWnFeU7rKIWT66VU5i8I+3Zc4wReGcuDK2+kuFhtoxBRGWGdvYI9UQmqNL/yQH1KogWwh+xGEaIPH8V/i2Zg==", + "requires": { + "@babel/runtime": "^7.14.6" + } + }, + "i18next-http-backend": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-1.4.1.tgz", + "integrity": "sha512-s4Q9hK2jS29iyhniMP82z+yYY8riGTrWbnyvsSzi5TaF7Le4E7b5deTmtuaRuab9fdDcYXtcwdBgawZG+JCEjA==", + "requires": { + "cross-fetch": "3.1.5" + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -16509,6 +16715,11 @@ "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", "dev": true }, + "javascript-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz", + "integrity": "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==" + }, "jest-worker": { "version": "27.3.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.3.1.tgz", @@ -17175,6 +17386,14 @@ } } }, + "node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "requires": { + "whatwg-url": "^5.0.0" + } + }, "node-forge": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", @@ -18149,6 +18368,15 @@ "integrity": "sha512-prq82ofMbnRyj5wqDe8hsTRcdR25jQ+B8KtCS7BLCzjFHAwNuCjRwzPuP4eYLsEBjEIeYd6try+pdLdw0kPkpg==", "requires": {} }, + "react-i18next": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.1.tgz", + "integrity": "sha512-S8cl4mvIOSA7OQCE5jNy2yhv705Vwi+7PinpqKIYcBmX/trJtHKqrf6CL67WJSA8crr2JU+oxE9jn9DQIrQezg==", + "requires": { + "@babel/runtime": "^7.14.5", + "html-parse-stringify": "^3.0.1" + } + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -19174,6 +19402,11 @@ "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", "dev": true }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "trough": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", @@ -19458,6 +19691,11 @@ "@math.gl/web-mercator": "^3.5.5" } }, + "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==" + }, "vt-pbf": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", @@ -19494,6 +19732,11 @@ "minimalistic-assert": "^1.0.0" } }, + "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==" + }, "webpack": { "version": "5.64.4", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.64.4.tgz", @@ -19672,6 +19915,15 @@ "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", "dev": true }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -19740,6 +19992,33 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" }, + "yaml-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/yaml-loader/-/yaml-loader-0.8.0.tgz", + "integrity": "sha512-LjeKnTzVBKWiQBeE2L9ssl6WprqaUIxCSNs5tle8PaDydgu3wVFXTbMfsvF2MSErpy9TDVa092n4q6adYwJaWg==", + "requires": { + "javascript-stringify": "^2.0.1", + "loader-utils": "^2.0.0", + "yaml": "^2.0.0" + }, + "dependencies": { + "loader-utils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz", + "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==", + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + }, + "yaml": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.1.1.tgz", + "integrity": "sha512-o96x3OPo8GjWeSLF+wOAbrPfhFOGY0W00GNaxCDv+9hkcDJEnev1yh8S7pgHF0ik6zc8sQLuL8hjHjJULZp8bw==" + } + } + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2fd9ef2..179eca1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,8 @@ "echarts": "^5.3.2", "echarts-for-react": "^3.0.2", "fomantic-ui-less": "^2.8.8", + "i18next-browser-languagedetector": "^6.1.4", + "i18next-http-backend": "^1.4.1", "immer": "^9.0.7", "luxon": "^1.28.0", "maplibre-gl": "^1.15.2", @@ -29,6 +31,7 @@ "react-dom": "^17.0.2", "react-helmet": "^6.1.0", "react-hook-form": "^6.15.8", + "react-i18next": "^11.18.1", "react-map-gl": "^6.1.17", "react-markdown": "^5.0.3", "react-redux": "^7.2.6", @@ -41,7 +44,8 @@ "sass": "^1.43.5", "semantic-ui-react": "^2.0.4", "ts-loader": "^9.2.6", - "typescript": "^4.7.4" + "typescript": "^4.7.4", + "yaml-loader": "^0.8.0" }, "eslintConfig": { "extends": [ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8dd3d57..b8a087b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,9 +7,11 @@ import {useObservable} from 'rxjs-hooks' import {from} from 'rxjs' import {pluck} from 'rxjs/operators' import {Helmet} from "react-helmet"; +import {useTranslation} from 'react-i18next' import {useConfig} from 'config' import styles from './App.module.less' +import {AVAILABLE_LOCALES, setLocale} from 'i18n' import { ExportPage, @@ -58,6 +60,7 @@ function Banner({text, style = 'warning'}: {text: string; style: 'warning' | 'in } const App = connect((state) => ({login: state.login}))(function App({login}) { + const {t} = useTranslation() const config = useConfig() const apiVersion = useObservable(() => from(api.get('/info')).pipe(pluck('version'))) @@ -210,12 +213,6 @@ const App = connect((state) => ({login: state.login}))(function App({login}) { Imprint - - - - -
Info
- ({login: state.login}))(function App({login}) { target="_blank" rel="noreferrer" > - {apiVersion ? `v${apiVersion}` : 'Fetching version...'} + Version {apiVersion ? `v${apiVersion}` : 'Fetching version...'}
+ + +
{t('App.footer.changeLanguage')}
+ + {AVAILABLE_LOCALES.map(locale => setLocale(locale)}>{t(`locales.${locale}`)})} + +
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts new file mode 100644 index 0000000..34f3e59 --- /dev/null +++ b/frontend/src/i18n.ts @@ -0,0 +1,95 @@ +import { useState, useEffect, useMemo } from "react"; +import i18next, { TOptions } from "i18next"; +import { BehaviorSubject, combineLatest } from "rxjs"; +import { map, distinctUntilChanged } from "rxjs/operators"; +import HttpBackend, { + BackendOptions, + RequestCallback, +} from "i18next-http-backend"; +import { initReactI18next } from "react-i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; + +export type AvailableLocales = "en" | "de"; + +async function request( + _options: BackendOptions, + url: string, + _payload: any, + callback: RequestCallback +) { + try { + const [lng] = url.split("/"); + const locale = await import(`translations/${lng}.yaml`); + callback(null, { status: 200, data: locale }); + } catch (e) { + console.error(`Unable to load locale at ${url}\n`, e); + callback(null, { status: 404, data: String(e) }); + } +} + +export const AVAILABLE_LOCALES: AvailableLocales[] = ["en", "de"]; + +const i18n = i18next.createInstance(); + +const options: TOptions = { + fallbackLng: "en", + + ns: ["common"], + defaultNS: "common", + whitelist: AVAILABLE_LOCALES, + + // loading via webpack + backend: { + loadPath: "{{lng}}/{{ns}}", + parse: (data: any) => data, + request, + }, + + load: "languageOnly", + + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + }, +}; + +i18n + .use(HttpBackend) + .use(initReactI18next) + .use(LanguageDetector) + .init({ ...options }); + +const locale$ = new BehaviorSubject("en"); + +export const translate = i18n.t.bind(i18n); + +export const translate$ = (stringAndData$: [string, any]) => + combineLatest([stringAndData$, locale$.pipe(distinctUntilChanged())]).pipe( + map(([stringAndData]) => { + if (typeof stringAndData === "string") { + return i18n.t(stringAndData); + } else { + const [string, data] = stringAndData; + return i18n.t(string, { data }); + } + }) + ); + +export const setLocale = (locale: AvailableLocales) => { + i18n.changeLanguage(locale); + locale$.next(locale); +}; + +export function useLocale() { + const [, reload] = useState(); + + useEffect(() => { + i18n.on("languageChanged", reload); + return () => { + i18n.off("languageChanged", reload); + }; + }, []); + + return i18n.language; +} + +export default i18n; diff --git a/frontend/src/index.js b/frontend/src/index.js index d092360..e23cc49 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -1,4 +1,4 @@ -import React from 'react' +import React, {Suspense} from 'react' import {Settings} from 'luxon' import ReactDOM from 'react-dom' import 'fomantic-ui-less/semantic.less' @@ -11,13 +11,16 @@ import 'maplibre-gl/dist/maplibre-gl.css' import {Provider} from 'react-redux' import store from './store' +import './i18n' // TODO: remove Settings.defaultLocale = 'de-DE' ReactDOM.render( - + + + , document.getElementById('root') ) diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 5d803ae..1f6f3b8 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -4,6 +4,7 @@ import {Message, Grid, Loader, Header, Item} from 'semantic-ui-react' import {useObservable} from 'rxjs-hooks' import {of, from} from 'rxjs' import {map, switchMap} from 'rxjs/operators' +import {useTranslation} from 'react-i18next' import api from 'api' import {Stats, Page, Map} from 'components' @@ -22,9 +23,10 @@ function MostRecentTrack() { [] ) + const {t} = useTranslation() return ( <> -
Most recent track
+
{t('HomePage.mostRecentTrack')}
{track === undefined ? ( diff --git a/frontend/src/translations/de.yaml b/frontend/src/translations/de.yaml new file mode 100644 index 0000000..1e7eba1 --- /dev/null +++ b/frontend/src/translations/de.yaml @@ -0,0 +1,6 @@ +HomePage: + mostRecentTrack: Neueste Aufzeichnung + +App: + footer: + changeLanguage: Sprache wechseln diff --git a/frontend/src/translations/en.yaml b/frontend/src/translations/en.yaml new file mode 100644 index 0000000..c123b3c --- /dev/null +++ b/frontend/src/translations/en.yaml @@ -0,0 +1,10 @@ +HomePage: + mostRecentTrack: Most recent track + +locales: + en: English + de: Deutsch + +App: + footer: + changeLanguage: Change language diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 3206622..345fbae 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -207,6 +207,14 @@ module.exports = function (webpackEnv) { }, }, }, + { + test: /\.ya?ml$/, + type: 'json', + use: [{ + loader: 'yaml-loader', + options: {asJSON: true}, + }], + }, { test: /\.css$/i, use: getStyleLoaders(false),