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
-
-
-
-
-
-
({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 (
<>
-
+ {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),