diff --git a/docs/design.md b/docs/design.md index 4a1cbde3..59095790 100644 --- a/docs/design.md +++ b/docs/design.md @@ -32,3 +32,6 @@ ## Components * Every actuator is a component * Every sensor is a component + +# Localization +* This page describes easy ways to use the i18n support that I added: https://phrase.com/blog/posts/localizing-react-apps-with-i18next/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b3af31f6..8d40474d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,14 @@ "@types/react-dom": "^19.0.2", "antd": "^5.22.1", "blockly": "^11.1.1", + "i18next": "^24.2.2", + "i18next-http-backend": "^3.0.2", "jszip": "^3.10.1", "lucide-react": "^0.460.0", "re-resizable": "^6.10.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^15.4.1", "react-syntax-highlighter": "^15.6.1", "web-vitals": "^2.1.4" }, @@ -3214,6 +3217,57 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "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-fetch/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/cross-fetch/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/cross-fetch/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/cross-fetch/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/cross-spawn": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", @@ -3918,6 +3972,15 @@ "node": ">=10" } }, + "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/http-proxy-agent": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", @@ -3949,6 +4012,46 @@ "node": ">= 6" } }, + "node_modules/i18next": { + "version": "24.2.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.2.tgz", + "integrity": "sha512-NE6i86lBCKRYZa5TaUDkU5S4HFgLIEJRLr3Whf2psgaxBleQ2LC1YW1Vc+SCgkAW7VEzndT6al6+CzegSUHcTQ==", + "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.23.2" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "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/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -5932,6 +6035,28 @@ "react": "^18.3.1" } }, + "node_modules/react-i18next": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.1.tgz", + "integrity": "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "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": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -6732,10 +6857,10 @@ } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "license": "Apache-2.0", "optional": true, "peer": true, "bin": { @@ -6743,7 +6868,7 @@ "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/typescript-strict-plugin": { @@ -7212,22 +7337,6 @@ } } }, - "node_modules/vite-tsconfig-paths/node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/vite/node_modules/rollup": { "version": "4.34.6", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.6.tgz", @@ -7376,6 +7485,15 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "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/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/package.json b/package.json index f7acd64d..fbd896fc 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,14 @@ "@types/react-dom": "^19.0.2", "antd": "^5.22.1", "blockly": "^11.1.1", + "i18next": "^24.2.2", + "i18next-http-backend": "^3.0.2", "jszip": "^3.10.1", "lucide-react": "^0.460.0", "re-resizable": "^6.10.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^15.4.1", "react-syntax-highlighter": "^15.6.1", "web-vitals": "^2.1.4" }, diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json new file mode 100644 index 00000000..21f146b8 --- /dev/null +++ b/public/locales/en/translation.json @@ -0,0 +1,12 @@ +{ + "mechanism_delete": "Delete Project", + "mechanism_rename": "Rename Project", + "mechanism_copy": "Copy Project", + "opmode_delete": "Delete Project", + "opmode_rename": "Rename Project", + "opmode_copy": "Copy Project", + "project_delete": "Delete Project", + "project_rename": "Rename Project", + "project_copy": "Copy Project", + "fail_list_modules": "Failed to load the list of modules." +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index d56e4554..6539682b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,8 @@ import { UploadOutlined, } from '@ant-design/icons'; +import { useTranslation } from "react-i18next"; + import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { dracula } from 'react-syntax-highlighter/dist/esm/styles/prism'; @@ -117,6 +119,8 @@ const App: React.FC = () => { const PURPOSE_RENAME_MODULE = 'RenameModule'; const PURPOSE_COPY_MODULE = 'CopyModule'; + const { t } = useTranslation(); + const ignoreEffect = () => { if (!import.meta.env.MODE || import.meta.env.MODE === 'development') { // Development mode. @@ -209,9 +213,9 @@ const App: React.FC = () => { } catch (e) { console.log('Failed to load the list of modules. Caught the following error...'); console.log(e); - setAlertErrorMessage('Failed to load the list of modules.'); + setAlertErrorMessage(t("fail_list_modules")); setAlertErrorVisible(true); - reject(new Error('Failed to load the list of modules.')); + reject(new Error(t("fail_list_modules"))); } }); }; @@ -308,17 +312,17 @@ const App: React.FC = () => { if (module != null) { if (module.moduleType == commonStorage.MODULE_TYPE_PROJECT) { - setRenameTooltip('Rename Project'); - setCopyTooltip('Copy Project'); - setDeleteTooltip('Delete Project'); + setRenameTooltip(t("project_rename")); + setCopyTooltip(t("project_copy")); + setDeleteTooltip(t("project_delete")); } else if (module.moduleType == commonStorage.MODULE_TYPE_MECHANISM) { - setRenameTooltip('Rename Mechanism'); - setCopyTooltip('Copy Mechanism'); - setDeleteTooltip('Delete Mechanism'); + setRenameTooltip(t("mechanism_rename")); + setCopyTooltip(t("mechanism_copy")); + setDeleteTooltip(t("mechanism_delete")); } else if (module.moduleType == commonStorage.MODULE_TYPE_OPMODE) { - setRenameTooltip('Rename OpMode'); - setCopyTooltip('Copy OpMode'); - setDeleteTooltip('Delete OpMode'); + setRenameTooltip(t("opmode_rename")); + setCopyTooltip(t("opmode_copy")); + setDeleteTooltip(t("opmode_delete")); } storage.saveEntry('mostRecentModulePath', currentModulePath); diff --git a/src/i18n/config.ts b/src/i18n/config.ts new file mode 100644 index 00000000..66c86bc8 --- /dev/null +++ b/src/i18n/config.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Porpoiseful LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Configuration for i18n + * @author alan@porpoiseful.com (Alan Smith) + * + * This is mostly borrowed with a few adaptations from + * https://phrase.com/blog/posts/localizing-react-apps-with-i18next/ + */ +import i18n from "i18next"; +import HttpApi from "i18next-http-backend"; +import { initReactI18next } from "react-i18next"; + +i18n + // Add backend as a plugin so we can load the needed translation at runtime + .use(HttpApi) + // Add React bindings as a plugin so it will re-render when language changes + .use(initReactI18next) + .init({ + // Config options + + // Specifies the default language (locale) used + // when a user visits our site for the first time. + lng: "en", + + // Fallback locale used when a translation is missing. + fallbackLng: "en", + + // Normally, we want `escapeValue: true` as it ensures that i18next escapes any code in + // translation messages, safeguarding against XSS (cross-site scripting) attacks. However, + // React does this escaping itself, so we turn it off in i18next. + interpolation: { + escapeValue: false, + }, + }); + +export default i18n; \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 032464fb..f1e1cbfd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; import App from './App'; +import "./i18n/config.ts" import reportWebVitals from './reportWebVitals'; const root = ReactDOM.createRoot(